@checkbox-site/cli 0.4.3 → 0.4.5

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 (3) hide show
  1. package/README.md +5 -3
  2. package/dist/checkbox.js +1086 -366
  3. package/package.json +5 -2
package/dist/checkbox.js CHANGED
@@ -123,7 +123,37 @@ var init_args = __esm({
123
123
  // src/config.ts
124
124
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
125
125
  import { homedir } from "node:os";
126
- import { join } from "node:path";
126
+ import { dirname, join, resolve } from "node:path";
127
+ function readEnvValue(text, name) {
128
+ const re = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=[ \\t]*(?:"([^"\\r\\n]*)"|'([^'\\r\\n]*)'|([^\\s#]*))[ \\t]*(?:#.*)?$`, "gm");
129
+ let found;
130
+ for (let m = re.exec(text); m; m = re.exec(text)) found = (m[1] ?? m[2] ?? m[3] ?? "").trim();
131
+ return found || void 0;
132
+ }
133
+ function findProjectKey(cwd, env2 = {}) {
134
+ const home = resolve(env2.HOME || homedir());
135
+ let dir = resolve(cwd);
136
+ for (let depth = 0; depth < 8; depth++) {
137
+ for (const f of PROJECT_KEY_FILES) {
138
+ let text;
139
+ try {
140
+ text = readFileSync(join(dir, f), "utf8");
141
+ } catch {
142
+ continue;
143
+ }
144
+ const v = readEnvValue(text, "CHECKBOX_SECRET_KEY");
145
+ if (v && looksLikeApiKey(v)) {
146
+ const up = depth === 0 ? "" : "../".repeat(depth);
147
+ return { apiKey: v, file: `${up}${f}` };
148
+ }
149
+ }
150
+ if (existsSync(join(dir, ".git")) || dir === home) break;
151
+ const parent = dirname(dir);
152
+ if (parent === dir) break;
153
+ dir = parent;
154
+ }
155
+ return void 0;
156
+ }
127
157
  function configDir(env2) {
128
158
  if (env2.CHECKBOX_CONFIG_DIR) return env2.CHECKBOX_CONFIG_DIR;
129
159
  const base = env2.XDG_CONFIG_HOME || join(env2.HOME || homedir(), ".config");
@@ -167,27 +197,35 @@ function createConfigStore(env2) {
167
197
  }
168
198
  };
169
199
  }
170
- function resolveCredentials(env2, stored, flags) {
200
+ function resolveCredentials(env2, stored, flags, project) {
171
201
  const apiBase = (flags.apiBase || env2.CHECKBOX_API_BASE || stored.api_base || DEFAULT_API_BASE).replace(/\/+$/, "");
172
202
  if (flags.apiKey) return { apiKey: flags.apiKey, apiBase, source: "flag" };
173
203
  if (env2.CHECKBOX_API_KEY) return { apiKey: env2.CHECKBOX_API_KEY, apiBase, source: "env" };
174
204
  if (env2.CHECKBOX_SECRET_KEY) return { apiKey: env2.CHECKBOX_SECRET_KEY, apiBase, source: "env" };
205
+ if (project) return { apiKey: project.apiKey, apiBase, source: "project", file: project.file };
175
206
  if (stored.api_key) return { apiKey: stored.api_key, apiBase, source: "config" };
176
207
  return void 0;
177
208
  }
209
+ function describeKeySource(c, env2 = {}) {
210
+ if (c.source === "flag") return "--api-key";
211
+ if (c.source === "env") return env2.CHECKBOX_API_KEY ? "CHECKBOX_API_KEY" : "CHECKBOX_SECRET_KEY in the environment";
212
+ if (c.source === "project") return `this project's ${c.file ?? ".env.local"}`;
213
+ return "your saved login (checkbox login)";
214
+ }
178
215
  function maskKey(key) {
179
- const m = /^([a-z]{2}_(?:live|test)_)/.exec(key);
216
+ const m = /^([a-z]{2,3}_(?:live|test)_)/.exec(key);
180
217
  const prefix = m?.[1] ?? key.slice(0, 3);
181
218
  return `${prefix}\u2026${key.slice(-4)}`;
182
219
  }
183
220
  function looksLikeApiKey(key) {
184
221
  return /^(?:ckr|ck|rk|sk)_(?:live|test)_[A-Za-z0-9_-]{8,}$/.test(key.trim());
185
222
  }
186
- var DEFAULT_API_BASE;
223
+ var DEFAULT_API_BASE, PROJECT_KEY_FILES;
187
224
  var init_config = __esm({
188
225
  "src/config.ts"() {
189
226
  "use strict";
190
227
  DEFAULT_API_BASE = "https://app.checkbox.site";
228
+ PROJECT_KEY_FILES = [".env.local", ".env", ".checkbox/keys.env"];
191
229
  }
192
230
  });
193
231
 
@@ -2444,7 +2482,7 @@ var init_operations = __esm({
2444
2482
  /**
2445
2483
  * Connect a billing platform.
2446
2484
  *
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.
2485
+ * 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
2486
  *
2449
2487
  * @param site Unique identifier of the site (site_…).
2450
2488
  * @see POST /v1/sites/{site}/integrations (operation `connectBillingPlatform`, scope `sites:write`)
@@ -2874,7 +2912,7 @@ var init_operations = __esm({
2874
2912
  /**
2875
2913
  * List due notices.
2876
2914
  *
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.
2915
+ * 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
2916
  *
2879
2917
  * @see GET /v1/notices/due (operation `listDueNotices`, scope `notices:read`)
2880
2918
  */
@@ -2904,7 +2942,7 @@ var init_operations = __esm({
2904
2942
  /**
2905
2943
  * Retrieve a due notice.
2906
2944
  *
2907
- * One notice you owe a subscriber, with its window, recommended send date, copy-ready email, BCC address and send status.
2945
+ * 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
2946
  *
2909
2947
  * @param notice Unique identifier of the due notice (ntd_…).
2910
2948
  * @see GET /v1/notices/{notice} (operation `retrieveDueNotice`, scope `notices:read`)
@@ -2972,7 +3010,7 @@ var init_operations = __esm({
2972
3010
  /**
2973
3011
  * Check Stripe Checkout Session params.
2974
3012
  *
2975
- * Pass exactly the object your code gives stripe.checkout.sessions.create (plus merchant.merchant_name and merchant.cancel_url, and merchant.price/interval when line_items use a Price id). Checkbox models the Stripe-hosted page those params render and returns findings plus `correction`: a JSON Patch (RFC 6902) and the full corrected params (custom_text.submit with the renewal terms, consent_collection.terms_of_service = "required", custom_text.terms_of_service_acceptance as the consent label), verified by the engine. Stripe's own small grey styling keeps "clear and conspicuous" at needs_review on any Stripe-hosted page; also show the terms on your page before redirecting. Stateless and deterministic: nothing is stored, the same input on the same as_of date gives the same verdicts. Every verdict comes from the Checkbox rules engine over the legal catalog (federal ROSCA / EFTA plus the state automatic-renewal laws); the response carries rules_version, catalog_version and a disclaimer (not legal advice). Read `verdict`, then `next_action`: it tells you exactly what to change and to call the check again until verdict is pass.
3013
+ * Body: `{ params, merchant }` (or `{ params, site }`). `params` is exactly the object your code gives stripe.checkout.sessions.create; `merchant` adds merchant_name, cancel_url, and price/interval when line_items use a Price id. Checkbox models the Stripe-hosted page those params render and returns findings plus `correction`: a JSON Patch (RFC 6902) and the full corrected params (custom_text.submit with the renewal terms, consent_collection.terms_of_service = "required", custom_text.terms_of_service_acceptance as the consent label), verified by the engine. Stripe's own small grey styling keeps "clear and conspicuous" at needs_review on any Stripe-hosted page; also show the terms on your page before redirecting. Stateless and deterministic: nothing is stored, the same input on the same as_of date gives the same verdicts. Every verdict comes from the Checkbox rules engine over the legal catalog (federal ROSCA / EFTA plus the state automatic-renewal laws); the response carries rules_version, catalog_version and a disclaimer (not legal advice). Read `verdict`, then `next_action`: it tells you exactly what to change and to call the check again until verdict is pass.
2976
3014
  *
2977
3015
  * @see POST /v1/checks/stripe_checkout_session (operation `checkStripeCheckoutSession`, scope `checks:read`)
2978
3016
  */
@@ -3257,7 +3295,7 @@ var init_operations = __esm({
3257
3295
  /**
3258
3296
  * Store a billing test-mode key.
3259
3297
  *
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.
3298
+ * 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
3299
  *
3262
3300
  * @param site Unique identifier of the site (site_…).
3263
3301
  * @see POST /v1/sites/{site}/test_credentials (operation `createTestCredential`, scope `test_runs:write`)
@@ -3365,7 +3403,7 @@ var init_operations = __esm({
3365
3403
  /**
3366
3404
  * Start a lifecycle test run.
3367
3405
  *
3368
- * Starts a synthetic test subscription in TEST mode and returns it queued. Stripe runs mirror your checkout into a test-mode Checkout Session, subscribe a test customer (card 4242) whose email is a Checkbox inbox, wait for your acknowledgment, advance a test clock through every legal notice window before trial end and renewal, cancel online in the customer portal and wait for the confirmation; then delete the test clock. Usually takes 2-4 minutes (longer when your own emails are slow to arrive): poll retrieveTestRun (next_action says when) or listen for test_run.completed. Each step reports observations, check results and agent-readable fixes; apply them and run again to verify.
3406
+ * Starts a synthetic test subscription in TEST mode and returns it queued. Stripe runs mirror your checkout into a test-mode Checkout Session, subscribe a test customer (card 4242) whose email is a Checkbox inbox, wait for your acknowledgment, advance a test clock through every legal notice window before trial end and renewal, cancel online in the customer portal and wait for the confirmation; then delete the test clock. Usually takes 1-2 minutes; a real_checkout run a few minutes (longer when your own emails are slow to arrive): poll retrieveTestRun (next_action says when) or listen for test_run.completed. Each step reports observations, check results and agent-readable fixes; apply them and run again to verify.
3369
3407
  *
3370
3408
  * @param site Unique identifier of the site (site_…).
3371
3409
  * @see POST /v1/sites/{site}/test_runs (operation `createTestRun`, scope `test_runs:write`, live mode needs an activated account)
@@ -3579,7 +3617,7 @@ var SDK_VERSION;
3579
3617
  var init_version = __esm({
3580
3618
  "../sdk-node/src/version.ts"() {
3581
3619
  "use strict";
3582
- SDK_VERSION = "0.2.1";
3620
+ SDK_VERSION = "0.2.3";
3583
3621
  }
3584
3622
  });
3585
3623
 
@@ -3768,9 +3806,9 @@ var init_client = __esm({
3768
3806
  const p = globalThis.process;
3769
3807
  return p?.env?.[name] || void 0;
3770
3808
  };
3771
- sleep = (ms, signal) => new Promise((resolve9, reject) => {
3809
+ sleep = (ms, signal) => new Promise((resolve10, reject) => {
3772
3810
  if (signal?.aborted) return reject(signal.reason);
3773
- const t = setTimeout(resolve9, ms);
3811
+ const t = setTimeout(resolve10, ms);
3774
3812
  signal?.addEventListener("abort", () => (clearTimeout(t), reject(signal.reason)), { once: true });
3775
3813
  });
3776
3814
  Checkbox = class {
@@ -4164,7 +4202,7 @@ var init_types2 = __esm({
4164
4202
 
4165
4203
  // src/run.ts
4166
4204
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
4167
- import { dirname, join as join2 } from "node:path";
4205
+ import { dirname as dirname2, join as join2 } from "node:path";
4168
4206
  import { fileURLToPath, pathToFileURL } from "node:url";
4169
4207
  function registerCommands(registry) {
4170
4208
  REGISTRY = registry;
@@ -4208,11 +4246,11 @@ async function listCommands(dir = COMMANDS_DIR) {
4208
4246
  }
4209
4247
  function readAll(stream) {
4210
4248
  if (!stream || stream.isTTY) return Promise.resolve("");
4211
- return new Promise((resolve9, reject) => {
4249
+ return new Promise((resolve10, reject) => {
4212
4250
  let data = "";
4213
4251
  stream.setEncoding?.("utf8");
4214
4252
  stream.on("data", (c) => data += c.toString());
4215
- stream.on("end", () => resolve9(data));
4253
+ stream.on("end", () => resolve10(data));
4216
4254
  stream.on("error", reject);
4217
4255
  });
4218
4256
  }
@@ -4230,7 +4268,8 @@ async function helpText(dir) {
4230
4268
  "",
4231
4269
  "Global flags:",
4232
4270
  " --json One JSON document on stdout (for agents and scripts)",
4233
- " --api-key <key> Use this key (default: $CHECKBOX_API_KEY, then the key saved by `checkbox login`)",
4271
+ " --api-key <key> Use this key (default: $CHECKBOX_API_KEY, then CHECKBOX_SECRET_KEY from the environment or",
4272
+ " this project's .env.local / .env, then the key saved by `checkbox login`)",
4234
4273
  " --api-base <url> API base URL (default: $CHECKBOX_API_BASE or https://app.checkbox.site)",
4235
4274
  " -q, --quiet Only print errors",
4236
4275
  " --no-color Plain output",
@@ -4293,7 +4332,18 @@ async function run(argv, io) {
4293
4332
  }
4294
4333
  const flags = { json: g.json, quiet: g.quiet, color, help: false, ...g.apiKey ? { apiKey: g.apiKey } : {}, ...g.apiBase ? { apiBase: g.apiBase } : {} };
4295
4334
  const config = createConfigStore(io.env);
4296
- const credentials = () => resolveCredentials(io.env, config.load(), flags);
4335
+ let projectKey = null;
4336
+ const project = () => projectKey === null ? projectKey = findProjectKey(io.cwd, io.env) : projectKey;
4337
+ const credentials = () => resolveCredentials(io.env, config.load(), flags, project());
4338
+ let saidWhichKey = false;
4339
+ const sayWhichKey = (c) => {
4340
+ if (saidWhichKey || !c) return;
4341
+ saidWhichKey = true;
4342
+ const stored = config.load().api_key;
4343
+ if (c.source !== "config" && stored && stored !== c.apiKey) {
4344
+ out.info(out.c.dim(`Using the key from ${describeKeySource(c, io.env)} (${maskKey(c.apiKey)}), not your saved login (${maskKey(stored)}).`));
4345
+ }
4346
+ };
4297
4347
  const ctx = {
4298
4348
  argv: rest,
4299
4349
  flags,
@@ -4307,6 +4357,7 @@ async function run(argv, io) {
4307
4357
  const c = credentials();
4308
4358
  const apiKey = o.apiKey ?? c?.apiKey;
4309
4359
  if (!apiKey) throw new NotLoggedInError();
4360
+ if (!o.apiKey) sayWhichKey(c);
4310
4361
  return createApiClient({ apiKey, apiBase: o.apiBase ?? c?.apiBase ?? resolveCredentials(io.env, {}, flags)?.apiBase ?? "https://app.checkbox.site", userAgent: `checkbox-cli/${CLI_VERSION}`, ...io.fetch ? { fetch: io.fetch } : {} });
4311
4362
  },
4312
4363
  readStdin: () => readAll(io.stdin),
@@ -4315,10 +4366,21 @@ async function run(argv, io) {
4315
4366
  try {
4316
4367
  return await cmd.run(ctx);
4317
4368
  } catch (err) {
4318
- return reportError(out, err);
4369
+ return reportError(out, err, { credentials: credentials(), project: project() ?? void 0, env: io.env });
4370
+ }
4371
+ }
4372
+ function authNextAction(ctxInfo) {
4373
+ const used = ctxInfo?.credentials;
4374
+ const proj = ctxInfo?.project;
4375
+ if (proj && used && proj.apiKey !== used.apiKey) {
4376
+ return {
4377
+ type: "use_project_key",
4378
+ message: `The key from ${describeKeySource(used, ctxInfo?.env)} (${maskKey(used.apiKey)}) was rejected, but this project's ${proj.file} has another key (${maskKey(proj.apiKey)}). Unset ${used.source === "env" ? "that environment variable" : "the --api-key flag"} so the CLI uses it, or check that the right key is in ${proj.file}.`
4379
+ };
4319
4380
  }
4381
+ return void 0;
4320
4382
  }
4321
- function reportError(out, err) {
4383
+ function reportError(out, err, auth) {
4322
4384
  if (err instanceof UsageError) {
4323
4385
  out.error({ message: err.message, code: "usage_error", type: "usage_error" });
4324
4386
  return EXIT.usage;
@@ -4341,7 +4403,10 @@ function reportError(out, err) {
4341
4403
  ...err.requestId ? { request_id: err.requestId } : {},
4342
4404
  ...err.docUrl ? { doc_url: err.docUrl } : {},
4343
4405
  status: apiStatus(err),
4344
- ...apiStatus(err) === 0 ? { next_action: { type: "retry", message: "Check your network (and --api-base / CHECKBOX_API_BASE), or add --offline to run a check locally." } } : err.nextAction ? { next_action: err.nextAction } : isAuthError(err) ? { next_action: { type: "login", message: "The API key was rejected. Run `checkbox login` with a valid key, or set CHECKBOX_API_KEY." } } : {}
4406
+ ...apiStatus(err) === 0 ? { next_action: { type: "retry", message: "Check your network (and --api-base / CHECKBOX_API_BASE), or add --offline to run a check locally." } } : isAuthError(err) && authNextAction(auth) ? (
4407
+ // The server's 401 next step sends agents to device approval; a key this CLI can see locally comes first.
4408
+ { next_action: authNextAction(auth), ...auth?.credentials ? { key_source: auth.credentials.source, ...auth.credentials.file ? { key_file: auth.credentials.file } : {} } : {} }
4409
+ ) : err.nextAction ? { next_action: err.nextAction } : isAuthError(err) ? { next_action: { type: "login", message: "The API key was rejected. Run `checkbox login` with a valid key, or set CHECKBOX_API_KEY." } } : {}
4345
4410
  });
4346
4411
  return isAuthError(err) ? EXIT.auth : apiStatus(err) === 400 ? EXIT.usage : EXIT.error;
4347
4412
  }
@@ -4357,7 +4422,7 @@ var init_run = __esm({
4357
4422
  init_http();
4358
4423
  init_output();
4359
4424
  init_types2();
4360
- HERE = dirname(fileURLToPath(import.meta.url));
4425
+ HERE = dirname2(fileURLToPath(import.meta.url));
4361
4426
  COMMANDS_DIR = join2(HERE, "commands");
4362
4427
  CLI_VERSION = (() => {
4363
4428
  try {
@@ -4368,7 +4433,7 @@ var init_run = __esm({
4368
4433
  })();
4369
4434
  NotLoggedInError = class extends Error {
4370
4435
  constructor() {
4371
- super("Not logged in: no API key found (flag --api-key, env CHECKBOX_API_KEY, or `checkbox login`).");
4436
+ super("Not logged in: no API key found (flag --api-key, env CHECKBOX_API_KEY, CHECKBOX_SECRET_KEY in .env.local, or `checkbox login`).");
4372
4437
  this.name = "NotLoggedInError";
4373
4438
  }
4374
4439
  };
@@ -4707,7 +4772,8 @@ var init_secrets = __esm({
4707
4772
  { name: "stripe_webhook_secret", re: /\bwhsec_[A-Za-z0-9+/=_-]{4,}/g, replace: keepPrefix(() => 6) },
4708
4773
  { name: "stripe_publishable_live", re: /\bpk_live_[A-Za-z0-9_-]{4,}/g, replace: keepPrefix(() => 8) },
4709
4774
  // Checkbox API keys (ck_test_ / ck_live_) and scoped tokens.
4710
- { name: "checkbox_key", re: /\bck_(?:live|test)_[A-Za-z0-9_-]{4,}/g, replace: keepPrefix((m) => m.indexOf("_", 3) + 1) },
4775
+ // ckr_ (restricted) keys too: round 29 found they were left whole.
4776
+ { name: "checkbox_key", re: /\bckr?_(?:live|test)_[A-Za-z0-9_-]{4,}/g, replace: keepPrefix((m) => m.indexOf("_", 4) + 1) },
4711
4777
  // AWS access key ids (and the secret that usually follows them on the same line).
4712
4778
  { name: "aws_access_key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: () => "AKIA[redacted]" },
4713
4779
  { name: "aws_secret", re: /(aws_secret_access_key|AWS_SECRET_ACCESS_KEY|secretAccessKey)(\s*[:=]\s*["']?)[A-Za-z0-9/+=]{30,}/g, replace: (m) => m.replace(/[A-Za-z0-9/+=]{30,}$/, "[redacted]") },
@@ -5126,7 +5192,7 @@ var init_california = __esm({
5126
5192
  sourceUrl: URL_ARTICLE,
5127
5193
  effectiveDate: ORIGINAL,
5128
5194
  lastVerified: LAST_VERIFIED,
5129
- summary: 'Clear and conspicuous offer terms next to the consent request; affirmative (and since July 2025, express affirmative) consent; retainable acknowledgment; online "click to cancel" for online sign-ups; a notice 3-21 days before a free trial or promotional price lasting more than 31 days ends, and 15-45 days before a term of one year or longer renews; 7-30 day fee-change notice; annual reminder; same-medium cancellation; limits on save offers; consent records kept 3 years, or 1 year after termination if longer. Remedies: civil (UCL) remedies only (\xA7 17604); unconsented goods are gifts (\xA7 17603).',
5195
+ summary: 'Clear and conspicuous offer terms next to the consent request; affirmative (and since July 2025, express affirmative) consent; retainable acknowledgment; online "click to cancel" for online sign-ups; a notice 3-21 days before a free trial or promotional price lasting more than 31 days ends, and 15-45 days before a term of one year or longer renews; 7-30 day fee-change notice; annual reminder; same-medium cancellation; limits on save offers; consent records kept 3 years, or 1 year after termination if longer. No criminal penalties; civil remedies apply (\xA7 17604), in practice through the UCL, FAL and CLRA; unconsented goods are gifts (\xA7 17603).',
5130
5196
  status: "in_force",
5131
5197
  confidence: "high",
5132
5198
  verification: "primary",
@@ -5250,14 +5316,14 @@ var init_federal = __esm({
5250
5316
  effectiveDate: EFTA_EFFECTIVE,
5251
5317
  stage: "reminder",
5252
5318
  title: "Advance notice when a recurring debit will change in amount",
5253
- summary: "When a preauthorized debit from a consumer's account will vary in amount from the previous transfer or from the authorized amount (e.g. a subscription price increase billed to a debit card or by ACH), the payee must send written notice of the amount and date of the transfer at least 10 days before the scheduled transfer date.",
5319
+ summary: "When a preauthorized debit from a consumer's account will vary in amount from the previous transfer or from the authorized amount (e.g. a subscription price increase billed to a debit card or by ACH), the payee must send written notice of the amount and date of the transfer at least 10 days before the scheduled transfer date (\xA7 1005.10(d)(1)). The payee must tell the consumer of the right to notice of every varying transfer, but may let the consumer choose notice only when a transfer falls outside a specified range of amounts, or differs from the most recent transfer by more than an agreed amount (\xA7 1005.10(d)(2)).",
5254
5320
  appliesWhen: { paymentMethods: ["debit", "ach"] },
5255
5321
  checkIds: ["reminder.price_change"],
5256
5322
  severity: "medium",
5257
5323
  noticeWindow: noticeWindowAtLeast(10, "charge"),
5258
5324
  notes: notes(
5259
5325
  windowNote(noticeWindowAtLeast(10, "charge"), "the changed debit"),
5260
- "Applies only to debits from consumer asset accounts (debit, prepaid, ACH), not credit cards. The payee may instead let the consumer elect to be notified only when the amount falls outside a specified range or differs from the most recent transfer by more than an agreed amount."
5326
+ "Applies only to debits from consumer asset accounts (debit, prepaid, ACH), not credit cards. A consumer who chose range-only notice under \xA7 1005.10(d)(2) gets no notice for a change inside that range; Checkbox cannot see that choice, so the checks expect the notice for every price change."
5261
5327
  )
5262
5328
  })
5263
5329
  ];
@@ -5406,7 +5472,7 @@ var init_exposure = __esm({
5406
5472
  fees: NO_PRIVATE
5407
5473
  },
5408
5474
  "US-FED.1005.": {
5409
- privateSuit: "Yes: the Electronic Fund Transfer Act lets consumers sue (15 U.S.C. \xA7 1693m). Applies when the subscription is paid by debit card or ACH.",
5475
+ privateSuit: "Yes, in the law itself: the Electronic Fund Transfer Act lets consumers sue (15 U.S.C. \xA7 1693m). Applies when the subscription is paid by debit card or ACH.",
5410
5476
  damages: "Actual damages, plus statutory damages of $100 to $1,000 in an individual action, or in a class action up to the lesser of $500,000 or 1% of the defendant's net worth (15 U.S.C. \xA7 1693m(a)(1), (2)).",
5411
5477
  penalties: "None in private suits: \xA7 1693m provides damages, costs and fees, not civil penalties. The CFPB enforces the Act (15 U.S.C. \xA7 1693o(a)(5)) and can seek civil money penalties for a violation of federal consumer financial law, which includes the Act (12 U.S.C. \xA7\xA7 5481(12)(C), 5565(a)(2)(H)): up to $5,000 a day, $25,000 a day if reckless and $1,000,000 a day if knowing, before inflation adjustment (12 U.S.C. \xA7 5565(c)(2)).",
5412
5478
  fees: "Costs and reasonable attorney's fees for a successful action (15 U.S.C. \xA7 1693m(a)(3))."
@@ -5418,7 +5484,7 @@ var init_exposure = __esm({
5418
5484
  fees: NO_PRIVATE
5419
5485
  },
5420
5486
  "US-CA": {
5421
- privateSuit: "Through general consumer law: the ARL has no private right of its own (Mayron v. Google, 2020); plaintiffs sue under the UCL, FAL and CLRA and must show economic injury. Before suing for damages under the CLRA, the consumer must send a written notice and demand by certified or registered mail at least 30 days ahead, and no damages action lies if an appropriate correction is given or agreed within 30 days (Cal. Civ. Code \xA7 1782(a), (b)); a CLRA claim for an injunction needs no prior notice (\xA7 1782(d)).",
5487
+ privateSuit: "Yes, through general consumer law: the ARL has no private right of its own (Mayron v. Google, 2020); plaintiffs sue under the UCL, FAL and CLRA and must show economic injury. Before suing for damages under the CLRA, the consumer must send a written notice and demand by certified or registered mail at least 30 days ahead, and no damages action lies if an appropriate correction is given or agreed within 30 days (Cal. Civ. Code \xA7 1782(a), (b)); a CLRA claim for an injunction needs no prior notice (\xA7 1782(d)).",
5422
5488
  damages: "Goods or services provided without the required consent are an unconditional gift (Cal. Bus. & Prof. Code \xA7 17603); all civil remedies are available (\xA7 17604, with a good-faith-compliance safe harbor). Under the CLRA: actual damages (in a class action, at least $1,000 in total), an injunction, restitution of property, punitive damages and other relief the court deems proper (Cal. Civ. Code \xA7 1780(a)). Under the UCL and the FAL: an injunction and orders restoring money or property acquired by the unlawful practice (restitution) (Cal. Bus. & Prof. Code \xA7\xA7 17203, 17535); a private plaintiff must have suffered injury in fact and lost money or property (\xA7 17535, for the FAL). Neither section provides damages.",
5423
5489
  penalties: "In public actions only (the Attorney General, district attorneys and some county counsel and city attorneys): up to $2,500 per violation under the UCL (Cal. Bus. & Prof. Code \xA7 17206(a)) and up to $2,500 per violation under the FAL (\xA7 17536(a)).",
5424
5490
  fees: "Under the CLRA: the court shall award costs and attorney's fees to a prevailing plaintiff; a prevailing defendant may get reasonable fees only if the suit was not brought in good faith (Cal. Civ. Code \xA7 1780(e)). The UCL and FAL sections provide no attorney's fees (Cal. Bus. & Prof. Code \xA7\xA7 17203, 17535). Plaintiffs sometimes seek fees under the private attorney general statute, which lets a court award fees to a successful party in an action that enforced an important right affecting the public interest, when its conditions are met (Cal. Code Civ. Proc. \xA7 1021.5).",
@@ -5551,7 +5617,7 @@ var init_exposure = __esm({
5551
5617
  fees: OPEN
5552
5618
  },
5553
5619
  "US-ND": {
5554
- privateSuit: "Yes: a person aggrieved may sue to enjoin the violation or for restitution, or both (N.D. Cent. Code \xA7 51-37-06).",
5620
+ privateSuit: "Yes, in the law itself: a person aggrieved may sue to enjoin the violation or for restitution, or both (N.D. Cent. Code \xA7 51-37-06).",
5555
5621
  damages: "Restitution (N.D. Cent. Code \xA7 51-37-06); goods or services provided under a non-compliant renewal, or after an attempt to cancel, are an unconditional gift (\xA7 51-37-04).",
5556
5622
  penalties: "Each act is a separate violation of N.D. Cent. Code ch. 51-15, enforced by the attorney general with that chapter's remedies (\xA7 51-37-05). Unsettled: the figure is not yet confirmed from the statute text.",
5557
5623
  fees: "Costs, expenses and reasonable attorney's fees (N.D. Cent. Code \xA7 51-37-06)."
@@ -9824,7 +9890,7 @@ var init_catalog = __esm({
9824
9890
  init_states();
9825
9891
  init_terms();
9826
9892
  init_types3();
9827
- CATALOG_VERSION = "2026.11.09.2";
9893
+ CATALOG_VERSION = "2026.11.09.3";
9828
9894
  CATALOG = [
9829
9895
  ...FEDERAL_REQUIREMENTS,
9830
9896
  ...CALIFORNIA_REQUIREMENTS,
@@ -10645,7 +10711,7 @@ function analyzePolicyText(input) {
10645
10711
  function clausesOf(clauses, code2) {
10646
10712
  return clauses.filter((c) => c.code === code2);
10647
10713
  }
10648
- var POLICY_KINDS, POLICY_FRAGMENT, POLICY_FETCHER_USER_AGENT, SUBSCRIPTION_WORDS, NO_CANCEL, OFFLINE_ONLY, CONTACT_TO_CANCEL, ACCOUNT_NOT_PLACE, CANCEL_ONLINE_STRONG, NOTICE_PERIOD, ALL_SALES_FINAL, CANCEL_ONLINE, AUTO_RENEWAL, HOW_TO_CANCEL, NO_REFUNDS, REFUND_WINDOW, GUARANTEE, REFUND_EXCEPTION;
10714
+ var POLICY_KINDS, POLICY_FRAGMENT, POLICY_FETCHER_USER_AGENT, SUBSCRIPTION_WORDS, NO_CANCEL, OFFLINE_ONLY, STOP_SUBSCRIPTION, CONTACT_TO_CANCEL, ACCOUNT_NOT_PLACE, CANCEL_ONLINE_STRONG, NOTICE_PERIOD, ALL_SALES_FINAL, CANCEL_ONLINE, AUTO_RENEWAL, HOW_TO_CANCEL, NO_REFUNDS, REFUND_WINDOW, GUARANTEE, REFUND_EXCEPTION;
10649
10715
  var init_policy_docs = __esm({
10650
10716
  "../rules/src/policy-docs.ts"() {
10651
10717
  "use strict";
@@ -10666,9 +10732,12 @@ var init_policy_docs = __esm({
10666
10732
  /\bcancell?(?:ations?|ing)?\b[^.]{0,80}\b(?:only|solely|exclusively)\b[^.]{0,60}\b(?:phone|telephone|mail|post|in writing|in person|fax)\b/i,
10667
10733
  /\bcancell?ation requests?\b[^.]{0,60}\b(?:by|via) (?:certified |registered )?(?:mail|post|letter|fax|phone|telephone)\b/i
10668
10734
  ];
10735
+ STOP_SUBSCRIPTION = "(?:stop|end|terminate|discontinue|turn off)\\s+(?:your |the |a |my |this )?(?:paid |monthly |annual |yearly )?(?:subscription|membership|plan|auto-?renewal|automatic renewal|recurring (?:billing|payments?|charges?))";
10669
10736
  CONTACT_TO_CANCEL = [
10670
10737
  /\bto cancel\b[^.]{0,80}\b(?:contact|email|e-mail|reach out to|message)\b/i,
10671
10738
  /\b(?:contact|email|e-mail)\b[^.]{0,60}\bto cancel\b/i,
10739
+ new RegExp(`\\bto ${STOP_SUBSCRIPTION}\\b[^.]{0,80}(?:\\b(?:contact|email|e-mail|reach out to|message|write to)\\b|\\[redacted\\])`, "i"),
10740
+ new RegExp(`(?:\\b(?:contact|email|e-mail|write to)\\b|\\[redacted\\])[^.]{0,60}\\bto ${STOP_SUBSCRIPTION}\\b`, "i"),
10672
10741
  // Round 2 pilot: "You may cancel by contacting support" / "cancellations are handled by emailing us".
10673
10742
  /\bcancel\w*\b[^.]{0,60}\b(?:by|through|via)\s+(?:contacting|emailing|e-mailing|messaging|writing to|reaching out to|calling|phoning|sending (?:us )?an? (?:email|e-mail|message))\b/i,
10674
10743
  // Normal round 2 (nextjs): the scanner redacts e-mail addresses before the rules see the text, so
@@ -11309,7 +11378,7 @@ function noticePlanOptions(ctx, priceChange = false) {
11309
11378
  const newPrices = (ctx.events ?? []).filter((e) => e.type === "price_change_scheduled").map((e) => e.amount).filter((n) => typeof n === "number" && n > 0).map((n) => n / 10 ** currencyDigitsOf(currency));
11310
11379
  if (priceChange && newPrices.length) return { plans: plans.map((p) => ({ ...p, amount: -1 })), extraAmounts: newPrices };
11311
11380
  if (!plans.length) return { plans: [], extraAmounts: [] };
11312
- return { plans, extraAmounts: newPrices };
11381
+ return { plans, extraAmounts: newPrices, ...ctx.events?.length ? { events: ctx.events } : {} };
11313
11382
  }
11314
11383
  function declaredPlansFrom(plans) {
11315
11384
  const out = [];
@@ -11551,11 +11620,11 @@ function siteOffers(input) {
11551
11620
  };
11552
11621
  for (const d of declared) {
11553
11622
  const price3 = stripe.find((s2) => sameTerms(s2, d));
11554
- const trial = d.trialDays && d.trialDays > 0 ? d.trialDays : price3?.trialDays && price3.trialDays > 0 ? price3.trialDays : soldTrial({ ...d, ...price3 ? { priceId: price3.id } : {} });
11623
+ const trial = d.trialDays && d.trialDays > 0 ? d.trialDays : soldTrial({ ...d, ...price3 ? { priceId: price3.id } : {} }) || (price3?.trialDays && price3.trialDays > 0 ? price3.trialDays : 0);
11555
11624
  push(toOffer(d, "declared", { trialDays: trial, ...price3 ? { priceId: price3.id } : {} }));
11556
11625
  }
11557
11626
  const chosen = input.facts?.planPriceId ? stripe.find((p) => p.id === input.facts.planPriceId) : void 0;
11558
- if (chosen) push(toOffer(chosen, "chosen", chosen.trialDays && chosen.trialDays > 0 ? {} : { trialDays: soldTrial(chosen) }));
11627
+ if (chosen) push(toOffer(chosen, "chosen", soldTrial(chosen) > 0 ? { trialDays: soldTrial(chosen) } : {}));
11559
11628
  if (!declared.length) {
11560
11629
  for (const s2 of sold) {
11561
11630
  const price3 = stripe.find((p) => sameTerms(p, s2));
@@ -11570,7 +11639,7 @@ function siteOffers(input) {
11570
11639
  const anchors = stripe.filter((s2) => out.some((o) => o.priceId === s2.id));
11571
11640
  for (const s2 of stripe) {
11572
11641
  if (out.some((o) => o.priceId === s2.id) || !anchors.some((a) => sameProductName(a.name, s2.name))) continue;
11573
- push(toOffer(s2, "stripe", s2.trialDays && s2.trialDays > 0 ? {} : { trialDays: soldTrial(s2) }));
11642
+ push(toOffer(s2, "stripe", soldTrial(s2) > 0 ? { trialDays: soldTrial(s2) } : {}));
11574
11643
  }
11575
11644
  }
11576
11645
  const facts = input.facts;
@@ -12937,14 +13006,14 @@ var init_visible_html = __esm({
12937
13006
 
12938
13007
  // ../rules/src/checks/context.ts
12939
13008
  function heuristicReader() {
12940
- const cache = /* @__PURE__ */ new Map();
13009
+ const cache2 = /* @__PURE__ */ new Map();
12941
13010
  return {
12942
13011
  mode: "heuristic",
12943
13012
  async analyze(text) {
12944
- let a = cache.get(text);
13013
+ let a = cache2.get(text);
12945
13014
  if (!a) {
12946
13015
  a = analyzeTerms(text);
12947
- cache.set(text, a);
13016
+ cache2.set(text, a);
12948
13017
  }
12949
13018
  return a;
12950
13019
  }
@@ -13103,6 +13172,14 @@ function offerSlice(c, g) {
13103
13172
  links: keep(c.links)
13104
13173
  };
13105
13174
  }
13175
+ function offerCardName(text, cta, index) {
13176
+ const t = normalizeText(text).replace(/\s+/g, " ").trim();
13177
+ 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);
13178
+ const before = (money3 ? t.slice(0, money3.index) : "").replace(new RegExp(`^${cta.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "").replace(/[\s:·|–—-]+$/, "").trim();
13179
+ const heading = before && before.length <= 40 ? before : before ? before.split(" ").slice(0, 4).join(" ") : "";
13180
+ const price3 = money3?.[0].trim();
13181
+ return heading && price3 ? `${heading}, ${price3}` : heading || price3 || `card ${index + 1}`;
13182
+ }
13106
13183
  function buildSignupView(capture, offer) {
13107
13184
  const c = offer === void 0 ? capture : offerSlice(capture, offer);
13108
13185
  const device = c.device === "mobile" ? "mobile" : "desktop";
@@ -13127,9 +13204,11 @@ function buildSignupView(capture, offer) {
13127
13204
  [...c.disclosures ?? [], ...checkboxes, ...allLinks].filter(isVisible).map((e) => e.style?.fontSizePx ?? NaN)
13128
13205
  );
13129
13206
  const offerCta = offer === void 0 ? void 0 : capture.offers?.[offer]?.cta ?? primaryCta2?.text;
13207
+ const sameCta = offer !== void 0 && offerCta ? (capture.offers ?? []).filter((o) => normalizeText(o.cta) === normalizeText(offerCta)).length > 1 : false;
13208
+ const card = sameCta ? offerCardName(capture.offers[offer].text, offerCta, offer) : void 0;
13130
13209
  return {
13131
13210
  capture,
13132
- label: offerCta ? `${captureLabel(c)} \u2014 plan button \u201C${normalizeText(offerCta).slice(0, 60)}\u201D` : captureLabel(c),
13211
+ label: offerCta ? `${captureLabel(c)} \u2014 plan button \u201C${normalizeText(offerCta).slice(0, 60)}\u201D${card ? ` in the card \u201C${card}\u201D` : ""}` : captureLabel(c),
13133
13212
  ...offer !== void 0 ? { offer: { index: offer, cta: normalizeText(offerCta ?? "").slice(0, 120) } } : {},
13134
13213
  device,
13135
13214
  viewport,
@@ -13783,7 +13862,7 @@ function remediationFor(checkId, platform) {
13783
13862
  if (guide?.snippet) rem.snippet = { ...guide.snippet };
13784
13863
  return rem;
13785
13864
  }
13786
- var DISCLOSURE_COPY_TEMPLATE, CONSENT_COPY_TEMPLATE, ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_REMINDER_COPY_TEMPLATE, RENEWAL_REMINDER_COPY_TEMPLATE, PRICE_CHANGE_COPY_TEMPLATE, ANNUAL_TERMS_COPY_TEMPLATE, CHARGE_NOTICE_COPY_TEMPLATE, CANCELLATION_CONFIRMATION_COPY_TEMPLATE, TERMS_CANCELLATION_COPY_TEMPLATE, TERMS_REFUND_COPY_TEMPLATE, TERMS_RENEWAL_COPY_TEMPLATE, PLACEHOLDER_STEP, CUSTOM_DISCLOSURE_HTML, CUSTOM_CONSENT_HTML, STRIPE_CHECKOUT_TS, STRIPE_ANNUAL_REMINDER_TS, STRIPE_CHARGE_NOTICE_TS, STRIPE_PORTAL_TS, STRIPE_ACK_WEBHOOK_TS, STRIPE_CANCEL_CONFIRMATION_TS, SHOPIFY_LIQUID, SHOPIFY_CHECKOUT_EXTENSION_TSX, CUSTOM_ACCOUNT_CANCEL_HTML, CUSTOM_ACK_EMAIL_HTML, VERIFY, VERIFY_TERMS, VERIFY_EMAIL, CB_CHECKOUT, CB_PORTAL, CB_EMAILS, CHARGEBEE_DISCLOSURE, CHARGEBEE_CONSENT, CHARGEBEE_ACK, CHARGEBEE_PORTAL, CHARGEBEE_TRIAL_REMINDER, CHARGEBEE_RENEWAL_REMINDER, CHARGEBEE_ANNUAL_TERMS, CHARGEBEE_CHARGE_NOTICE, RC_ACCOUNT_PAGES, RC_EMAILS, RECURLY_DISCLOSURE, RECURLY_CONSENT, RECURLY_ACK, RECURLY_PORTAL, RECURLY_TRIAL_REMINDER, RECURLY_RENEWAL_REMINDER, RECURLY_ANNUAL_TERMS, RECURLY_CHARGE_NOTICE, PD_RETAIN, PADDLE_DISCLOSURE, PADDLE_CONSENT, PADDLE_ACK, PADDLE_PORTAL, PADDLE_TRIAL_REMINDER, PADDLE_RENEWAL_REMINDER, PADDLE_ANNUAL_TERMS, PADDLE_CHARGE_NOTICE, STRIPE_DISCLOSURE, SHOPIFY_DISCLOSURE, RECHARGE_DISCLOSURE, CUSTOM_DISCLOSURE, DISCLOSURE_PLATFORMS, STRIPE_ACK, SHOPIFY_ACK, RECHARGE_ACK, CUSTOM_ACK, ACK_PLATFORMS, STRIPE_PORTAL, SHOPIFY_PORTAL, RECHARGE_PORTAL, CUSTOM_PORTAL, PORTAL_PLATFORMS, STRIPE_TRIAL_REMINDER, STRIPE_RENEWAL_REMINDER, STRIPE_ANNUAL_TERMS, STRIPE_CHARGE_NOTICE, SHOPIFY_ANNUAL_TERMS, SHOPIFY_CHARGE_NOTICE, RECHARGE_ANNUAL_TERMS, RECHARGE_CHARGE_NOTICE, CUSTOM_ANNUAL_TERMS, CUSTOM_CHARGE_NOTICE, SHOPIFY_REMINDER, RECHARGE_REMINDER, CUSTOM_REMINDER, SPECS, PLATFORM_LABELS, OFFER_PERMISSION_FIX, OFFER_QUESTION_STOP_FIX, OFFER_PERMISSION_SNIPPET, CANCEL_HANDLER_REMEDIATION, OFFER_PERMISSION_REMEDIATION;
13865
+ var DISCLOSURE_COPY_TEMPLATE, CONSENT_COPY_TEMPLATE, ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_REMINDER_COPY_TEMPLATE, RENEWAL_REMINDER_COPY_TEMPLATE, PRICE_CHANGE_COPY_TEMPLATE, ANNUAL_TERMS_COPY_TEMPLATE, CHARGE_NOTICE_COPY_TEMPLATE, CANCELLATION_CONFIRMATION_COPY_TEMPLATE, TERMS_CANCELLATION_COPY_TEMPLATE, TERMS_REFUND_COPY_TEMPLATE, TERMS_RENEWAL_COPY_TEMPLATE, PLACEHOLDER_STEP, CUSTOM_DISCLOSURE_HTML, CUSTOM_CONSENT_HTML, STRIPE_CHECKOUT_TS, STRIPE_ANNUAL_REMINDER_TS, STRIPE_CHARGE_NOTICE_TS, STRIPE_PORTAL_TS, STRIPE_ACK_WEBHOOK_TS, STRIPE_CANCEL_CONFIRMATION_TS, SHOPIFY_LIQUID, SHOPIFY_CHECKOUT_EXTENSION_TSX, CUSTOM_ACCOUNT_CANCEL_HTML, CUSTOM_ACK_EMAIL_HTML, VERIFY, VERIFY_TERMS, VERIFY_EMAIL, CB_CHECKOUT, CB_PORTAL, CB_EMAILS, CHARGEBEE_DISCLOSURE, CHARGEBEE_CONSENT, CHARGEBEE_ACK, CHARGEBEE_PORTAL, CHARGEBEE_TRIAL_REMINDER, CHARGEBEE_RENEWAL_REMINDER, CHARGEBEE_ANNUAL_TERMS, CHARGEBEE_CHARGE_NOTICE, RC_ACCOUNT_PAGES, RC_EMAILS, RECURLY_DISCLOSURE, RECURLY_CONSENT, RECURLY_ACK, RECURLY_PORTAL, RECURLY_TRIAL_REMINDER, RECURLY_RENEWAL_REMINDER, RECURLY_ANNUAL_TERMS, RECURLY_CHARGE_NOTICE, PD_RETAIN, PADDLE_DISCLOSURE, PADDLE_CONSENT_HTML, PADDLE_CONSENT, PADDLE_ACK, PADDLE_PORTAL, PADDLE_TRIAL_REMINDER, PADDLE_RENEWAL_REMINDER, PADDLE_ANNUAL_TERMS, PADDLE_CHARGE_NOTICE, STRIPE_DISCLOSURE, SHOPIFY_DISCLOSURE, RECHARGE_DISCLOSURE, CUSTOM_DISCLOSURE, DISCLOSURE_PLATFORMS, STRIPE_ACK, SHOPIFY_ACK, RECHARGE_ACK, CUSTOM_ACK, ACK_PLATFORMS, STRIPE_PORTAL, SHOPIFY_PORTAL, RECHARGE_PORTAL, CUSTOM_PORTAL, PORTAL_PLATFORMS, STRIPE_TRIAL_REMINDER, STRIPE_RENEWAL_REMINDER, STRIPE_ANNUAL_TERMS, STRIPE_CHARGE_NOTICE, SHOPIFY_ANNUAL_TERMS, SHOPIFY_CHARGE_NOTICE, RECHARGE_ANNUAL_TERMS, RECHARGE_CHARGE_NOTICE, CUSTOM_ANNUAL_TERMS, CUSTOM_CHARGE_NOTICE, SHOPIFY_REMINDER, RECHARGE_REMINDER, CUSTOM_REMINDER, SPECS, PLATFORM_LABELS, OFFER_PERMISSION_FIX, OFFER_QUESTION_STOP_FIX, OFFER_PERMISSION_SNIPPET, CANCEL_HANDLER_REMEDIATION, OFFER_PERMISSION_REMEDIATION;
13787
13866
  var init_remediation = __esm({
13788
13867
  "../rules/src/remediation.ts"() {
13789
13868
  "use strict";
@@ -14099,13 +14178,32 @@ and click \u201CCancel subscription\u201D. You can cancel anytime online.</p>`;
14099
14178
  ],
14100
14179
  snippet: { language: "html", code: CUSTOM_DISCLOSURE_HTML }
14101
14180
  };
14181
+ PADDLE_CONSENT_HTML = `<!-- Replace your button that opens Paddle Checkout with this form (Paddle.js must be loaded and initialized). -->
14182
+ <form id="checkbox-paddle-checkout">
14183
+ <label style="display:flex;gap:8px;align-items:flex-start;font-size:14px;color:#1f2937;">
14184
+ <!-- Never add the "checked" attribute: consent must be an affirmative act -->
14185
+ <input type="checkbox" name="auto_renew_consent" value="yes" required>
14186
+ <span>I agree that my subscription will automatically renew every {interval} at {price} until I cancel.</span>
14187
+ </label>
14188
+ <button type="submit">Subscribe</button>
14189
+ </form>
14190
+ <script>
14191
+ document.getElementById('checkbox-paddle-checkout').addEventListener('submit', function (event) {
14192
+ event.preventDefault(); // the browser only gets here once the required box is ticked
14193
+ var label = this.querySelector('label span').textContent.trim();
14194
+ Paddle.Checkout.open({
14195
+ items: [{ priceId: 'pri_REPLACE_ME', quantity: 1 }],
14196
+ customData: { auto_renew_consent: { at: new Date().toISOString(), label: label } },
14197
+ });
14198
+ });
14199
+ </script>`;
14102
14200
  PADDLE_CONSENT = {
14103
14201
  steps: [
14104
14202
  "Add a required, unchecked renewal-consent checkbox (the wording below) on your page, directly above the button that opens Paddle Checkout, and only open checkout once it is ticked.",
14105
- "Save when it was ticked and the terms that were shown, so you can prove consent later.",
14106
- "For developers: call Paddle.Checkout.open() only after the box is ticked, and pass the consent time in customData so it is stored on the transaction."
14203
+ 'Paddle opens checkout from a button, not a form, so a "required" box alone blocks nothing: put the box and the button in one form (the snippet does) or keep the button disabled until the box is ticked.',
14204
+ "Save when it was ticked and the terms that were shown, so you can prove consent later: the snippet passes them in customData, which Paddle stores on the transaction. On Lemon Squeezy, pass them as checkout[custom][\u2026] on the checkout URL."
14107
14205
  ],
14108
- snippet: { language: "html", code: CUSTOM_CONSENT_HTML }
14206
+ snippet: { language: "html", code: PADDLE_CONSENT_HTML }
14109
14207
  };
14110
14208
  PADDLE_ACK = {
14111
14209
  steps: [
@@ -15056,6 +15154,11 @@ function offerQuestionStop(flow) {
15056
15154
  const last = [...flow.steps ?? []].reverse().find((s2) => s2.pageKind !== "account");
15057
15155
  return last && OFFER_PERMISSION_RE.test(normalizeText(last.text)) ? last : void 0;
15058
15156
  }
15157
+ function stuckHint(flow) {
15158
+ const blockers = flow.blockers ?? [];
15159
+ if (flow.completed || blockers.some((b) => /answered the cancel request with an error/i.test(b))) return "";
15160
+ return blockers.some((b) => /no further cancel option|not completed within \d+ steps/i.test(b)) ? `. ${CANCEL_STEPS_HINT}` : "";
15161
+ }
15059
15162
  function offerQuestionStopResult(id, stop, refs2, data) {
15060
15163
  return makeResult(
15061
15164
  id,
@@ -15345,7 +15448,7 @@ function notRefreshedUnits(stale, fresh) {
15345
15448
  };
15346
15449
  });
15347
15450
  }
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;
15451
+ 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
15452
  var init_cancellation = __esm({
15350
15453
  "../rules/src/checks/cancellation.ts"() {
15351
15454
  "use strict";
@@ -15379,6 +15482,7 @@ var init_cancellation = __esm({
15379
15482
  ORDER_ONLY_RE = /\bcancel\s+(?:this\s+|my\s+)?order\b/i;
15380
15483
  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
15484
  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";
15485
+ 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
15486
  onlineAvailable = {
15383
15487
  id: "cancel.online_available",
15384
15488
  usesText: false,
@@ -15485,10 +15589,10 @@ var init_cancellation = __esm({
15485
15589
  return makeResult(
15486
15590
  id,
15487
15591
  "needs_review",
15488
- `The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}, so online cancellation could not be confirmed`,
15592
+ `The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}, so online cancellation could not be confirmed${stuckHint(flow)}`,
15489
15593
  refs2,
15490
15594
  0.4,
15491
- data
15595
+ stuckHint(flow) ? { ...data, suggestCancelSteps: true } : data
15492
15596
  );
15493
15597
  }
15494
15598
  const offline = offlineOnlyCancellation(input);
@@ -15734,13 +15838,13 @@ var init_cancellation = __esm({
15734
15838
  const offerStop = offerQuestionStop(flow);
15735
15839
  if (offerStop) return offerQuestionStopResult(id, offerStop, refs2, data);
15736
15840
  if (b.other.length) {
15737
- return makeResult(id, "needs_review", `The cancellation flow reported: ${b.other.join("; ")}`, refs2, 0.6, data);
15841
+ return makeResult(id, "needs_review", `The cancellation flow reported: ${b.other.join("; ")}${stuckHint(flow)}`, refs2, 0.6, stuckHint(flow) ? { ...data, suggestCancelSteps: true } : data);
15738
15842
  }
15739
15843
  if (!flow.completed) {
15740
15844
  return makeResult(
15741
15845
  id,
15742
15846
  "needs_review",
15743
- `The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}; friction could not be fully assessed`,
15847
+ `The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}; friction could not be fully assessed${stuckHint(flow)}`,
15744
15848
  refs2,
15745
15849
  0.4,
15746
15850
  data
@@ -15999,6 +16103,19 @@ var init_config2 = __esm({
15999
16103
  }
16000
16104
  return makeResult(id, "fail", `Self-serve cancellation is turned off in ${name}'s customer portal`, refs(input), 0.9, { ...data, cancelPath: "portal_only" });
16001
16105
  }
16106
+ if (model.otherOnlineWorks) {
16107
+ const flow = input.ctx.cancellation;
16108
+ const works = inApp?.state === "works" && !!flow;
16109
+ 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`;
16110
+ return makeResult(
16111
+ id,
16112
+ "not_applicable",
16113
+ `Checkbox could not read whether self-serve cancellation is on in ${name}'s portal, but ${via}`,
16114
+ [...refs(input), ...works ? [flow.id] : []],
16115
+ works ? 0.75 : 0.6,
16116
+ { ...data, verifiedBy: "cancel.online_available", ...works ? { cancellationId: flow.id } : { ownCancelPage: true }, ...inApp ? { cancelsInApp: true } : {} }
16117
+ );
16118
+ }
16002
16119
  return makeResult(id, "needs_review", `Could not read whether self-serve cancellation is enabled in ${name}`, refs(input), 0.4, data);
16003
16120
  }
16004
16121
  };
@@ -16262,15 +16379,34 @@ function offlineOnlyCancelIn(text, cancelMethods) {
16262
16379
  const online = cancelMethods.some((m) => !CONTACT_TO_CANCEL_RE.test(m.text) && !/@|\d{3}[\s.-]\d{4}|\b(?:call|phone|chat|e-?mail|contact|mail|reply)\b/i.test(m.text));
16263
16380
  return online ? null : offline;
16264
16381
  }
16382
+ function inForceAmount(e, events, fallbackCurrency = "usd") {
16383
+ const own = e.noticeTerms;
16384
+ if (own && typeof own.amount === "number" && own.amount > 0) return own.amount / 10 ** currencyDigitsOf(typeof own.currency === "string" ? own.currency : fallbackCurrency);
16385
+ const ref = typeof e.subscriptionRef === "string" ? e.subscriptionRef.trim() : "";
16386
+ const sent2 = toDate(e.receivedAt)?.getTime();
16387
+ if (!ref || sent2 === void 0) return void 0;
16388
+ let best;
16389
+ let bestAt = -Infinity;
16390
+ for (const ev of events) {
16391
+ if (ev.subscriptionRef !== ref || !CHARGE_AMOUNT_EVENTS.has(ev.type) || !(typeof ev.amount === "number" && ev.amount > 0)) continue;
16392
+ const at = toDate(ev.occurredAt)?.getTime();
16393
+ if (at === void 0 || at > sent2 + 36e5 || at < bestAt) continue;
16394
+ best = ev;
16395
+ bestAt = at;
16396
+ }
16397
+ return best ? best.amount / 10 ** currencyDigitsOf(best.currency ?? fallbackCurrency) : void 0;
16398
+ }
16265
16399
  function noticeContent(e, opts = {}) {
16266
16400
  const body = emailBody(e);
16267
16401
  const a = analyzeTerms(body);
16268
16402
  const quotes = {};
16269
16403
  const wrong = {};
16270
16404
  const plans = opts.plans ?? [];
16405
+ const inForce = opts.events ? inForceAmount(e, opts.events, plans[0]?.currency) : void 0;
16271
16406
  const expected = [
16272
16407
  ...plans.map((p) => p.amount / 10 ** currencyDigitsOf(p.currency)),
16273
- ...opts.extraAmounts ?? []
16408
+ ...opts.extraAmounts ?? [],
16409
+ ...inForce !== void 0 ? [inForce] : []
16274
16410
  ];
16275
16411
  const amount = a.recurringPrices[0]?.money ?? positiveAmounts(a)[0];
16276
16412
  const stated = positiveAmounts(a).filter((m) => m.amount !== null);
@@ -16335,7 +16471,7 @@ function contentFixFromText(checkId, text) {
16335
16471
  const m = new RegExp(`^the ${noun} \\(.*?\\) .*? does not state (.+?)(?: correctly \\(| \u2014 )`, "i").exec(text.trim());
16336
16472
  return m ? `Add ${m[1]} to the ${noun}; Checkbox's template has every required line.` : void 0;
16337
16473
  }
16338
- var ELEMENT_LABELS, CHARGE_FOLLOWS_RE, DATE_TAG_RE, TRIAL_END_WHEN_RE, PRODUCT_RE, CONTACT_RE, CANCEL_URL_RE, NOTICE_NOUN;
16474
+ var ELEMENT_LABELS, CHARGE_FOLLOWS_RE, DATE_TAG_RE, TRIAL_END_WHEN_RE, PRODUCT_RE, CONTACT_RE, CANCEL_URL_RE, CHARGE_AMOUNT_EVENTS, NOTICE_NOUN;
16339
16475
  var init_notice_content = __esm({
16340
16476
  "../rules/src/checks/notice-content.ts"() {
16341
16477
  "use strict";
@@ -16361,6 +16497,7 @@ var init_notice_content = __esm({
16361
16497
  PRODUCT_RE = /\b(?:[Yy]our|[Tt]he|[Tt]his)\s+(?:[A-Z0-9][\w&'’.+-]*\s+){1,5}(?:[Ss]ubscription|[Mm]embership|[Pp]lan|[Bb]ox|[Ss]ervice|[Cc]lub|[Pp]ackage)\b|\b(?:[Pp]lan|[Pp]roduct|[Ss]ubscription|[Mm]embership)\s*:\s*\S[^\n]{1,60}/;
16362
16498
  CONTACT_RE = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+|(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b|\bcontact\s+(?:us|support|our\s+(?:support|customer|help))\b[^\n]{0,80}(?:https?:\/\/\S+|@)|\b(?:support|help)\s+(?:center|centre)\s*:?\s*https?:\/\/\S+/i;
16363
16499
  CANCEL_URL_RE = /https?:\/\/\S*(?:cancel|billing\.stripe\.com|customer[-_]?portal|\/portal|manage|subscription|account)\S*/i;
16500
+ CHARGE_AMOUNT_EVENTS = /* @__PURE__ */ new Set(["subscription_created", "subscription_renewed", "upcoming_renewal", "trial_will_end"]);
16364
16501
  NOTICE_NOUN = {
16365
16502
  "reminder.trial_ending": "trial-ending reminder",
16366
16503
  "reminder.annual_renewal": "renewal reminder",
@@ -16430,6 +16567,64 @@ var init_email_plan = __esm({
16430
16567
  }
16431
16568
  });
16432
16569
 
16570
+ // ../rules/src/checks/forwarded.ts
16571
+ function headerAddress(block, name) {
16572
+ const m = new RegExp(String.raw`^\s*[*>]*\s*${name}\s*:\s*\*?\s*(.+)$`, "im").exec(block);
16573
+ if (!m) return void 0;
16574
+ const a = ADDRESS_RE.exec(m[1]);
16575
+ return a ? a[0].toLowerCase().replace(/[.>]+$/, "") : void 0;
16576
+ }
16577
+ function forwardedInfo(e) {
16578
+ const hit = cache.get(e);
16579
+ if (hit) return hit;
16580
+ const text = e.text ?? "";
16581
+ const marker = FWD_MARKER_RE.exec(text);
16582
+ const forwarded = !!marker || FWD_SUBJECT_RE.test(e.subject ?? "");
16583
+ const info = { forwarded };
16584
+ if (forwarded) {
16585
+ const start = marker ? marker.index : 0;
16586
+ const block = text.slice(start, start + 1200);
16587
+ const from = headerAddress(block, "From");
16588
+ const to = headerAddress(block, "To");
16589
+ if (from) info.originalFrom = from;
16590
+ if (to) info.originalTo = to;
16591
+ }
16592
+ cache.set(e, info);
16593
+ return info;
16594
+ }
16595
+ function domainOf(address) {
16596
+ const m = /@([^\s<>"]+)/.exec(address ?? "");
16597
+ return m ? m[1].toLowerCase().replace(/[>.]+$/, "") : "";
16598
+ }
16599
+ function isMorDomain(domain) {
16600
+ return !!domain && MERCHANT_OF_RECORD_DOMAINS.some((d) => domain === d || domain.endsWith(`.${d}`));
16601
+ }
16602
+ function isMerchantOfRecordEmail(e) {
16603
+ return isMorDomain(domainOf(e.from)) || isMorDomain(domainOf(forwardedInfo(e).originalFrom));
16604
+ }
16605
+ function inboundDomain() {
16606
+ const env2 = typeof process !== "undefined" ? process.env.INBOUND_EMAIL_DOMAIN : void 0;
16607
+ return (env2 ?? "").trim().replace(/^@/, "").replace(/\.$/, "").toLowerCase() || "inbound.subcomply.localhost";
16608
+ }
16609
+ function sentStraightToCheckbox(e) {
16610
+ if (forwardedInfo(e).originalTo) return false;
16611
+ if (e.scanId || e.relatedSignupAt || e.subscriptionRef || e.testRun) return false;
16612
+ const addresses = (e.to ?? "").match(/[^\s<>,"]+@[^\s<>,"]+/g) ?? [];
16613
+ const domain = inboundDomain();
16614
+ return addresses.length > 0 && addresses.every((a) => domainOf(a) === domain);
16615
+ }
16616
+ var FWD_SUBJECT_RE, FWD_MARKER_RE, ADDRESS_RE, cache, MERCHANT_OF_RECORD_DOMAINS;
16617
+ var init_forwarded = __esm({
16618
+ "../rules/src/checks/forwarded.ts"() {
16619
+ "use strict";
16620
+ FWD_SUBJECT_RE = /^\s*(?:(?:re|aw|sv)\s*:\s*)*(?:fwd?|fw|wg|tr)\s*:/i;
16621
+ FWD_MARKER_RE = /(?:-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message\s*:|-{2,}\s*Original Message\s*-{2,}|-{2,}\s*Forwarded Message\s*-{2,}|^\s*_{8,}\s*$\s*^\s*From\s*:)/im;
16622
+ ADDRESS_RE = /[^\s<>,"':;()[\]]+@[^\s<>,"':;()[\]]+\.[a-z]{2,}/i;
16623
+ cache = /* @__PURE__ */ new WeakMap();
16624
+ MERCHANT_OF_RECORD_DOMAINS = ["paddle.com", "paddle.net", "lemonsqueezy.com", "fastspring.com"];
16625
+ }
16626
+ });
16627
+
16433
16628
  // ../rules/src/checks/confirmation.ts
16434
16629
  function isPostSignupEmail(e) {
16435
16630
  if (e.kind !== "other" || !e.scanId || !e.relatedSignupAt) return false;
@@ -16442,10 +16637,12 @@ function findAcknowledgments(emails) {
16442
16637
  if (cached) return cached;
16443
16638
  const acks = [];
16444
16639
  const receipts = [];
16445
- const acknowledged = new Set(emails.filter((e) => e.kind === "acknowledgment").map((e) => recipientOf(e)).filter((r) => r !== ""));
16640
+ const explicit = emails.filter((e) => e.kind === "acknowledgment");
16641
+ const acknowledged = new Set(explicit.map((e) => recipientOf(e)).filter((r) => r !== ""));
16446
16642
  for (const e of emails) {
16447
16643
  if (e.kind === "acknowledgment") acks.push(e);
16448
16644
  else if (acknowledged.has(recipientOf(e))) continue;
16645
+ else if (explicit.length && e.kind === "receipt" && (forwardedInfo(e).forwarded || isMerchantOfRecordEmail(e) || sentStraightToCheckbox(e))) continue;
16449
16646
  else if (e.kind === "receipt" || isPostSignupEmail(e)) {
16450
16647
  (looksLikeSubscriptionTerms(analyzeTerms(emailBody(e))) ? acks : receipts).push(e);
16451
16648
  }
@@ -16455,6 +16652,8 @@ function findAcknowledgments(emails) {
16455
16652
  return set;
16456
16653
  }
16457
16654
  function recipientOf(e) {
16655
+ const original = forwardedInfo(e).originalTo;
16656
+ if (original) return original;
16458
16657
  const m = /[^\s<>,"]+@[^\s<>,"]+/.exec(e.to ?? "");
16459
16658
  return m ? m[0].toLowerCase() : "";
16460
16659
  }
@@ -16664,6 +16863,7 @@ var init_confirmation = __esm({
16664
16863
  init_intro_terms();
16665
16864
  init_offer();
16666
16865
  init_email_plan();
16866
+ init_forwarded();
16667
16867
  ACK_MAX_DELAY_HOURS = 24;
16668
16868
  NO_ACK_EMAIL_MESSAGE = "No acknowledgment email captured yet \u2014 BCC your sign-up confirmation email to your Checkbox inbound address (see Install), or add a test purchase to your scan settings so our shopper inbox receives it.";
16669
16869
  ackCache = /* @__PURE__ */ new WeakMap();
@@ -18280,6 +18480,17 @@ function requiredFor(reqs) {
18280
18480
  if (!out.size) for (const el of CHARGE_NOTICE_REQUIRED) out.add(el);
18281
18481
  return [...out];
18282
18482
  }
18483
+ function chargeGapPhrase(content, gaps, expected) {
18484
+ const wrong = gaps.filter((g) => content.wrong?.[g]);
18485
+ const absent = gaps.filter((g) => !content.wrong?.[g]);
18486
+ const parts = [];
18487
+ if (absent.length) parts.push(`do not state ${describeMissing(absent)}`);
18488
+ for (const g of wrong) {
18489
+ const said = content.wrong[g];
18490
+ parts.push(g === "amount" ? `states ${ELEMENT_LABELS[g]} as \u201C${said}\u201D${expected ? `, but ${expected}` : ""}` : `states ${ELEMENT_LABELS[g]} as \u201C${said}\u201D, which does not count`);
18491
+ }
18492
+ return parts.join("; ");
18493
+ }
18283
18494
  async function runChargeNotice(input) {
18284
18495
  const id = "reminder.charge_notice";
18285
18496
  const configRefs = input.data.configRef ? [input.data.configRef] : [];
@@ -18307,6 +18518,12 @@ async function runChargeNotice(input) {
18307
18518
  }
18308
18519
  return c;
18309
18520
  };
18521
+ const chargeExpected = (e) => {
18522
+ const own = inForceAmount(e, input.ctx.events ?? [], chargePlanOpts.plans[0]?.currency);
18523
+ const cur = (chargePlanOpts.plans[0]?.currency ?? "usd").toUpperCase();
18524
+ if (own !== void 0) return `this subscription\u2019s charge is ${own.toFixed(2)} ${cur}`;
18525
+ return chargePlanOpts.plans.length ? `the plan is ${chargePlanOpts.plans.map((p) => `${planAmount(p).toFixed(2)} ${p.currency.toUpperCase()} ${planEvery(p)}`).join(", ")}` : "";
18526
+ };
18310
18527
  const counted = judged.filter((j) => j.state === "matched" || j.state === "partial" || j.state === "outside_window" || j.state === "missing");
18311
18528
  const matched = counted.filter((j) => j.state === "matched");
18312
18529
  const partial = counted.filter((j) => j.state === "partial");
@@ -18338,10 +18555,12 @@ async function runChargeNotice(input) {
18338
18555
  return makeResult(
18339
18556
  id,
18340
18557
  "fail",
18341
- `${incompleteMatched.length} of ${matched.length} charge notice(s) do not state ${describeMissing(x.missing)} (e.g. ${emailLabel(x.j.email)})`,
18558
+ `${incompleteMatched.length} of ${matched.length} charge notice(s) ${chargeGapPhrase(content(x.j.email), x.missing, chargeExpected(x.j.email))} (e.g. ${emailLabel(x.j.email)})`,
18342
18559
  [...refs2(incompleteMatched.map((i) => i.j)), ...refs2(missing), ...refs2(outside)],
18343
18560
  0.75,
18344
- data
18561
+ // Round 29 (pilot 28 item 11): when every failing notice is Checkbox Notices' own send, the plan fixes Checkbox's
18562
+ // template and settings; it never asks the merchant's agent to change an email it does not send.
18563
+ incompleteMatched.every((x2) => isCheckboxSentEmail(x2.j.email)) && !missing.length && !outside.length ? { ...data, contentByCheckbox: true } : data
18345
18564
  );
18346
18565
  }
18347
18566
  if (outside.length) {
@@ -18529,7 +18748,12 @@ async function overViews(checkId, input, headlines, evaluateView) {
18529
18748
  for (const view of views) outcomes.push({ view, unit: await evaluateView(view) });
18530
18749
  const failing = outcomes.filter((o) => o.unit.status === "fail");
18531
18750
  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);
18751
+ const mismatchOnly = checkId === "signup.disclosure.price" && !introOnly && failing.length > 0 && failing.every((o) => o.unit.meta?.priceMismatch === true);
18752
+ return aggregateViews(
18753
+ checkId,
18754
+ outcomes,
18755
+ 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
18756
+ );
18533
18757
  }
18534
18758
  function pageAnalysis(view) {
18535
18759
  const entry = heuristicCache.get(view) ?? {};
@@ -18821,6 +19045,12 @@ function introPriceUnit(u, view, plans, promosDeclared = false) {
18821
19045
  { quote: said, meta: { ...u.meta ?? {}, priceMismatch: true, introPriceMismatch: true, introFix: introFixLine(p.plan) } }
18822
19046
  );
18823
19047
  }
19048
+ function setOffAdvice(sizePx, contrast, weight, ref) {
19049
+ const refWeight = ref.fontWeight ?? 400;
19050
+ const larger = Math.ceil(ref.fontSizePx * SET_OFF_MIN_SIZE_RATIO);
19051
+ 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)"];
19052
+ 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("; ")}`;
19053
+ }
18824
19054
  function trialObservedOutsideCheckout(input) {
18825
19055
  const ctx = input.ctx;
18826
19056
  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 +19997,7 @@ ${view.pageText}`;
19767
19997
  if (setOff && !setOff.differentiators.length) {
19768
19998
  return unit(
19769
19999
  "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)`,
20000
+ 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
20001
  0.55,
19772
20002
  { meta: { ...meta, ...setOffMeta }, quote: q2 }
19773
20003
  );
@@ -20472,7 +20702,7 @@ var NOTICES_TEST_MODE_NOTE, NOTICE_KIND_CHECKS, NOT_SEEN_RE;
20472
20702
  var init_notices_on = __esm({
20473
20703
  "../rules/src/checks/notices-on.ts"() {
20474
20704
  "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.";
20705
+ 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
20706
  NOTICE_KIND_CHECKS = {
20477
20707
  acknowledgment: ["confirmation.email.sent", "confirmation.email.terms", "confirmation.email.cancellation_instructions", "confirmation.email.trial_terms"],
20478
20708
  trial_ending: ["reminder.trial_ending", "config.trial_reminder_enabled"],
@@ -20623,9 +20853,9 @@ function limiter(max) {
20623
20853
  run2();
20624
20854
  }
20625
20855
  };
20626
- return async (fn) => new Promise((resolve9, reject) => {
20856
+ return async (fn) => new Promise((resolve10, reject) => {
20627
20857
  queue.push(() => {
20628
- fn().then(resolve9, reject).finally(() => {
20858
+ fn().then(resolve10, reject).finally(() => {
20629
20859
  active--;
20630
20860
  next();
20631
20861
  });
@@ -22016,6 +22246,9 @@ function planFrequencyFix(c) {
22016
22246
  const every = /\bplans? (?:is|are) billed (every [a-z0-9 ]+?)(?= \/ | —|[.;,]|$)/i.exec(c.detail)?.[1];
22017
22247
  return every ? `State how often it is charged ("${every}").` : void 0;
22018
22248
  }
22249
+ function billingUnread(findings) {
22250
+ return findings.some((f) => String(f.checkId).startsWith("config.") && /^No billing platform (?:connected|settings to read)/i.test(f.detail ?? ""));
22251
+ }
22019
22252
  function rollup(items, opts = {}) {
22020
22253
  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
22254
  const pending = [];
@@ -22059,7 +22292,8 @@ function rollup(items, opts = {}) {
22059
22292
  label = `${counts.not_checked} not checked yet${counts.pending_live_check ? ` \xB7 ${plural2(counts.pending_live_check, "live check", "live checks")} pending` : ""}`;
22060
22293
  } else {
22061
22294
  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";
22295
+ const pass = opts.billingUnread ? CHECKS_PASS_UNREAD_LABEL : "Checks pass";
22296
+ 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
22297
  }
22064
22298
  return { status, label, counts, pending, suggestions, ...opts.groupOf ? { byTask: Object.fromEntries(groups) } : {} };
22065
22299
  }
@@ -22092,7 +22326,7 @@ function classifyFindings(findings) {
22092
22326
  info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
22093
22327
  }));
22094
22328
  }
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;
22329
+ 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
22330
  var init_status = __esm({
22097
22331
  "../rules/src/status.ts"() {
22098
22332
  "use strict";
@@ -22317,7 +22551,7 @@ var init_status = __esm({
22317
22551
  )
22318
22552
  })
22319
22553
  },
22320
- { code: "consent_implied", category: "content", when: has(/consent is implied by clicking/), build: () => ({ status: "needs_fix", fix: 'Add one unchecked checkbox "I agree to the automatic renewal terms above".' }) },
22554
+ { code: "consent_implied", category: "content", when: has(/consent is implied by clicking/), build: () => ({ status: "needs_fix", fix: 'Ask for a separate yes to the renewal: usually one unchecked box, "I agree to the automatic renewal terms above".' }) },
22321
22555
  { code: "consent_general_terms", category: "content", when: has(/covers general terms, not the/), build: () => ({ status: "needs_fix", fix: 'Make the checkbox about the renewal: "I agree to the automatic renewal terms above."' }) },
22322
22556
  // Round 23 (pilot 22 nontech-founder done blocker): "does not appear to be required" means the capture could not see
22323
22557
  // whether the box is enforced (a script may block the purchase, as Stripe's own page does). A person who can try it
@@ -22760,7 +22994,7 @@ var init_status = __esm({
22760
22994
  "signup.disclosure.term_length": 'Say how often it renews ("renews every month until you cancel").',
22761
22995
  "signup.disclosure.proximity": "Put the renewal line under the price, before the card and email fields and the pay button.",
22762
22996
  "signup.disclosure.conspicuous": "Show the renewal line at body size, semibold, in your normal text colour.",
22763
- "signup.consent.affirmative": 'Add one unchecked checkbox "I agree to the automatic renewal terms above".',
22997
+ "signup.consent.affirmative": 'Ask for a separate yes to the renewal: usually one unchecked box, "I agree to the automatic renewal terms above".',
22764
22998
  "signup.consent.not_prechecked": "Leave the consent checkbox unchecked by default.",
22765
22999
  "confirmation.email.sent": "Send an acknowledgment email right after sign-up (Checkbox has a template).",
22766
23000
  "confirmation.email.terms": "Restate the renewal terms (price, frequency, until cancelled) in the acknowledgment.",
@@ -22781,6 +23015,7 @@ var init_status = __esm({
22781
23015
  "terms.refund_policy_consistent": "Make the refund policy match the money-back promise on your site.",
22782
23016
  "terms.renewal_terms_present": "Add a short automatic-renewal section to your Terms."
22783
23017
  };
23018
+ CHECKS_PASS_UNREAD_LABEL = "Checks pass on what we can see";
22784
23019
  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
23020
  }
22786
23021
  });
@@ -23140,7 +23375,7 @@ async function runEvaluationInScope(input, opts) {
23140
23375
  }
23141
23376
  const classified = classifyFindings(findings);
23142
23377
  for (const { finding, info } of classified) finding.compliance = info;
23143
- const complianceRollup = rollup(classified.map(({ finding, info }) => ({ checkId: finding.checkId, info })), { groupOf: taskGroupOf });
23378
+ const complianceRollup = rollup(classified.map(({ finding, info }) => ({ checkId: finding.checkId, info })), { groupOf: taskGroupOf, billingUnread: billingUnread(findings) });
23144
23379
  const { score, insurable } = scoreFindings(findings);
23145
23380
  const summary = { pass: 0, fail: 0, needs_review: 0, not_applicable: 0 };
23146
23381
  for (const f of findings) summary[f.status]++;
@@ -24488,16 +24723,24 @@ function readParams(params, merchant, kind, asOf) {
24488
24723
  throw new PreflightInputError("The billing interval is unknown: pass line_items[].price_data.recurring.interval or merchant.interval", "merchant.interval");
24489
24724
  }
24490
24725
  const plan2 = resolvePlan(merged, "merchant.");
24491
- if (coupon && merchantIntro && coupon.duration !== "forever" && plan2.introAmount !== void 0) {
24726
+ if (coupon && merchantIntro && plan2.introAmount !== void 0) {
24492
24727
  const pct = typeof coupon.percent_off === "number" ? coupon.percent_off : void 0;
24493
24728
  const off = typeof coupon.amount_off === "number" ? coupon.amount_off : void 0;
24494
24729
  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) {
24730
+ const months = plan2.interval === "year" ? 12 * plan2.intervalCount : plan2.interval === "month" ? plan2.intervalCount : void 0;
24731
+ 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;
24732
+ const declaredPeriods = plan2.introPeriods ?? 1;
24733
+ const amountDiffers = fromCoupon !== void 0 && Math.abs(fromCoupon - plan2.introAmount) >= 1;
24734
+ const durationDiffers = couponPeriods !== void 0 && couponPeriods !== declaredPeriods;
24735
+ if (amountDiffers || durationDiffers) {
24496
24736
  const id = typeof coupon.id === "string" ? `coupon ${coupon.id}` : "the coupon";
24737
+ const per = (n) => n === Infinity ? "every billing period (forever)" : n === 1 ? "the first billing period" : `the first ${n} billing periods`;
24738
+ 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")})`}`;
24739
+ const declaredSays = `${formatMoney(plan2.introAmount, plan2.currency)} for ${per(declaredPeriods)}`;
24497
24740
  caveats.push({
24498
24741
  checks: ["signup.disclosure.price"],
24499
24742
  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`,
24743
+ 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
24744
  param: "params.discounts"
24502
24745
  });
24503
24746
  }
@@ -25851,7 +26094,7 @@ __export(check_exports, {
25851
26094
  default: () => check_default
25852
26095
  });
25853
26096
  import { readFileSync as readFileSync3 } from "node:fs";
25854
- import { resolve } from "node:path";
26097
+ import { resolve as resolve2 } from "node:path";
25855
26098
  function operationFor(endpoint) {
25856
26099
  const op = Object.values(OPERATIONS).find((o) => o.httpMethod === "POST" && o.path === endpoint);
25857
26100
  if (!op) throw new Error(`No API operation for POST ${endpoint}`);
@@ -25860,7 +26103,7 @@ function operationFor(endpoint) {
25860
26103
  async function readInput(ctx, file) {
25861
26104
  if (file === "-") return ctx.readStdin();
25862
26105
  try {
25863
- return readFileSync3(resolve(ctx.cwd, file), "utf8");
26106
+ return readFileSync3(resolve2(ctx.cwd, file), "utf8");
25864
26107
  } catch (err) {
25865
26108
  throw new UsageError(`Cannot read ${file}: ${err.code ?? err.message}`);
25866
26109
  }
@@ -26148,7 +26391,7 @@ var init_check = __esm({
26148
26391
 
26149
26392
  // src/ci/config.ts
26150
26393
  import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
26151
- import { dirname as dirname2, isAbsolute, join as join3, resolve as resolve2 } from "node:path";
26394
+ import { dirname as dirname3, isAbsolute, join as join3, resolve as resolve3 } from "node:path";
26152
26395
  function planList(config, ref) {
26153
26396
  if (Array.isArray(ref)) return ref;
26154
26397
  if (ref === "all" && !(config.plans ?? []).some((p) => p.name === "all")) return (config.plans ?? []).map((p) => p.name);
@@ -26156,7 +26399,7 @@ function planList(config, ref) {
26156
26399
  }
26157
26400
  function findConfig(cwd, explicit) {
26158
26401
  if (explicit) {
26159
- const p = isAbsolute(explicit) ? explicit : resolve2(cwd, explicit);
26402
+ const p = isAbsolute(explicit) ? explicit : resolve3(cwd, explicit);
26160
26403
  return existsSync3(p) ? p : void 0;
26161
26404
  }
26162
26405
  for (const f of CONFIG_FILES) if (existsSync3(join3(cwd, f))) return join3(cwd, f);
@@ -26177,7 +26420,7 @@ function loadConfig(path) {
26177
26420
  }
26178
26421
  const issues = validateConfig(value);
26179
26422
  if (issues.length) throw new ConfigError(path, issues);
26180
- return { config: value, path, dir: dirname2(path), raw };
26423
+ return { config: value, path, dir: dirname3(path), raw };
26181
26424
  }
26182
26425
  function validateConfig(value) {
26183
26426
  const is = new Issues();
@@ -26460,7 +26703,7 @@ function parseObjectLiteral(source, env2 = {}) {
26460
26703
  const p = new Parser(source, env2);
26461
26704
  if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
26462
26705
  const v = p.object("");
26463
- return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)] };
26706
+ return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
26464
26707
  }
26465
26708
  var UNKNOWN, Parser;
26466
26709
  var init_literal = __esm({
@@ -26474,6 +26717,7 @@ var init_literal = __esm({
26474
26717
  }
26475
26718
  i = 0;
26476
26719
  unresolved = [];
26720
+ fromEnv = {};
26477
26721
  ws() {
26478
26722
  for (; ; ) {
26479
26723
  while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
@@ -26560,7 +26804,10 @@ var init_literal = __esm({
26560
26804
  if (name === "null") return null;
26561
26805
  if (name === "undefined") return void 0;
26562
26806
  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;
26807
+ if (env2) {
26808
+ if (this.env[env2[1]] !== void 0 && path) this.fromEnv[path] = name;
26809
+ return this.env[env2[1]] ?? UNKNOWN;
26810
+ }
26564
26811
  return UNKNOWN;
26565
26812
  }
26566
26813
  this.skipExpr();
@@ -26875,11 +27122,17 @@ function findStripeKey(env2, processEnv) {
26875
27122
  sources.push([`${k} (environment)`, processEnv[k]]);
26876
27123
  sources.push([`${k} (.env)`, env2[k]]);
26877
27124
  }
26878
- const found = sources.filter((s2) => typeof s2[1] === "string" && /^(sk|rk)_test_[A-Za-z0-9]{10,}$/.test(s2[1].trim()));
26879
- const pick = found.find((s2) => s2[1].startsWith("rk_test_")) ?? found[0];
26880
- if (!pick) return void 0;
26881
- const key = pick[1].trim();
26882
- return { key, hint: `${key.slice(0, 8)}\u2026${key.slice(-4)}`, source: pick[0], restricted: key.startsWith("rk_test_") };
27125
+ const found = sources.filter((s2) => typeof s2[1] === "string" && /^(sk|rk)_(test|live)_[A-Za-z0-9]{10,}$/.test(s2[1].trim())).map(([src, v]) => [src, v.trim()]);
27126
+ const hint = (key) => `${key.slice(0, 8)}\u2026${key.slice(-4)}`;
27127
+ const pick = found.find((s2) => s2[1].startsWith("rk_test_"));
27128
+ if (pick) return { key: { key: pick[1], hint: hint(pick[1]), source: pick[0], restricted: true } };
27129
+ const other = found.find((s2) => s2[1].startsWith("sk_test_")) ?? found[0];
27130
+ if (!other) return {};
27131
+ return { refused: { hint: hint(other[1]), source: other[0], kind: /_live_/.test(other[1]) ? "live" : "secret" } };
27132
+ }
27133
+ function stripeKeyRefusedMessage(r) {
27134
+ const what = r.kind === "live" ? `${r.hint} in ${r.source} is a LIVE Stripe key. Live keys are never stored by the CLI or an agent: a person adds them in the Checkbox dashboard.` : `${r.hint} in ${r.source} is the Stripe secret key, which can do everything in your Stripe test account, so Checkbox refuses it.`;
27135
+ return `${what} For test runs, make a restricted TEST key instead: in Stripe, switch to Test mode, open Developers \u2192 API keys \u2192 Create restricted key, tick the permissions on https://checkbox.site/docs/guides/stripe-key, and put the key (rk_test_\u2026) in STRIPE_RESTRICTED_KEY in .env.local (never in a chat). Then run \`checkbox init\` again.`;
26883
27136
  }
26884
27137
  function sessionLiteral(text, index) {
26885
27138
  const open2 = text.indexOf("{", index);
@@ -26961,7 +27214,8 @@ function detectProject(root, processEnv = {}) {
26961
27214
  checkoutCode,
26962
27215
  payButtons,
26963
27216
  checkoutSessions,
26964
- ...stripeKey ? { stripeTestKey: stripeKey } : {},
27217
+ ...stripeKey.key ? { stripeTestKey: stripeKey.key } : {},
27218
+ ...stripeKey.refused ? { stripeKeyRefused: stripeKey.refused } : {},
26965
27219
  ...brand ? { brand } : {},
26966
27220
  plans: plansFromSessions(checkoutSessions),
26967
27221
  env: env2
@@ -27001,8 +27255,8 @@ var init_detect = __esm({
27001
27255
  });
27002
27256
 
27003
27257
  // 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";
27258
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
27259
+ import { basename, dirname as dirname4, join as join5, relative as relative2 } from "node:path";
27006
27260
  function walk3(root, visit) {
27007
27261
  let n = 0;
27008
27262
  const go = (dir, depth) => {
@@ -27035,20 +27289,70 @@ function emailKindOf(file) {
27035
27289
  const name = basename(file).replace(/\.[^.]+$/, "");
27036
27290
  return EMAIL_NAMES.find(([re]) => re.test(name))?.[1];
27037
27291
  }
27038
- function scaffoldConfig(root, env2 = {}) {
27292
+ function plansFromLiterals(text, file) {
27293
+ const out = [];
27294
+ for (const m of text.matchAll(/(?:(?:['"]?)([A-Za-z_$][\w$ -]*)['"]?\s*:\s*)?\{([^{}]{0,600})\}/g)) {
27295
+ const body = m[2];
27296
+ const interval = /\binterval\s*:\s*['"](day|week|month|year)['"]/.exec(body)?.[1];
27297
+ if (!interval) continue;
27298
+ const price3 = /\bprice\s*:\s*(\d+(?:\.\d{1,2})?)\b/.exec(body)?.[1];
27299
+ const minor = /\b(?:amount|unit_amount|price_cents|cents)\s*:\s*(\d+)\b/.exec(body)?.[1];
27300
+ const major = price3 !== void 0 ? Number(price3) : minor !== void 0 ? Number(minor) / 100 : void 0;
27301
+ if (major === void 0 || !(major > 0)) continue;
27302
+ const name = /\b(?:name|title|label)\s*:\s*['"]([^'"]{1,80})['"]/.exec(body)?.[1] ?? m[1]?.trim();
27303
+ if (!name) continue;
27304
+ const currency = /\bcurrency\s*:\s*['"]([a-z]{3})['"]/i.exec(body)?.[1]?.toLowerCase() ?? "usd";
27305
+ const count = /\binterval_?count\s*:\s*(\d+)/i.exec(body)?.[1];
27306
+ const trial = /\btrial(?:_period)?_?days\s*:\s*(\d+)/i.exec(body)?.[1];
27307
+ if (out.some((p) => p.name === name)) continue;
27308
+ const key = m[1]?.trim() && /^[A-Za-z_$][\w$]*$/.test(m[1].trim()) ? m[1].trim() : void 0;
27309
+ 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 });
27310
+ }
27311
+ return out;
27312
+ }
27313
+ function paramsBuilder(root, file, line2) {
27314
+ let text;
27315
+ try {
27316
+ text = readFileSync6(join5(root, file), "utf8");
27317
+ } catch {
27318
+ return void 0;
27319
+ }
27320
+ const from = text.split("\n").slice(line2 - 1).join("\n");
27321
+ const call = /checkout\.sessions\.create\s*\(\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/.exec(from);
27322
+ if (!call) return void 0;
27323
+ const fn = call[1];
27324
+ const takesArgs = call[2].trim().length > 0;
27325
+ const imp = new RegExp(`import\\s*\\{[^}]*\\b${fn}\\b[^}]*\\}\\s*from\\s*['"](\\.{1,2}/[^'"]+)['"]`).exec(text);
27326
+ let target;
27327
+ if (imp) {
27328
+ const base = join5(dirname4(file), imp[1]).split("\\").join("/");
27329
+ 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)));
27330
+ } else if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${fn}\\b|export\\s+const\\s+${fn}\\b`).test(text)) target = file;
27331
+ return target ? { module: `${target}#${fn}`, takesArgs } : void 0;
27332
+ }
27333
+ function scaffoldConfig(root, env2 = {}, facts = {}) {
27039
27334
  const det = detectProject(root, env2);
27040
27335
  const todos = [];
27041
27336
  const found = [];
27042
27337
  const names = /* @__PURE__ */ new Set();
27043
27338
  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");
27339
+ const fact = (k) => typeof facts[k] === "string" && facts[k].trim() ? facts[k].trim() : void 0;
27340
+ const brand = fact("merchant_name") ?? det.brand?.name;
27341
+ merchant.name = brand ?? "TODO: the brand subscribers see";
27342
+ if (!brand) todos.push("merchant.name: the brand subscribers see");
27343
+ if (fact("merchant_name")) found.push(`merchant name "${fact("merchant_name")}" (from checkbox init)`);
27046
27344
  const cancelPath = det.policyPaths.cancellation;
27047
- if (det.siteUrl && cancelPath) merchant.cancel_url = `${originOf(det.siteUrl)}${cancelPath}`;
27345
+ const siteUrl = fact("site_url") ?? det.siteUrl;
27346
+ const cancelFact = fact("cancellation_url") ?? fact("account_url");
27347
+ if (cancelFact) {
27348
+ merchant.cancel_url = cancelFact;
27349
+ found.push(`cancel URL ${cancelFact} (from checkbox init)`);
27350
+ } else if (siteUrl && cancelPath) merchant.cancel_url = `${originOf(siteUrl)}${cancelPath}`;
27048
27351
  else {
27049
27352
  merchant.cancel_url = "TODO: https://\u2026 page where subscribers cancel online";
27050
27353
  todos.push("merchant.cancel_url: the page where subscribers cancel online");
27051
27354
  }
27355
+ if (fact("support_email")) merchant.support_contact = fact("support_email");
27052
27356
  const plans = det.plans.map((p, i) => ({
27053
27357
  name: p.name ?? `Plan ${i + 1}`,
27054
27358
  amount: p.amount,
@@ -27058,6 +27362,32 @@ function scaffoldConfig(root, env2 = {}) {
27058
27362
  ...p.trial_days ? { trial_days: p.trial_days } : {}
27059
27363
  }));
27060
27364
  for (const p of det.plans) found.push(`plan ${p.name ?? ""} ${p.amount / 100} ${p.currency}/${p.interval} (${p.source})`.replace(/\s+/g, " "));
27365
+ const planKeys = /* @__PURE__ */ new Map();
27366
+ if (!plans.length && Array.isArray(facts.plans)) {
27367
+ for (const [i, p] of facts.plans.entries()) {
27368
+ if (typeof p.amount !== "number" || typeof p.interval !== "string") continue;
27369
+ 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 } : {} });
27370
+ found.push(`plan ${String(plans[plans.length - 1].name)} (from checkbox init --plan)`);
27371
+ }
27372
+ }
27373
+ if (!plans.length) {
27374
+ walk3(root, (rel2) => {
27375
+ if (!PLAN_FILE.test(rel2) || plans.length >= 8) return;
27376
+ let text = "";
27377
+ try {
27378
+ text = readFileSync6(join5(root, rel2), "utf8");
27379
+ } catch {
27380
+ return;
27381
+ }
27382
+ for (const p of plansFromLiterals(text, rel2)) {
27383
+ if (plans.some((x) => x.name === p.name)) continue;
27384
+ const { source, key, ...plan2 } = p;
27385
+ plans.push(plan2);
27386
+ if (key) planKeys.set(p.name, key);
27387
+ found.push(`plan ${p.name} ${p.price} ${p.currency}/${p.interval} (${source})`);
27388
+ }
27389
+ });
27390
+ }
27061
27391
  if (!plans.length) {
27062
27392
  plans.push({ name: "TODO: plan name", price: "TODO: e.g. 19.99", currency: "usd", interval: "month" });
27063
27393
  todos.push("plans: the price, interval and trial of each plan (the checkout uses Price ids, which cannot be read from code)");
@@ -27071,6 +27401,22 @@ function scaffoldConfig(root, env2 = {}) {
27071
27401
  const call = (perFile.get(s2.file) ?? 0) + 1;
27072
27402
  perFile.set(s2.file, call);
27073
27403
  const entry = { name: unique(names, `Stripe Checkout (${basename(s2.file)}${call > 1 ? ` #${call}` : ""})`), source: s2.file, ...call > 1 ? { call } : {} };
27404
+ const builder = !s2.params ? paramsBuilder(root, s2.file, s2.line) : void 0;
27405
+ if (builder) {
27406
+ found.push(`${s2.file}:${s2.line}: Checkout Session built by ${builder.module}`);
27407
+ const keyed = plans.length > 1 && plans.every((p) => planKeys.has(String(p.name)));
27408
+ if (builder.takesArgs && plans.length > 1) {
27409
+ for (const p of plans) {
27410
+ const n = String(p.name);
27411
+ 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 });
27412
+ }
27413
+ if (!keyed) todos.push(`${builder.module}: the argument that builds each plan's params ("args")`);
27414
+ } else {
27415
+ 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 });
27416
+ if (builder.takesArgs && !(plans.length === 1 && planKeys.size)) todos.push(`${builder.module}: the argument that builds the params ("args")`);
27417
+ }
27418
+ continue;
27419
+ }
27074
27420
  if (!s2.params) {
27075
27421
  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
27422
  delete entry.source;
@@ -27134,7 +27480,7 @@ function scaffoldConfig(root, env2 = {}) {
27134
27480
  found
27135
27481
  };
27136
27482
  }
27137
- var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique;
27483
+ var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
27138
27484
  var init_scaffold = __esm({
27139
27485
  "src/ci/scaffold.ts"() {
27140
27486
  "use strict";
@@ -27160,12 +27506,13 @@ var init_scaffold = __esm({
27160
27506
  names.add(n);
27161
27507
  return n;
27162
27508
  };
27509
+ PLAN_FILE = /(?:^|\/)(?:plans?|pricing|prices|tiers|subscriptions?|billing|products?)(?:\.config)?\.(?:[cm]?[jt]sx?|json)$/i;
27163
27510
  }
27164
27511
  });
27165
27512
 
27166
27513
  // src/ci/coverage.ts
27167
27514
  import { readFileSync as readFileSync7 } from "node:fs";
27168
- import { join as join6, relative as relative3, resolve as resolve3 } from "node:path";
27515
+ import { join as join6, relative as relative3, resolve as resolve4 } from "node:path";
27169
27516
  function callsIn(text, file) {
27170
27517
  const hits = [];
27171
27518
  for (const [re, kind] of PATTERNS) {
@@ -27187,7 +27534,7 @@ function callsIn(text, file) {
27187
27534
  }
27188
27535
  function coverageOf(loaded, root, files) {
27189
27536
  const cfg = loaded.config;
27190
- const toRoot = (p) => relative3(root, resolve3(loaded.dir, p)).split("\\").join("/");
27537
+ const toRoot = (p) => relative3(root, resolve4(loaded.dir, p)).split("\\").join("/");
27191
27538
  const calls = [];
27192
27539
  const visit = (rel2) => {
27193
27540
  if (!CODE.test(rel2) || TESTISH.test(rel2)) return;
@@ -27287,11 +27634,11 @@ var init_coverage = __esm({
27287
27634
 
27288
27635
  // src/ci/inputs.ts
27289
27636
  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";
27637
+ import { existsSync as existsSync6, readFileSync as readFileSync8 } from "node:fs";
27638
+ import { dirname as dirname5, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve5 } from "node:path";
27292
27639
  import { pathToFileURL as pathToFileURL2 } from "node:url";
27293
27640
  function readRel(dir, file) {
27294
- const abs = isAbsolute2(file) ? file : resolve4(dir, file);
27641
+ const abs = isAbsolute2(file) ? file : resolve5(dir, file);
27295
27642
  try {
27296
27643
  return readFileSync8(abs, "utf8");
27297
27644
  } catch (err) {
@@ -27301,28 +27648,44 @@ function readRel(dir, file) {
27301
27648
  }
27302
27649
  function hasTsx(dir) {
27303
27650
  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);
27651
+ if (existsSync6(join7(d, "node_modules", "tsx", "package.json"))) return true;
27652
+ const up = dirname5(d);
27653
+ if (up === d) break;
27654
+ d = up;
27655
+ }
27656
+ return false;
27657
+ }
27658
+ function declaresTsx(dir) {
27659
+ for (let d = dir, i = 0; i < 8; i++) {
27660
+ try {
27661
+ const pkg = JSON.parse(readFileSync8(join7(d, "package.json"), "utf8"));
27662
+ if (pkg.dependencies?.tsx || pkg.devDependencies?.tsx) return true;
27663
+ } catch {
27664
+ }
27665
+ const up = dirname5(d);
27306
27666
  if (up === d) break;
27307
27667
  d = up;
27308
27668
  }
27309
27669
  return false;
27310
27670
  }
27671
+ function addTsx(dir, detail = "") {
27672
+ return declaresTsx(dir) ? NOT_INSTALLED : `Add tsx to the project (npm install -D tsx${detail})`;
27673
+ }
27311
27674
  function loaderFlags(file, dir, nodeVersion = process.versions.node, features = process.features) {
27312
27675
  if (/\.[cm]?js$/.test(file)) return [];
27313
27676
  if (hasTsx(dir)) return ["--import", "tsx"];
27314
- if (/\.[jt]sx$/.test(file)) return `${file}: JSX modules need tsx (npm install -D tsx), or export the rendered { subject, html } from a .js / .ts file`;
27677
+ if (/\.[jt]sx$/.test(file)) return declaresTsx(dir) ? `${file}: ${NOT_INSTALLED}` : `${file}: JSX modules need tsx (npm install -D tsx), or export the rendered { subject, html } from a .js / .ts file`;
27315
27678
  if (features.typescript) return [];
27316
27679
  const [major, minor] = nodeVersion.split(".").map(Number);
27317
27680
  if (major > 22 || major === 22 && minor >= 6) return ["--experimental-strip-types"];
27318
- return `${file}: TypeScript modules need Node 22.6+ (type stripping) or tsx in the project (npm install -D tsx); or point "file" at a JSON export`;
27681
+ return declaresTsx(dir) ? `${file}: ${NOT_INSTALLED}` : `${file}: TypeScript modules need Node 22.6+ (type stripping) or tsx in the project (npm install -D tsx); or point "file" at a JSON export`;
27319
27682
  }
27320
27683
  function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
27321
27684
  const hash = ref.lastIndexOf("#");
27322
27685
  const file = hash > 0 ? ref.slice(0, hash) : ref;
27323
27686
  const name = hash > 0 ? ref.slice(hash + 1) : "default";
27324
- const abs = resolve4(dir, file);
27325
- if (!existsSync5(abs)) return Promise.reject(new InputError(`${file} does not exist (paths are relative to checkbox.json)`));
27687
+ const abs = resolve5(dir, file);
27688
+ if (!existsSync6(abs)) return Promise.reject(new InputError(`${file} does not exist (paths are relative to checkbox.json)`));
27326
27689
  const flags = loaderFlags(file, dir);
27327
27690
  if (typeof flags === "string") return Promise.reject(new InputError(flags));
27328
27691
  return new Promise((resolvePromise, reject) => {
@@ -27341,12 +27704,39 @@ function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
27341
27704
  return;
27342
27705
  }
27343
27706
  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")}`));
27707
+ reject(new InputError(`${ref}: ${own ?? (err && err.killed ? `did not finish within ${timeoutMs / 1e3}s` : moduleErrorMessage(stderr, dir, flags) || err?.message || "no output")}`));
27346
27708
  }
27347
27709
  );
27348
27710
  });
27349
27711
  }
27712
+ function moduleErrorMessage(stderr, dir, flags = []) {
27713
+ const text = stderr.trim();
27714
+ if (!text) return "";
27715
+ const rel2 = (p) => {
27716
+ const r = relative4(dir, p.replace(/^file:\/\//, ""));
27717
+ return r && !r.startsWith("..") ? r.split("\\").join("/") : p;
27718
+ };
27719
+ const tsx = flags.includes("tsx");
27720
+ const missing = /Cannot find (module|package) '([^']+)' imported from (\S+)/.exec(text);
27721
+ if (missing) {
27722
+ const [, what, target, from] = missing;
27723
+ if (what === "package" || !/^[./]|^[A-Za-z]:[\\/]/.test(target) && !target.startsWith(dir)) {
27724
+ 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).`;
27725
+ }
27726
+ const noExt = !/\.[cm]?[jt]sx?$|\.json$/.test(target);
27727
+ if (noExt && !tsx) {
27728
+ return `${rel2(from)} imports ${rel2(target)} without a file extension, which Node cannot resolve on its own. ${addTsx(dir, "; checkbox ci then imports modules the way your bundler does")}, or write the import with its extension (e.g. './${rel2(target).split("/").pop()}.ts').`;
27729
+ }
27730
+ return `${rel2(from)} imports ${rel2(target)}, which does not exist${noExt ? " (tsx found no .ts / .js file of that name either)" : ""}.`;
27731
+ }
27732
+ if (/ERR_UNKNOWN_FILE_EXTENSION/.test(text)) {
27733
+ const file = /Unknown file extension "([^"]+)" for (\S+)/.exec(text);
27734
+ return `Node cannot import ${file ? `${rel2(file[2])} (${file[1]})` : "a file the module imports"}. ${addTsx(dir)}, or point the entry at a .js / .ts file that imports no ${file?.[1] ?? "such"} files.`;
27735
+ }
27736
+ const lines = text.split("\n").map((l) => l.trim()).filter((l) => l && !/^at /.test(l) && !/^Node\.js v\d/.test(l));
27737
+ const first2 = lines.find((l) => /^(?:[A-Z]\w*)?Error\b.*:/.test(l)) ?? lines.find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "";
27738
+ return first2.replace(/file:\/\/\S+/g, (u) => rel2(u)).replace(/\s+/g, " ").slice(0, 400);
27739
+ }
27350
27740
  function paramsFromSource(text, file, call = 1, env2 = {}) {
27351
27741
  const calls = [...text.matchAll(/checkout\.sessions\.create\s*\(/g)];
27352
27742
  if (!calls.length) throw new InputError(`${file} has no stripe.checkout.sessions.create( call`);
@@ -27357,7 +27747,32 @@ function paramsFromSource(text, file, call = 1, env2 = {}) {
27357
27747
  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
27748
  const parsed = parseObjectLiteral(lit, env2);
27359
27749
  if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
27360
- return { params: parsed.value, line: line2, unresolved: parsed.unresolved };
27750
+ return { params: parsed.value, line: line2, unresolved: parsed.unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
27751
+ }
27752
+ function lineOfAny(text, needles) {
27753
+ const lower2 = text.toLowerCase();
27754
+ for (const n of needles) {
27755
+ const k = n.trim().toLowerCase();
27756
+ if (k.length < 2) continue;
27757
+ const i = lower2.indexOf(k);
27758
+ if (i >= 0) return text.slice(0, i).split("\n").length;
27759
+ }
27760
+ return void 0;
27761
+ }
27762
+ function exportLine(dir, ref) {
27763
+ const hash = ref.lastIndexOf("#");
27764
+ const file = hash > 0 ? ref.slice(0, hash) : ref;
27765
+ const name = hash > 0 ? ref.slice(hash + 1) : "default";
27766
+ let text;
27767
+ try {
27768
+ text = readFileSync8(resolve5(dir, file), "utf8");
27769
+ } catch {
27770
+ return 1;
27771
+ }
27772
+ const esc3 = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27773
+ 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");
27774
+ const m = re.exec(text);
27775
+ return m ? text.slice(0, m.index).split("\n").length + (m[0].startsWith("\n") ? 1 : 0) : 1;
27361
27776
  }
27362
27777
  function projectEnv(dir) {
27363
27778
  const out = {};
@@ -27389,7 +27804,7 @@ function planTerms(config, plan2) {
27389
27804
  return t;
27390
27805
  }
27391
27806
  function rel(loaded, root, file) {
27392
- return relative4(root, resolve4(loaded.dir, file)).split("\\").join("/") || file;
27807
+ return relative4(root, resolve5(loaded.dir, file)).split("\\").join("/") || file;
27393
27808
  }
27394
27809
  function emailFromFile(content, file, subject) {
27395
27810
  let body = content;
@@ -27423,15 +27838,22 @@ async function stripeJob(loaded, e, o, root) {
27423
27838
  }
27424
27839
  } else if (e.module) {
27425
27840
  const file = e.module.replace(/#.*$/, "");
27426
- job.target = { file: rel(loaded, root, file), line: 1 };
27841
+ job.target = { file: rel(loaded, root, file), line: exportLine(dir, e.module) };
27427
27842
  job.inputs.push(file);
27428
27843
  params = await runModule(dir, e.module, e.args ?? [], o.env);
27844
+ 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`] : []);
27845
+ if (unset.length) {
27846
+ job.placeholders = Object.fromEntries(unset.map((p) => [p, "{{your Stripe Price id: unset when checkbox ci ran the module}}"]));
27847
+ 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.`);
27848
+ }
27429
27849
  } else {
27430
27850
  const src = e.source;
27431
27851
  job.inputs.push(src);
27432
27852
  const r = paramsFromSource(readRel(dir, src), src, e.call ?? 1, projectEnv(dir));
27433
27853
  job.target = { file: rel(loaded, root, src), line: r.line };
27854
+ job.fromSource = true;
27434
27855
  params = r.params;
27856
+ if (r.fromEnv) job.placeholders = Object.fromEntries(Object.entries(r.fromEnv).map(([p, expr]) => [p, `{{${expr}}}`]));
27435
27857
  if (r.unresolved.length) {
27436
27858
  job.partial = true;
27437
27859
  job.unresolved = r.unresolved;
@@ -27507,16 +27929,32 @@ function pageJob(loaded, e, o, root) {
27507
27929
  if (e.source) {
27508
27930
  job.target = { file: rel(loaded, root, e.source), line: 1 };
27509
27931
  job.inputs.push(e.source);
27932
+ const src = e.source;
27933
+ job.locate = (needles) => {
27934
+ try {
27935
+ return lineOfAny(readRel(dir, src), needles);
27936
+ } catch {
27937
+ return void 0;
27938
+ }
27939
+ };
27510
27940
  }
27511
27941
  if (e.url) {
27512
- if (!o.online) job.skip = "URL pages are fetched by the API: run with --online (and CHECKBOX_SECRET_KEY)";
27942
+ if (!o.online) job.skip = 'URL pages are fetched by the API: run with --online and a live-mode key (ck_live_\u2026 on an activated account), or list the built page under "file" to check it offline';
27513
27943
  else job.body = { url: e.url, ...common3 };
27514
27944
  return job;
27515
27945
  }
27516
27946
  try {
27517
27947
  if (!e.source) job.target = { file: rel(loaded, root, e.file), line: 1 };
27518
27948
  job.inputs.push(e.file);
27519
- job.body = { html: readRel(dir, e.file), ...common3 };
27949
+ let html;
27950
+ try {
27951
+ html = readRel(dir, e.file);
27952
+ } catch (err) {
27953
+ if (!existsSync6(resolve5(dir, e.file))) throw new InputError(`${e.file} does not exist (paths are relative to checkbox.json). If it is build output, build it before checkbox ci: in GitHub Actions, add a build step before the Checkbox step (\`checkbox ci init\` writes one when package.json has a build script).`);
27954
+ throw err;
27955
+ }
27956
+ if (!e.source) job.locate = (needles) => lineOfAny(html, needles);
27957
+ job.body = { html, ...common3 };
27520
27958
  } catch (err) {
27521
27959
  job.error = err.message;
27522
27960
  }
@@ -27533,7 +27971,7 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
27533
27971
  msg2 = emailFromFile(readRel(dir, e.file), e.file, e.subject);
27534
27972
  } else if (e.module) {
27535
27973
  const file = e.module.replace(/#.*$/, "");
27536
- job.target = { file: rel(loaded, root, file), line: 1 };
27974
+ job.target = { file: rel(loaded, root, file), line: exportLine(dir, e.module) };
27537
27975
  job.inputs.push(file);
27538
27976
  const v = await runModule(dir, e.module, e.args ?? [], o.env);
27539
27977
  if (typeof v === "string") msg2 = emailFromFile(v, /^\s*</.test(v) ? "x.html" : "x.txt", e.subject);
@@ -27581,7 +28019,7 @@ async function resolveJobs(loaded, o) {
27581
28019
  }
27582
28020
  return jobs;
27583
28021
  }
27584
- var InputError, CHILD, jurisdictionsOf;
28022
+ var InputError, NOT_INSTALLED, CHILD, jurisdictionsOf;
27585
28023
  var init_inputs = __esm({
27586
28024
  "src/ci/inputs.ts"() {
27587
28025
  "use strict";
@@ -27594,6 +28032,7 @@ var init_inputs = __esm({
27594
28032
  this.name = "InputError";
27595
28033
  }
27596
28034
  };
28035
+ NOT_INSTALLED = "tsx is in package.json but not installed: install the project's dependencies before checkbox ci (npm ci; in GitHub Actions a `- run: npm ci` step before the Checkbox step, which `checkbox ci init` writes)";
27597
28036
  CHILD = `
27598
28037
  const [url, name, argsJson] = process.argv.slice(1);
27599
28038
  const mod = await import(url);
@@ -27612,102 +28051,6 @@ process.exit(0);
27612
28051
  }
27613
28052
  });
27614
28053
 
27615
- // src/ci/report.ts
27616
- function icon(s2, c) {
27617
- if (s2 === "pass") return c.green("\u2713");
27618
- if (s2 === "fail" || s2 === "error") return c.red("\u2717");
27619
- if (s2 === "needs_review") return c.yellow("?");
27620
- return c.dim("\u2013");
27621
- }
27622
- function overallLine(run2) {
27623
- const s2 = run2.summary;
27624
- const parts = [];
27625
- if (s2.error) parts.push(`${s2.error} could not run`);
27626
- if (s2.fail) parts.push(`${s2.fail} need${s2.fail === 1 ? "s" : ""} a fix`);
27627
- if (s2.needs_review) parts.push(`${s2.needs_review} need${s2.needs_review === 1 ? "s" : ""} review${run2.strict ? " (strict)" : ""}`);
27628
- if (s2.pass) parts.push(`${s2.pass} pass`);
27629
- if (s2.not_applicable) parts.push(`${s2.not_applicable} not required`);
27630
- if (s2.skipped) parts.push(`${s2.skipped} skipped`);
27631
- return parts.join(" \xB7 ") || "nothing checked";
27632
- }
27633
- function renderHuman(run2, c, opts = {}) {
27634
- const lines = [];
27635
- const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
27636
- lines.push(`${c.bold("Checkbox CI")} ${run2.config} \xB7 ${run2.mode}${versions} \xB7 ${run2.checks.length} check${run2.checks.length === 1 ? "" : "s"}`);
27637
- const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
27638
- for (const x of run2.checks) {
27639
- const label = x.status === "fail" || x.status === "error" ? c.red(x.label) : x.status === "needs_review" ? c.yellow(x.label) : x.status === "pass" ? c.green(x.label) : c.dim(x.label);
27640
- lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
27641
- if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
27642
- if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
27643
- 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, " ")}`));
27645
- if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
27646
- }
27647
- const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
27648
- lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
27649
- if (run2.checks.some((x) => x.corrected) && !opts.verbose) lines.push(c.dim("Corrected params / text: --verbose, --json, or the job summary in GitHub Actions."));
27650
- lines.push(c.dim(run2.disclaimer));
27651
- return lines;
27652
- }
27653
- function annotations(run2) {
27654
- const out = [];
27655
- 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;
27657
- if (!level) continue;
27658
- const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
27659
- 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");
27661
- out.push(`::${level} ${props.join(",")}::${escData(body)}`);
27662
- }
27663
- return out;
27664
- }
27665
- function stepSummary(run2) {
27666
- const lines = [];
27667
- lines.push("## Checkbox compliance checks", "");
27668
- const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
27669
- lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
27670
- lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
27671
- 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)` : ""}` : "";
27673
- 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
- }
27675
- const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
27676
- for (const x of detailed) {
27677
- lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
27678
- if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
27679
- if (x.corrected) {
27680
- const fence = x.corrected.body.includes("```") ? "````" : "```";
27681
- lines.push(`${fence}${x.corrected.format === "json" ? "json" : "text"}`, x.corrected.body, fence, "");
27682
- }
27683
- lines.push("</details>");
27684
- }
27685
- lines.push("", `<sub>${run2.disclaimer} Docs: https://checkbox.site/docs/guides/ci</sub>`, "");
27686
- return lines.join("\n");
27687
- }
27688
- function escapeHtml(s2) {
27689
- return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27690
- }
27691
- var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
27692
- var init_report = __esm({
27693
- "src/ci/report.ts"() {
27694
- "use strict";
27695
- init_output();
27696
- TYPE_LABEL = {
27697
- stripe_checkout: "Stripe Checkout",
27698
- disclosure: "Disclosure",
27699
- page: "Page",
27700
- email: "Email",
27701
- notice_schedule: "Notice schedule"
27702
- };
27703
- where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
27704
- escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
27705
- escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
27706
- cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
27707
- STATUS_MD = { pass: "\u2705", fail: "\u274C", needs_review: "\u26A0\uFE0F", error: "\u274C", skipped: "\u23ED\uFE0F", not_applicable: "\u2796" };
27708
- }
27709
- });
27710
-
27711
28054
  // ../rules/src/fixes/conflicts.ts
27712
28055
  var init_conflicts = __esm({
27713
28056
  "../rules/src/fixes/conflicts.ts"() {
@@ -27874,6 +28217,10 @@ function pickDaysBefore(kind, range) {
27874
28217
  if (open2) return lo + OPEN_WINDOW_MARGIN_DAYS;
27875
28218
  return lo + Math.floor((range.maxDays - lo) / 2);
27876
28219
  }
28220
+ function sendLeadDays(range, days) {
28221
+ if (range.maxDays >= OPEN_ENDED_DAYS2 || !Number.isInteger(days) || days <= 0) return days;
28222
+ return days <= range.maxDays ? days + BOUNDED_SEND_MARGIN_DAYS : days;
28223
+ }
27877
28224
  function stabWindows(windows) {
27878
28225
  const sorted = [...windows].sort((a, b) => a.window.maxDays - b.window.maxDays || a.window.minDays - b.window.minDays);
27879
28226
  const groups = [];
@@ -27891,11 +28238,15 @@ function stabWindows(windows) {
27891
28238
  function slotOf(kind, anchorAt, range, members, daysBefore) {
27892
28239
  const days = daysBefore ?? pickDaysBefore(kind, range);
27893
28240
  const maxDays = Math.min(range.maxDays, OPEN_ENDED_DAYS2);
28241
+ const lead = sendLeadDays(range, days);
28242
+ const sendAt = new Date(anchorAt.getTime() - lead * DAY_MS5);
28243
+ const opensAt = new Date(anchorAt.getTime() - maxDays * DAY_MS5);
27894
28244
  return {
27895
28245
  slot: `d${days}`,
27896
28246
  daysBefore: days,
27897
- sendAt: new Date(anchorAt.getTime() - days * DAY_MS5),
27898
- opensAt: new Date(anchorAt.getTime() - maxDays * DAY_MS5),
28247
+ sendAt,
28248
+ // The send time is inside [opensAt, closesAt] (a bounded window's send sits inside its maximum's rounding).
28249
+ opensAt: sendAt.getTime() < opensAt.getTime() ? sendAt : opensAt,
27899
28250
  closesAt: new Date(anchorAt.getTime() - range.minDays * DAY_MS5),
27900
28251
  range: { minDays: range.minDays, maxDays },
27901
28252
  jurisdictions: [...new Set(members.map((m) => m.jurisdiction))].sort(),
@@ -27976,7 +28327,7 @@ function renewalReminderApplies(termMonths2, jurisdictions, asOf, requirements)
27976
28327
  (r) => r.appliesWhen?.minTermMonths !== void 0 && r.appliesWhen.minTermMonths <= termMonths2
27977
28328
  );
27978
28329
  }
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;
28330
+ 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
28331
  var init_timing = __esm({
27981
28332
  "../../apps/api/src/notices/timing.ts"() {
27982
28333
  "use strict";
@@ -27987,6 +28338,7 @@ var init_timing = __esm({
27987
28338
  OPEN_WINDOW_MARGIN_DAYS = 2;
27988
28339
  TRIAL_MIN_MARGIN_DAYS = 0.5;
27989
28340
  TRIAL_SIGNUP_GAP_DAYS = 1 / 24;
28341
+ BOUNDED_SEND_MARGIN_DAYS = 0.25;
27990
28342
  PRICE_CHANGE_MAX_LEAD_DAYS = 60;
27991
28343
  ANNUAL_TERMS_DEFAULT_WINDOW = { minDays: 15, maxDays: 45 };
27992
28344
  NOTICE_CHECKS2 = {
@@ -28039,7 +28391,7 @@ function singleDayNote(requirementIds, days) {
28039
28391
  const lower2 = [...new Set(windows.filter((w) => w.minDays === days).map((w) => jur(w.id)))];
28040
28392
  const upper = [...new Set(windows.filter((w) => w.maxDays === days).map((w) => jur(w.id)))];
28041
28393
  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.`;
28394
+ 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
28395
  }
28044
28396
  function addInterval(d, interval, n) {
28045
28397
  if (interval === "day") return new Date(d.getTime() + n * DAY_MS5);
@@ -28202,6 +28554,32 @@ function operationFor2(kind) {
28202
28554
  function onlineEngine(client) {
28203
28555
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
28204
28556
  }
28557
+ function ciVocabulary(text) {
28558
+ 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").replace(/In test mode, pass the page source as html\./g, 'With a test key, list the built page under "file" in checkbox.json instead of "url" (or run --online with a live key, ck_live_\u2026).');
28559
+ }
28560
+ function pricingPageFixes(fixes) {
28561
+ const reworded = fixes.map(
28562
+ (f) => f.replace(/,? before the card and email fields and the (?:pay|purchase) button/g, ", next to the plan's button").replace(/,? before the card and email fields/g, ", next to the plan's button")
28563
+ );
28564
+ const out = [];
28565
+ const renewalLineFor = /* @__PURE__ */ new Set();
28566
+ for (const f of reworded) {
28567
+ const card = /^(Plan card [^:]+): /.exec(f)?.[1] ?? "";
28568
+ const isRenewalLine = /\b(?:Add one line under the price|Put the renewal line under the price|Move the renewal line under the price)/.test(f);
28569
+ if (isRenewalLine) {
28570
+ if (renewalLineFor.has(card)) continue;
28571
+ renewalLineFor.add(card);
28572
+ }
28573
+ if (!out.includes(f)) out.push(f);
28574
+ }
28575
+ return out;
28576
+ }
28577
+ function stripeConsentFix(text, params) {
28578
+ if (!/^(Ask for a separate yes to the renewal|Add one unchecked checkbox)/.test(text)) return text;
28579
+ const cc = params?.consent_collection;
28580
+ if (cc?.terms_of_service === "required") return text;
28581
+ 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.';
28582
+ }
28205
28583
  function ciStatusOf(r) {
28206
28584
  const cs = r.compliance_status;
28207
28585
  if (r.verdict === "not_applicable") return "not_applicable";
@@ -28210,9 +28588,9 @@ function ciStatusOf(r) {
28210
28588
  if (cs && OK_STATUSES.has(cs)) return "pass";
28211
28589
  return "needs_review";
28212
28590
  }
28213
- function fixesOf(r) {
28591
+ function fixesOf(r, onlyNeedsFix = false) {
28214
28592
  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)));
28593
+ 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
28594
  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
28595
  for (const c of failing) {
28218
28596
  const base = c.compliance?.fix || c.fix || c.compliance?.question?.text || c.explanation || c.title || c.check_id;
@@ -28225,14 +28603,25 @@ function fixesOf(r) {
28225
28603
  function failingCards(explanation) {
28226
28604
  if (!explanation) return [];
28227
28605
  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]))];
28606
+ return [...new Set([...explanation.matchAll(/plan button “([^”]{1,80})”(?: in the card “([^”]{1,80})”)?/g)].map((m) => m[2] ?? m[1]))];
28607
+ }
28608
+ function withPlaceholders(params, placeholders) {
28609
+ if (!placeholders || !params || typeof params !== "object") return params;
28610
+ const copy = JSON.parse(JSON.stringify(params));
28611
+ for (const [path, value] of Object.entries(placeholders)) {
28612
+ const keys = path.split(/\.|\[(\d+)\]/).filter((k) => k !== void 0 && k !== "");
28613
+ let node = copy;
28614
+ for (const k of keys.slice(0, -1)) node = node && typeof node === "object" ? node[k] : void 0;
28615
+ if (node && typeof node === "object") node[keys[keys.length - 1]] = value;
28616
+ }
28617
+ return copy;
28229
28618
  }
28230
- function correctedOf(type, r, partial = false) {
28619
+ function correctedOf(type, r, partial = false, placeholders) {
28231
28620
  if (type === "stripe_checkout" && r.correction?.patch?.length && partial) {
28232
28621
  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
28622
  }
28234
28623
  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) };
28624
+ 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
28625
  }
28237
28626
  const perPlan = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
28238
28627
  if (type === "page" && perPlan.length >= 2 && ciStatusOf(r) !== "pass") {
@@ -28264,11 +28653,38 @@ ${r.template.text}` };
28264
28653
  }
28265
28654
  return void 0;
28266
28655
  }
28267
- function noticeCoverage(config, planName, schedule) {
28656
+ function declaredByCheckbox(config) {
28657
+ const sb = config.notices?.sent_by_checkbox;
28658
+ return sb === true ? Object.values(NOTICE_TO_EMAIL) : Array.isArray(sb) ? sb : [];
28659
+ }
28660
+ function sentByNotices(seen) {
28661
+ if ("error" in seen || seen.mode !== "checkbox" || seen.real_sends === "off") return /* @__PURE__ */ new Set();
28662
+ return new Set(seen.enabled);
28663
+ }
28664
+ function noticesCrossCheck(config, seen, target) {
28665
+ const declared = declaredByCheckbox(config);
28666
+ if (!declared.length) return void 0;
28667
+ const base = { name: "Checkbox Notices (site settings)", type: "notice_schedule", target, warnings: [] };
28668
+ if ("error" in seen) {
28669
+ return { ...base, status: "needs_review", label: "Not checked: could not read the site's Notice settings", message: seen.error, fixes: [] };
28670
+ }
28671
+ const real = sentByNotices(seen);
28672
+ const all = config.notices?.sent_by_checkbox === true;
28673
+ const missing = all ? real.size ? [] : declared : declared.filter((k) => !real.has(k));
28674
+ if (!missing.length) {
28675
+ 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)" : "";
28676
+ 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: [] };
28677
+ }
28678
+ 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`;
28679
+ 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.`;
28680
+ 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] };
28681
+ }
28682
+ function noticeCoverage(config, planName, schedule, opts = {}) {
28268
28683
  const plan2 = (config.plans ?? []).find((p) => p.name === planName);
28269
28684
  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 : []);
28685
+ const declared = declaredByCheckbox(config);
28686
+ const real = opts.notices && !("error" in opts.notices) ? sentByNotices(opts.notices) : void 0;
28687
+ const byCheckbox = new Set(real ? declared.filter((k) => real.has(k)) : declared);
28272
28688
  for (const k of byCheckbox) have.add(k);
28273
28689
  const yearly = plan2 ? (plan2.interval === "year" ? 12 * (plan2.interval_count ?? 1) : plan2.interval === "month" ? plan2.interval_count ?? 1 : 0) >= 12 : false;
28274
28690
  if (yearly && have.has("renewal_reminder")) have.add("annual_terms");
@@ -28280,8 +28696,8 @@ function noticeCoverage(config, planName, schedule) {
28280
28696
  const missing = [...required].filter(([k]) => !have.has(k));
28281
28697
  if (!required.size) return { status: "not_applicable", label: "No reminder emails required", fixes: [] };
28282
28698
  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.`);
28699
+ 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: [] };
28700
+ 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
28701
  return { status: "needs_review", label: `${missing.length} required email${missing.length === 1 ? "" : "s"} not in checkbox.json`, fix: fixes[0], fixes };
28286
28702
  }
28287
28703
  function unverifiedOffer(r) {
@@ -28295,8 +28711,8 @@ function unverifiedOffer(r) {
28295
28711
  if (coupon && open2.length) return coupon.message;
28296
28712
  return void 0;
28297
28713
  }
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] };
28714
+ async function runJob(job, engine, config, opts = {}) {
28715
+ const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: { ...job.target }, ...job.fromSource ? { fromSource: true } : {}, warnings: [...job.warnings] };
28300
28716
  if (job.error) return { ...base, status: "error", label: "Could not run", message: job.error, fixes: [] };
28301
28717
  if (job.skip || !job.body) return { ...base, status: "skipped", label: "Skipped", message: job.skip ?? "nothing to check", fixes: [] };
28302
28718
  let report;
@@ -28305,49 +28721,91 @@ async function runJob(job, engine, config) {
28305
28721
  } catch (err) {
28306
28722
  const e = err;
28307
28723
  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 } : {} };
28724
+ 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
28725
  }
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);
28726
+ if (job.type === "notice_schedule") return { ...base, ...noticeCoverage(config, job.plan, report, opts), report };
28727
+ const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
28728
+ for (const w of report.warnings ?? []) {
28729
+ const text = ciVocabulary(w.message);
28730
+ base.warnings.push(text);
28731
+ if (ALERT_CODES.has(w.code)) alerts.push(text);
28732
+ }
28733
+ if (alerts.length) Object.assign(base, { alerts });
28312
28734
  const status = ciStatusOf(report);
28313
- const fixes = status === "pass" || status === "not_applicable" ? [] : fixesOf(report);
28735
+ const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
28736
+ let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
28737
+ const pricingPage = job.type === "page" && job.body?.page_kind === "pricing";
28738
+ if (pricingPage) fixes = pricingPageFixes(fixes);
28314
28739
  const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
28315
28740
  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
28741
  if (unread) {
28317
28742
  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
28743
  }
28744
+ const literal = [];
28745
+ if (unread) {
28746
+ const consentRead = !(job.unresolved ?? []).some((p) => /^(?:consent_collection|\.\.\.)/.test(p));
28747
+ const consentFails = (report.checks ?? []).some((c) => c.check_id === "signup.consent.affirmative" && (c.status === "fail" || c.compliance_status === "needs_fix"));
28748
+ if (consentRead && consentFails && params?.consent_collection?.terms_of_service !== "required") literal.push(stripeConsentFix("Add one unchecked checkbox", params));
28749
+ fixes.unshift(...literal);
28750
+ }
28319
28751
  const offer = !unread && status === "needs_review" && job.type === "stripe_checkout" ? unverifiedOffer(report) : void 0;
28320
28752
  if (offer) {
28321
28753
  fixes.unshift(
28322
28754
  `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
28755
  );
28324
28756
  }
28325
- const finalStatus = unread ? "needs_review" : status;
28326
- const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial);
28757
+ if (job.locate && (status === "fail" || status === "needs_review")) {
28758
+ const named = (report.checks ?? []).filter((c) => c.status === "fail" || c.status === "needs_review").flatMap((c) => failingCards(c.explanation));
28759
+ const needles = named.flatMap((n) => [n.split(", ")[0], ...n.split(", ").slice(1)]);
28760
+ const line2 = needles.length ? job.locate(needles) : void 0;
28761
+ if (line2) base.target = { ...base.target, line: line2 };
28762
+ }
28763
+ const declaredCoupon = !unread && status === "pass" && job.type === "stripe_checkout" ? (report.warnings ?? []).find((w) => w.code === "coupon_unverified" && /merchant\.intro_\* and are not verified/.test(w.message)) : void 0;
28764
+ if (declaredCoupon) {
28765
+ const id = /discounts use (coupon|promotion code) (\S+?),/.exec(declaredCoupon.message);
28766
+ const what = id ? `${id[1]} ${id[2]}` : "the coupon";
28767
+ fixes.unshift(
28768
+ engine.mode === "offline" ? `The intro price is declared in checkbox.json, not verified: offline, Checkbox cannot read ${what} from Stripe. Run with --online (CHECKBOX_SECRET_KEY, and the site's Stripe key connected) so Checkbox reads it and compares its percent / amount off and duration with the declared intro.` : `The intro price is declared in checkbox.json, not verified: Checkbox could not read ${what} through the site's connected Stripe key (is the site known, via --site or CHECKBOX_SITE, and does the key have Coupons: Read?). Until it is read, its percent / amount off and duration are not compared with the declared intro.`
28769
+ );
28770
+ }
28771
+ const finalStatus = unread ? literal.length ? "fail" : "needs_review" : declaredCoupon ? "needs_review" : status;
28772
+ const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders);
28773
+ if (corrected?.format === "json" && job.placeholders) {
28774
+ const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
28775
+ if (unset.length) Object.assign(base, { alerts: [...unset, ...base.alerts ?? []] });
28776
+ }
28327
28777
  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
28778
  return {
28329
28779
  ...base,
28330
28780
  status: finalStatus,
28331
28781
  ...blocking ? { blocking: true } : {},
28332
- label: unread ? `Not checked: ${unreadText[0]} is an expression` : report.compliance?.label || DEFAULT_LABEL[status],
28782
+ label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
28333
28783
  ...fixes[0] ? { fix: fixes[0] } : {},
28334
28784
  fixes,
28335
28785
  ...corrected ? { corrected } : {},
28336
28786
  report
28337
28787
  };
28338
28788
  }
28789
+ function countedLabel(label, r, adjust) {
28790
+ const m = /^(\d+) fix(?:es)? needed/.exec(label);
28791
+ if (!m) return label;
28792
+ const n = (adjust ? adjust(fixesOf(r, true)) : fixesOf(r, true)).length;
28793
+ if (!n || n === Number(m[1])) return label;
28794
+ return label.replace(m[0], `${n} fix${n === 1 ? "" : "es"} needed`);
28795
+ }
28339
28796
  function exitCodeOf(checks, strict) {
28340
28797
  if (checks.some((c) => c.failure === "auth")) return { status: "error", exit: EXIT.auth };
28341
28798
  if (checks.some((c) => c.failure === "api")) return { status: "error", exit: EXIT.error };
28342
28799
  if (checks.some((c) => c.status === "error")) return { status: "error", exit: EXIT.usage };
28343
28800
  if (checks.some((c) => c.status === "fail")) return { status: "fail", exit: EXIT.fail };
28344
28801
  if (checks.some((c) => c.status === "needs_review")) return { status: "needs_review", exit: strict || checks.some((c) => c.blocking) ? EXIT.needs_review : EXIT.ok };
28802
+ if (!checks.some((c) => c.status === "pass")) return { status: "skipped", exit: EXIT.needs_review };
28345
28803
  return { status: "pass", exit: EXIT.ok };
28346
28804
  }
28347
28805
  function mergeSourcePatches(checks) {
28348
28806
  const groups = /* @__PURE__ */ new Map();
28349
28807
  for (const c of checks) {
28350
- if (c.type !== "stripe_checkout" || c.corrected?.format !== "json" || !c.target.line) continue;
28808
+ if (c.type !== "stripe_checkout" || c.corrected?.format !== "json" || !c.target.line || !c.fromSource) continue;
28351
28809
  const key = `${c.target.file}:${c.target.line}`;
28352
28810
  groups.set(key, [...groups.get(key) ?? [], c]);
28353
28811
  }
@@ -28363,9 +28821,16 @@ function mergeSourcePatches(checks) {
28363
28821
  }
28364
28822
  }
28365
28823
  }
28824
+ function nothingCheckedMessage(run2) {
28825
+ const urlSkipped = run2.checks.some((c) => c.status === "skipped" && /^URL pages/.test(c.message ?? ""));
28826
+ const how = urlSkipped ? run2.mode === "offline" ? ' Page entries with a "url" are fetched only with --online and an API key (CHECKBOX_SECRET_KEY); offline, point them at the built HTML with "file" instead.' : ' Page entries with a "url" need a key that can read that site (a live key for a live site); or point them at the built HTML with "file".' : ' Add a check checkbox.json can run (a "file" page, a disclosure, Stripe Checkout params or an email).';
28827
+ return `Nothing was checked: every check was skipped or does not apply, so this run is not a pass (exit 2).${how}`;
28828
+ }
28366
28829
  async function runCi(jobs, engine, opts) {
28367
28830
  const checks = [...opts.extra ?? []];
28368
- for (const j of jobs) checks.push(await runJob(j, engine, opts.config));
28831
+ for (const j of jobs) checks.push(await runJob(j, engine, opts.config, { ...opts.notices ? { notices: opts.notices } : {} }));
28832
+ const cross = opts.notices ? noticesCrossCheck(opts.config, opts.notices, opts.noticesTarget ?? { file: opts.configPath }) : void 0;
28833
+ if (cross) checks.push(cross);
28369
28834
  mergeSourcePatches(checks);
28370
28835
  const summary = { pass: 0, fail: 0, needs_review: 0, not_applicable: 0, skipped: 0, error: 0 };
28371
28836
  for (const c of checks) summary[c.status]++;
@@ -28385,7 +28850,7 @@ async function runCi(jobs, engine, opts) {
28385
28850
  disclaimer: CI_DISCLAIMER
28386
28851
  };
28387
28852
  }
28388
- var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL;
28853
+ var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
28389
28854
  var init_runner = __esm({
28390
28855
  "src/ci/runner.ts"() {
28391
28856
  "use strict";
@@ -28422,16 +28887,205 @@ var init_runner = __esm({
28422
28887
  cancellation_confirmation: "cancellation confirmation"
28423
28888
  };
28424
28889
  DEFAULT_LABEL = { pass: "Checks pass", fail: "Fixes needed", needs_review: "Needs review", not_applicable: "Not required for this plan" };
28890
+ 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"]);
28425
28891
  }
28426
28892
  });
28427
28893
 
28428
- // src/ci/workflow.ts
28429
- var WORKFLOW_PATH, WORKFLOW_YAML;
28430
- var init_workflow = __esm({
28431
- "src/ci/workflow.ts"() {
28894
+ // src/ci/report.ts
28895
+ function icon(s2, c) {
28896
+ if (s2 === "pass") return c.green("\u2713");
28897
+ if (s2 === "fail" || s2 === "error") return c.red("\u2717");
28898
+ if (s2 === "needs_review") return c.yellow("?");
28899
+ return c.dim("\u2013");
28900
+ }
28901
+ function overallLine(run2) {
28902
+ const s2 = run2.summary;
28903
+ const parts = [];
28904
+ if (s2.error) parts.push(`${s2.error} could not run`);
28905
+ if (s2.fail) parts.push(`${s2.fail} need${s2.fail === 1 ? "s" : ""} a fix`);
28906
+ if (s2.needs_review) parts.push(`${s2.needs_review} need${s2.needs_review === 1 ? "s" : ""} review${run2.strict ? " (strict)" : ""}`);
28907
+ if (s2.pass) parts.push(`${s2.pass} pass`);
28908
+ if (s2.not_applicable) parts.push(`${s2.not_applicable} not required`);
28909
+ if (s2.skipped) parts.push(`${s2.skipped} skipped`);
28910
+ return parts.join(" \xB7 ") || "nothing checked";
28911
+ }
28912
+ function renderHuman(run2, c, opts = {}) {
28913
+ const lines = [];
28914
+ const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
28915
+ lines.push(`${c.bold("Checkbox CI")} ${run2.config} \xB7 ${run2.mode}${versions} \xB7 ${run2.checks.length} check${run2.checks.length === 1 ? "" : "s"}`);
28916
+ const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
28917
+ for (const x of run2.checks) {
28918
+ const label = x.status === "fail" || x.status === "error" ? c.red(x.label) : x.status === "needs_review" ? c.yellow(x.label) : x.status === "pass" ? c.green(x.label) : c.dim(x.label);
28919
+ lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
28920
+ if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
28921
+ if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
28922
+ if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(` fix: ${wrap(f, 110, " ")}`);
28923
+ const alerts = x.alerts ?? [];
28924
+ for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
28925
+ 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, " ")}`));
28926
+ if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
28927
+ }
28928
+ const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
28929
+ if (run2.status === "skipped") lines.push("", c.red(wrap(nothingCheckedMessage(run2), 110, "")));
28930
+ lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
28931
+ if (run2.checks.some((x) => x.corrected) && !opts.verbose) lines.push(c.dim("Corrected params / text: --verbose, --json, or the job summary in GitHub Actions."));
28932
+ lines.push(c.dim(run2.disclaimer));
28933
+ return lines;
28934
+ }
28935
+ function annotations(run2) {
28936
+ const out = [];
28937
+ for (const x of run2.checks) {
28938
+ const alerts = x.alerts ?? [];
28939
+ const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : alerts.length ? "warning" : void 0;
28940
+ if (!level) continue;
28941
+ const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
28942
+ const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
28943
+ ${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");
28944
+ out.push(`::${level} ${props.join(",")}::${escData(body)}`);
28945
+ }
28946
+ if (run2.status === "skipped") out.push(`::error file=${escProp(run2.config)},title=${escProp("Checkbox: nothing was checked")}::${escData(nothingCheckedMessage(run2))}`);
28947
+ return out;
28948
+ }
28949
+ function stepSummary(run2) {
28950
+ const lines = [];
28951
+ lines.push("## Checkbox compliance checks", "");
28952
+ const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
28953
+ lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
28954
+ if (run2.status === "skipped") lines.push(`\u274C ${nothingCheckedMessage(run2)}`, "");
28955
+ lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
28956
+ for (const x of run2.checks) {
28957
+ 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 : "";
28958
+ const fix = [fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`)].filter(Boolean).join("\n");
28959
+ lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
28960
+ }
28961
+ const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
28962
+ for (const x of detailed) {
28963
+ lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
28964
+ if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
28965
+ if (x.corrected) {
28966
+ const fence = x.corrected.body.includes("```") ? "````" : "```";
28967
+ lines.push(`${fence}${x.corrected.format === "json" ? "json" : "text"}`, x.corrected.body, fence, "");
28968
+ }
28969
+ lines.push("</details>");
28970
+ }
28971
+ lines.push("", `<sub>${run2.disclaimer} Docs: https://checkbox.site/docs/guides/ci</sub>`, "");
28972
+ return lines.join("\n");
28973
+ }
28974
+ function escapeHtml(s2) {
28975
+ return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28976
+ }
28977
+ var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
28978
+ var init_report = __esm({
28979
+ "src/ci/report.ts"() {
28432
28980
  "use strict";
28433
- WORKFLOW_PATH = ".github/workflows/checkbox.yml";
28434
- WORKFLOW_YAML = `# Checkbox: automatic-renewal law checks on every pull request (https://checkbox.site/docs/guides/ci).
28981
+ init_output();
28982
+ init_runner();
28983
+ TYPE_LABEL = {
28984
+ stripe_checkout: "Stripe Checkout",
28985
+ disclosure: "Disclosure",
28986
+ page: "Page",
28987
+ email: "Email",
28988
+ notice_schedule: "Notice schedule"
28989
+ };
28990
+ where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
28991
+ escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
28992
+ escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
28993
+ cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
28994
+ STATUS_MD = { pass: "\u2705", fail: "\u274C", needs_review: "\u26A0\uFE0F", error: "\u274C", skipped: "\u23ED\uFE0F", not_applicable: "\u2796" };
28995
+ }
28996
+ });
28997
+
28998
+ // src/commands/plan/lib.ts
28999
+ import { dirname as dirname6, resolve as resolve6 } from "node:path";
29000
+ async function resolveSite(ctx, flag, client) {
29001
+ const explicit = flag ?? ctx.env.CHECKBOX_SITE;
29002
+ if (explicit) {
29003
+ 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}"`);
29004
+ return explicit;
29005
+ }
29006
+ const projects = ctx.config.load().init?.projects ?? {};
29007
+ for (let dir = resolve6(ctx.cwd); ; dir = dirname6(dir)) {
29008
+ const site = projects[dir]?.site;
29009
+ if (site) return site;
29010
+ if (dirname6(dir) === dir) break;
29011
+ }
29012
+ const list2 = await client.sdk.sites.list({ limit: 3 });
29013
+ const sites = list2?.data ?? [];
29014
+ if (sites.length === 1) return sites[0].id;
29015
+ throw new UsageError(
29016
+ 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)."
29017
+ );
29018
+ }
29019
+ function nextLine(n) {
29020
+ const text = n.description ?? n.message ?? n.type;
29021
+ return `${text}${n.command ? `
29022
+ $ ${n.command}` : n.endpoint ? `
29023
+ ${n.endpoint}` : ""}`;
29024
+ }
29025
+ function renderPlan(p, c, opts = {}) {
29026
+ 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)}`;
29027
+ const tasks = opts.all ? p.tasks : p.tasks.filter((t) => t.status !== "done" && t.status !== "wont_fix");
29028
+ const lines = [head, p.summary, ""];
29029
+ for (const t of tasks) {
29030
+ lines.push(`${STATUS_MARK[t.status] ?? "[ ]"} ${String(t.order).padStart(2)}. ${t.title} ${c.dim(`(${t.priority}, ${t.id})`)}`);
29031
+ 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}`);
29032
+ 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"}>`)}`);
29033
+ else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
29034
+ else if (t.blocked_by.length) lines.push(c.dim(` after ${t.blocked_by.join(", ")}`));
29035
+ 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}`));
29036
+ }
29037
+ const hidden = p.tasks.length - tasks.length;
29038
+ if (hidden) lines.push(c.dim(` \u2026 ${hidden} done / declined (--all to show)`));
29039
+ lines.push("", `${c.cyan("next")} ${nextLine(p.next_action)}`);
29040
+ return lines;
29041
+ }
29042
+ function statusColor(s2, c) {
29043
+ return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
29044
+ }
29045
+ function renderVerification(v, c) {
29046
+ 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");
29047
+ const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
29048
+ const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
29049
+ if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
29050
+ if (v.scan) lines.push(c.dim(` confirming scan ${v.scan}`));
29051
+ 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}`);
29052
+ if (v.diff.length > 12) lines.push(c.dim(` \u2026 ${v.diff.length - 12} more`));
29053
+ lines.push(`${c.cyan("next")} ${nextLine(v.next_action)}`);
29054
+ return lines;
29055
+ }
29056
+ var SITE_RE, STATUS_MARK;
29057
+ var init_lib = __esm({
29058
+ "src/commands/plan/lib.ts"() {
29059
+ "use strict";
29060
+ init_args();
29061
+ SITE_RE = /^site_[0-9a-f]{32}$/;
29062
+ STATUS_MARK = { open: "[ ]", in_progress: "[~]", verifying: "[?]", done: "[x]", wont_fix: "[-]", manual: "[!]" };
29063
+ }
29064
+ });
29065
+
29066
+ // src/ci/workflow.ts
29067
+ import { execFileSync } from "node:child_process";
29068
+ import { existsSync as existsSync7, readFileSync as readFileSync9 } from "node:fs";
29069
+ import { join as join8 } from "node:path";
29070
+ function workflowYaml(needs = {}) {
29071
+ const node = !!(needs.install || needs.build);
29072
+ const steps = [" - uses: actions/checkout@v4"];
29073
+ if (node) {
29074
+ steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 20");
29075
+ if (needs.install) steps.push(` # ${needs.why?.install ?? 'checkbox.json "module" entries import your code and its dependencies.'}`, ` - run: ${needs.install}`);
29076
+ if (needs.build) steps.push(` # ${needs.why?.build ?? 'checkbox.json "pages" read built HTML, which a fresh clone does not have.'}`, ` - run: ${needs.build}`);
29077
+ } else {
29078
+ steps.push(' # Only needed when checkbox.json uses "module" entries that import your dependencies:', " # - run: npm ci");
29079
+ }
29080
+ steps.push(" - uses: checkbox-site/checkbox@v1", " # with:", " # strict: true # also fail when a check needs review");
29081
+ if (needs.urlPages) {
29082
+ steps.push(
29083
+ ' # args: --online # "url" pages are fetched by the API: needs a LIVE key (ck_live_\u2026) on an',
29084
+ ' # # activated account. Without one, list the built page as "file" instead.'
29085
+ );
29086
+ } else steps.push(" # args: --online # URL pages via the API; needs the env below");
29087
+ steps.push(" # env:", " # CHECKBOX_SECRET_KEY: ${{ secrets.CHECKBOX_SECRET_KEY }}");
29088
+ return `# Checkbox: automatic-renewal law checks on every pull request (https://checkbox.site/docs/guides/ci).
28435
29089
  # Runs the checks listed in checkbox.json with the Checkbox rules engine, offline (no key, no network), annotates the
28436
29090
  # lines that need a fix and writes a summary with the corrected params / text to the job page.
28437
29091
  name: Checkbox
@@ -28449,35 +29103,85 @@ jobs:
28449
29103
  runs-on: ubuntu-latest
28450
29104
  timeout-minutes: 10
28451
29105
  steps:
28452
- - uses: actions/checkout@v4
28453
- # Only needed when checkbox.json uses "module" entries that import your dependencies:
28454
- # - run: npm ci
28455
- - uses: checkbox-site/checkbox@v1
28456
- # with:
28457
- # strict: true # also fail when a check needs review
28458
- # args: --online # URL pages via the API; needs the env below
28459
- # env:
28460
- # CHECKBOX_SECRET_KEY: \${{ secrets.CHECKBOX_SECRET_KEY }}
29106
+ ${steps.join("\n")}
28461
29107
  `;
29108
+ }
29109
+ function installCommand(root) {
29110
+ if (existsSync7(join8(root, "pnpm-lock.yaml"))) return "corepack enable && pnpm install --frozen-lockfile";
29111
+ if (existsSync7(join8(root, "yarn.lock"))) return "corepack enable && yarn install --immutable";
29112
+ if (existsSync7(join8(root, "bun.lockb")) || existsSync7(join8(root, "bun.lock"))) return "npx --yes bun install --frozen-lockfile";
29113
+ if (existsSync7(join8(root, "package-lock.json")) || existsSync7(join8(root, "npm-shrinkwrap.json"))) return "npm ci";
29114
+ return "npm install";
29115
+ }
29116
+ function runScript(root, script) {
29117
+ if (existsSync7(join8(root, "pnpm-lock.yaml"))) return `pnpm run ${script}`;
29118
+ if (existsSync7(join8(root, "yarn.lock"))) return `yarn run ${script}`;
29119
+ return `npm run ${script}`;
29120
+ }
29121
+ function notInClone(root, files) {
29122
+ if (!files.length) return [];
29123
+ let tracked = null;
29124
+ try {
29125
+ const out = execFileSync("git", ["ls-files", "-z", "--", ...files], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
29126
+ tracked = new Set(out.split("\0").filter(Boolean));
29127
+ } catch {
29128
+ tracked = null;
29129
+ }
29130
+ return files.filter((f) => tracked ? !tracked.has(f.replace(/^\.\//, "")) : !existsSync7(join8(root, f)));
29131
+ }
29132
+ function workflowNeeds(root, config) {
29133
+ const checks = config.checks ?? {};
29134
+ const list2 = (k) => Array.isArray(checks[k]) ? checks[k] : [];
29135
+ const modules = [...list2("stripe_checkout"), ...list2("emails"), ...list2("disclosures")].some((e) => typeof e.module === "string" && !!e.module);
29136
+ const pageFiles = list2("pages").flatMap((e) => typeof e.file === "string" && !e.file.startsWith("TODO") ? [e.file] : []);
29137
+ const urlPages = list2("pages").some((e) => typeof e.url === "string");
29138
+ let pkg = null;
29139
+ try {
29140
+ pkg = JSON.parse(readFileSync9(join8(root, "package.json"), "utf8"));
29141
+ } catch {
29142
+ pkg = null;
29143
+ }
29144
+ const built = notInClone(root, pageFiles);
29145
+ const buildScript = pkg?.scripts?.build ? "build" : void 0;
29146
+ const needs = { why: {} };
29147
+ if (built.length && buildScript) {
29148
+ needs.build = runScript(root, buildScript);
29149
+ needs.why.build = `checkbox.json "pages" read ${built.slice(0, 2).join(", ")}${built.length > 2 ? ", \u2026" : ""}, built here (not in git).`;
29150
+ }
29151
+ if (pkg && (modules || needs.build)) {
29152
+ needs.install = installCommand(root);
29153
+ needs.why.install = modules ? 'checkbox.json "module" entries import your code and its dependencies.' : "The build needs your dependencies.";
29154
+ }
29155
+ if (urlPages) needs.urlPages = true;
29156
+ return needs;
29157
+ }
29158
+ var WORKFLOW_PATH, WORKFLOW_YAML;
29159
+ var init_workflow = __esm({
29160
+ "src/ci/workflow.ts"() {
29161
+ "use strict";
29162
+ WORKFLOW_PATH = ".github/workflows/checkbox.yml";
29163
+ WORKFLOW_YAML = workflowYaml();
28462
29164
  }
28463
29165
  });
28464
29166
 
28465
29167
  // src/commands/ci/index.ts
28466
29168
  var ci_exports = {};
28467
29169
  __export(ci_exports, {
29170
+ attachSiteForCoupons: () => attachSiteForCoupons,
29171
+ couponIdsIn: () => couponIdsIn,
28468
29172
  default: () => ci_default,
28469
29173
  touchesConfig: () => touchesConfig,
28470
29174
  undiffableInputs: () => undiffableInputs
28471
29175
  });
28472
- import { execFileSync } from "node:child_process";
28473
- import { appendFileSync, existsSync as existsSync6, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
29176
+ import { execFileSync as execFileSync2 } from "node:child_process";
29177
+ import { appendFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
28474
29178
  import { createInterface } from "node:readline";
28475
- import { dirname as dirname4, join as join8, relative as relative5, resolve as resolve5 } from "node:path";
29179
+ import { dirname as dirname7, join as join9, relative as relative5, resolve as resolve7 } from "node:path";
28476
29180
  function gitChanged(cwd, base) {
28477
29181
  try {
28478
- const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
28479
- 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)) };
29182
+ const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
29183
+ const out = execFileSync2("git", ["diff", "--name-only", `${base}...HEAD`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
29184
+ return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve7(root, f)) };
28481
29185
  } catch (err) {
28482
29186
  const msg2 = String(err.stderr || err.message).trim().split("\n")[0];
28483
29187
  return `git diff ${base}...HEAD failed (${msg2}); fetch the base branch (actions/checkout with fetch-depth: 0)`;
@@ -28487,13 +29191,13 @@ function undiffableInputs(loaded, jobs, gitRoot) {
28487
29191
  const need = [];
28488
29192
  const out = [];
28489
29193
  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}` });
29194
+ if (e.source) need.push({ path: resolve7(loaded.dir, e.source), label: `pages "${e.name}" source ${e.source}` });
28491
29195
  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}` });
29196
+ else if (e.file) need.push({ path: resolve7(loaded.dir, e.file), label: `pages "${e.name}" file ${e.file}` });
28493
29197
  }
28494
29198
  for (const j of jobs) {
28495
29199
  if (j.type === "page") continue;
28496
- for (const f of j.inputs) need.push({ path: resolve5(loaded.dir, f), label: `"${j.name}" reads ${f}` });
29200
+ for (const f of j.inputs) need.push({ path: resolve7(loaded.dir, f), label: `"${j.name}" reads ${f}` });
28497
29201
  }
28498
29202
  if (!need.length) return out;
28499
29203
  const real = (p) => {
@@ -28508,8 +29212,8 @@ function undiffableInputs(loaded, jobs, gitRoot) {
28508
29212
  let tracked;
28509
29213
  try {
28510
29214
  const rels = [...new Set(need.map((n) => relative5(gitRoot, n.path)))];
28511
- 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)));
29215
+ const listed = execFileSync2("git", ["ls-files", "-z", "--", ...rels], { cwd: gitRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
29216
+ tracked = new Set(listed.split("\0").filter(Boolean).map((f) => resolve7(gitRoot, f)));
28513
29217
  } catch (err) {
28514
29218
  return `git ls-files failed (${String(err.stderr || err.message).trim().split("\n")[0]})`;
28515
29219
  }
@@ -28521,7 +29225,7 @@ function touchesConfig(loaded, jobs, changed) {
28521
29225
  if (set.has(loaded.path)) return true;
28522
29226
  const codeChanged = changed.some((f) => /\.(?:[cm]?[jt]sx?)$/.test(f));
28523
29227
  for (const j of jobs) {
28524
- for (const f of j.inputs) if (set.has(resolve5(loaded.dir, f))) return true;
29228
+ for (const f of j.inputs) if (set.has(resolve7(loaded.dir, f))) return true;
28525
29229
  }
28526
29230
  const hasModules = [...loaded.config.checks.stripe_checkout ?? [], ...loaded.config.checks.emails ?? []].some((e) => "module" in e && e.module);
28527
29231
  return hasModules && codeChanged;
@@ -28546,15 +29250,26 @@ async function runInit(ctx, argv) {
28546
29250
  const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
28547
29251
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
28548
29252
  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]);
28550
- const workflowPath = join8(ctx.cwd, WORKFLOW_PATH);
29253
+ const configPath = explicit ? resolve7(ctx.cwd, explicit) : CONFIG_FILES.map((f) => join9(ctx.cwd, f)).find((f) => existsSync8(f)) ?? join9(ctx.cwd, CONFIG_FILES[0]);
29254
+ const workflowPath = join9(ctx.cwd, WORKFLOW_PATH);
28551
29255
  const force = bool(p.flags, "force") === true;
28552
29256
  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";
29257
+ const projects = ctx.config.load().init?.projects ?? {};
29258
+ let facts = {};
29259
+ for (let dir = resolve7(ctx.cwd); ; dir = dirname7(dir)) {
29260
+ if (projects[dir]?.facts) {
29261
+ facts = projects[dir].facts;
29262
+ break;
29263
+ }
29264
+ if (dirname7(dir) === dir) break;
29265
+ }
29266
+ const s2 = scaffoldConfig(ctx.cwd, ctx.env, facts);
29267
+ const actionFor = (f) => existsSync8(f) ? force ? "overwrite" : "keep" : "create";
28555
29268
  const files = [{ path: configPath, content: `${JSON.stringify(s2.config, null, 2)}
28556
29269
  `, action: actionFor(configPath) }];
28557
- if (withWorkflow) files.push({ path: workflowPath, content: WORKFLOW_YAML, action: actionFor(workflowPath) });
29270
+ const needs = workflowNeeds(ctx.cwd, s2.config);
29271
+ const workflowSteps = [...needs.install ? [needs.install] : [], ...needs.build ? [needs.build] : []];
29272
+ if (withWorkflow) files.push({ path: workflowPath, content: workflowYaml(needs), action: actionFor(workflowPath) });
28558
29273
  const toWrite = files.filter((f) => f.action !== "keep");
28559
29274
  const dry = bool(p.flags, "dry-run") === true;
28560
29275
  let write = !dry && toWrite.length > 0;
@@ -28566,7 +29281,7 @@ async function runInit(ctx, argv) {
28566
29281
  }
28567
29282
  if (write) {
28568
29283
  for (const f of toWrite) {
28569
- mkdirSync2(dirname4(f.path), { recursive: true });
29284
+ mkdirSync2(dirname7(f.path), { recursive: true });
28570
29285
  writeFileSync2(f.path, f.content);
28571
29286
  }
28572
29287
  }
@@ -28577,6 +29292,7 @@ async function runInit(ctx, argv) {
28577
29292
  kept: files.filter((f) => f.action === "keep").map((f) => relName(f.path)),
28578
29293
  ...write ? {} : { would_write: toWrite.map((f) => relName(f.path)) },
28579
29294
  found: s2.found,
29295
+ ...withWorkflow ? { workflow_steps: workflowSteps } : {},
28580
29296
  todos: s2.todos,
28581
29297
  config: s2.config,
28582
29298
  next_action: s2.todos.length ? { type: "edit_config", message: `Fill in the ${s2.todos.length} TODO item(s) in ${relName(configPath)} (checkbox ci refuses TODO values), then run \`checkbox ci\` and fix what it reports.` } : { type: "run_ci", message: "Run `checkbox ci` locally, fix what it reports, then commit checkbox.json and the workflow." }
@@ -28586,6 +29302,8 @@ async function runInit(ctx, argv) {
28586
29302
  for (const f of files) lines.push(`${f.action === "keep" ? ctx.out.c.dim("kept ") : write ? ctx.out.c.green(f.action === "create" ? "created" : "wrote ") : ctx.out.c.yellow("would write")} ${relName(f.path)}${f.action === "keep" ? ctx.out.c.dim(" (exists; --force overwrites)") : ""}`);
28587
29303
  if (!write && toWrite.length && !dry) lines.push(ctx.out.c.dim("Nothing written: add --yes to write without asking."));
28588
29304
  if (s2.found.length) lines.push("", "Found:", ...s2.found.map((f) => ` - ${f}`));
29305
+ if (withWorkflow && workflowSteps.length) lines.push("", `The workflow runs ${workflowSteps.map((c) => `\`${c}\``).join(" and ")} before the checks, so they work on a fresh clone.`);
29306
+ if (needs.urlPages) lines.push("", 'Note: "url" pages are fetched by the API (checkbox ci --online), which needs a LIVE key (ck_live_\u2026) on an activated account. Without one, list the built page as "file".');
28589
29307
  if (s2.todos.length) lines.push("", "To do:", ...s2.todos.map((t) => ` - ${t}`));
28590
29308
  lines.push("", `${ctx.out.c.cyan("Next:")} ${result.next_action.message}`);
28591
29309
  if (!write) lines.push("", ctx.out.c.dim(`${relName(configPath)}:`), ...JSON.stringify(s2.config, null, 2).split("\n").map((l) => ctx.out.c.dim(` ${l}`)));
@@ -28593,7 +29311,37 @@ async function runInit(ctx, argv) {
28593
29311
  });
28594
29312
  return EXIT.ok;
28595
29313
  }
28596
- var HELP2, ci_default;
29314
+ async function readNotices(ctx, client, siteFlag) {
29315
+ let site;
29316
+ try {
29317
+ site = await resolveSite(ctx, siteFlag, client);
29318
+ } catch (err) {
29319
+ 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).` };
29320
+ }
29321
+ try {
29322
+ const s2 = await client.sdk.noticeSettings.retrieve(site);
29323
+ return { site, mode: s2.mode, enabled: (s2.enabled_kinds ?? []).map((k) => NOTICE_KIND_TO_EMAIL[k] ?? k), real_sends: s2.real_sends };
29324
+ } catch (err) {
29325
+ return { error: `Could not read the Notice settings of ${site} (${err.message}).` };
29326
+ }
29327
+ }
29328
+ async function attachSiteForCoupons(ctx, client, jobs, siteFlag) {
29329
+ const withCoupon = jobs.filter((j) => j.type === "stripe_checkout" && j.body && couponIdsIn(j.body.params).length);
29330
+ if (!withCoupon.length) return;
29331
+ let site;
29332
+ try {
29333
+ site = await resolveSite(ctx, siteFlag, client);
29334
+ } catch (err) {
29335
+ for (const j of withCoupon) j.warnings.push(`The coupon was not read from Stripe: ${err.message} --online reads it through the site's connected Stripe key once it knows the site (--site or CHECKBOX_SITE).`);
29336
+ return;
29337
+ }
29338
+ for (const j of withCoupon) j.body = { ...j.body, site };
29339
+ }
29340
+ function couponIdsIn(params) {
29341
+ const discounts = params?.discounts;
29342
+ return (Array.isArray(discounts) ? discounts : []).flatMap((d) => d && typeof d === "object" && typeof d.coupon === "string" ? [d.coupon] : []);
29343
+ }
29344
+ var HELP2, NOTICE_KIND_TO_EMAIL, ci_default;
28597
29345
  var init_ci = __esm({
28598
29346
  "src/commands/ci/index.ts"() {
28599
29347
  "use strict";
@@ -28603,6 +29351,8 @@ var init_ci = __esm({
28603
29351
  init_inputs();
28604
29352
  init_report();
28605
29353
  init_runner();
29354
+ init_run();
29355
+ init_lib();
28606
29356
  init_scaffold();
28607
29357
  init_workflow();
28608
29358
  init_types2();
@@ -28611,7 +29361,10 @@ var init_ci = __esm({
28611
29361
  "",
28612
29362
  "Flags:",
28613
29363
  " --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",
29364
+ " --online Use the API (CHECKBOX_SECRET_KEY / CHECKBOX_API_KEY): URL pages (these need a live-mode",
29365
+ ' key on an activated account; with a test key list the built page as "file") and site-aware checks;',
29366
+ ` with "notices" in checkbox.json, also checks that the site's Checkbox Notices really send them`,
29367
+ " --site <site_\u2026> The site for --online (default: $CHECKBOX_SITE, the site `checkbox init` saved, or the only one)",
28615
29368
  ' --strict "Needs review" fails too (exit 2)',
28616
29369
  " --changed-only Skip when no file the config reads changed (git diff against --base, or",
28617
29370
  " origin/$GITHUB_BASE_REF in a pull request). Never skips while a page or file it reads",
@@ -28625,15 +29378,18 @@ var init_ci = __esm({
28625
29378
  "",
28626
29379
  "Exit: 0 pass (or needs review without --strict) \xB7 1 a check needs a fix \xB7 2 needs review with --strict, or a",
28627
29380
  " page / disclosure with no renewal terms to judge, a checkout whose coupon / trial is not verified, or a",
28628
- ' checkout entry with "strict": true that needs review \xB7',
29381
+ ' checkout entry with "strict": true that needs review, or nothing was checked (every check skipped or',
29382
+ " not applicable, e.g. url pages without --online) \xB7",
28629
29383
  " 3 config / usage error \xB7 4 auth (--online) \xB7 5 API or network error",
28630
29384
  "",
28631
29385
  "checkbox ci init [--yes] [--force] [--dry-run] [--no-workflow]",
28632
29386
  ' Writes checkbox.json from the project (Stripe Checkout calls, plans, email templates, pages; "TODO" where it',
28633
- " cannot tell) and .github/workflows/checkbox.yml. Asks first on a terminal; --yes writes without asking (agents).",
29387
+ ' cannot tell) and .github/workflows/checkbox.yml (with the install and build steps a fresh clone needs for "module"',
29388
+ " entries and built pages). Asks first on a terminal; --yes writes without asking (agents).",
28634
29389
  "",
28635
29390
  "Docs: https://checkbox.site/docs/guides/ci"
28636
29391
  ].join("\n");
29392
+ NOTICE_KIND_TO_EMAIL = { trial_ending: "trial_reminder" };
28637
29393
  ci_default = defineCommand({
28638
29394
  name: "ci",
28639
29395
  summary: "Run the checks in checkbox.json (CI: annotations + job summary); `ci init` scaffolds the config and workflow",
@@ -28641,7 +29397,7 @@ var init_ci = __esm({
28641
29397
  help: HELP2,
28642
29398
  async run(ctx) {
28643
29399
  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"] });
29400
+ const p = parseArgs(ctx.argv, { boolean: ["online", "offline", "strict", "verbose", "changed-only"], string: ["config", "base", "site"] });
28645
29401
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}" (did you mean \`checkbox ci init\`?)`);
28646
29402
  const gha = ctx.env.GITHUB_ACTIONS === "true";
28647
29403
  const strict = bool(p.flags, "strict") === true;
@@ -28672,7 +29428,7 @@ var init_ci = __esm({
28672
29428
  const workspace = ctx.env.GITHUB_WORKSPACE ?? ctx.cwd;
28673
29429
  const extra = (loaded.config.coverage === false ? [] : coverageResults(coverageOf(loaded, loaded.dir))).map((r) => ({
28674
29430
  ...r,
28675
- target: { ...r.target, file: relative5(workspace, join8(loaded.dir, r.target.file)).split("\\").join("/") || r.target.file }
29431
+ target: { ...r.target, file: relative5(workspace, join9(loaded.dir, r.target.file)).split("\\").join("/") || r.target.file }
28676
29432
  }));
28677
29433
  if (bool(p.flags, "changed-only")) {
28678
29434
  const base = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
@@ -28698,8 +29454,22 @@ ${message}
28698
29454
  }
28699
29455
  }
28700
29456
  }
28701
- const engine = online ? onlineEngine(ctx.client()) : offlineEngine();
28702
- const run2 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra });
29457
+ let client;
29458
+ if (online) {
29459
+ try {
29460
+ client = ctx.client();
29461
+ } catch (err) {
29462
+ if (!(err instanceof NotLoggedInError)) throw err;
29463
+ 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).");
29464
+ 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." } });
29465
+ return EXIT.auth;
29466
+ }
29467
+ }
29468
+ if (client) await attachSiteForCoupons(ctx, client, jobs, str(p.flags, "site"));
29469
+ const engine = client ? onlineEngine(client) : offlineEngine();
29470
+ const notices = client && declaredByCheckbox(loaded.config).length ? await readNotices(ctx, client, str(p.flags, "site")) : void 0;
29471
+ const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
29472
+ 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
29473
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
28704
29474
  if (gha) {
28705
29475
  for (const a of annotations(run2)) emit(a);
@@ -28931,8 +29701,8 @@ var init_patch = __esm({
28931
29701
  });
28932
29702
 
28933
29703
  // 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";
29704
+ import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync3 } from "node:fs";
29705
+ import { relative as relative6, resolve as resolve8 } from "node:path";
28936
29706
  function fixForTask(task, cwd, file) {
28937
29707
  const base = {
28938
29708
  object: "task_fix",
@@ -28944,12 +29714,12 @@ function fixForTask(task, cwd, file) {
28944
29714
  verify: task.verify
28945
29715
  };
28946
29716
  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);
29717
+ const candidate = file ?? (task.where.file_hint && existsSync9(resolve8(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
28948
29718
  if (task.change.type === "patch" && task.change.edits.length && candidate) {
28949
- const path = resolve6(cwd, candidate);
29719
+ const path = resolve8(cwd, candidate);
28950
29720
  let src;
28951
29721
  try {
28952
- src = readFileSync9(path, "utf8");
29722
+ src = readFileSync10(path, "utf8");
28953
29723
  } catch (err) {
28954
29724
  throw new UsageError(`Cannot read ${candidate}: ${err.code ?? err.message}`);
28955
29725
  }
@@ -29009,7 +29779,7 @@ var init_fix = __esm({
29009
29779
  const task = await ctx.client().sdk.tasks.retrieve(id);
29010
29780
  const result = fixForTask(task, ctx.cwd, str(flags, "file"));
29011
29781
  const out = str(flags, "out");
29012
- if (out && result.diff) writeFileSync3(resolve6(ctx.cwd, out), result.diff);
29782
+ if (out && result.diff) writeFileSync3(resolve8(ctx.cwd, out), result.diff);
29013
29783
  ctx.out.result({ ...result, task_object: task }, () => {
29014
29784
  const c = ctx.out.c;
29015
29785
  const lines = [`${c.bold(task.title)} ${c.dim(`(${task.priority}, ${task.id})`)}`, c.dim(`${task.requirement.citation}`), task.why, ""];
@@ -29111,12 +29881,12 @@ var init_device = __esm({
29111
29881
  });
29112
29882
 
29113
29883
  // src/commands/init/patch.ts
29114
- 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";
29884
+ import { execFileSync as execFileSync3 } from "node:child_process";
29885
+ import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync4 } from "node:fs";
29886
+ import { dirname as dirname8, join as join10 } from "node:path";
29117
29887
  function read(root, rel2) {
29118
29888
  try {
29119
- return readFileSync10(join9(root, rel2), "utf8");
29889
+ return readFileSync11(join10(root, rel2), "utf8");
29120
29890
  } catch {
29121
29891
  return null;
29122
29892
  }
@@ -29294,7 +30064,7 @@ function mask(v) {
29294
30064
  }
29295
30065
  function isGitTracked(root, file) {
29296
30066
  try {
29297
- execFileSync2("git", ["ls-files", "--error-unmatch", "--", file], { cwd: root, stdio: "ignore", timeout: 5e3 });
30067
+ execFileSync3("git", ["ls-files", "--error-unmatch", "--", file], { cwd: root, stdio: "ignore", timeout: 5e3 });
29298
30068
  return true;
29299
30069
  } catch {
29300
30070
  return false;
@@ -29311,7 +30081,7 @@ function planGitignore(d) {
29311
30081
  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
30082
  if (covered) return null;
29313
30083
  }
29314
- if (existing === null && !existsSync8(join9(d.root, ".git"))) return null;
30084
+ if (existing === null && !existsSync10(join10(d.root, ".git"))) return null;
29315
30085
  const text = `${existing ?? ""}${existing && !existing.endsWith("\n") ? "\n" : ""}# Checkbox keys (checkbox init)
29316
30086
  ${env2}
29317
30087
  `;
@@ -29327,8 +30097,8 @@ function planWellKnown(d, token) {
29327
30097
  }
29328
30098
  function applyChange(root, c) {
29329
30099
  if (c.action === "unchanged" || c.contents === void 0) return;
29330
- const abs = join9(root, c.file);
29331
- mkdirSync3(dirname5(abs), { recursive: true });
30100
+ const abs = join10(root, c.file);
30101
+ mkdirSync3(dirname8(abs), { recursive: true });
29332
30102
  writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
29333
30103
  }
29334
30104
  var MARK_START, MARK_END, NEXT_DOCUMENT, NUXT_PLUGIN;
@@ -29375,8 +30145,8 @@ __export(init_exports, {
29375
30145
  parsePlanFlag: () => parsePlanFlag,
29376
30146
  syncCheckoutConfigs: () => syncCheckoutConfigs
29377
30147
  });
29378
- import { existsSync as existsSync9 } from "node:fs";
29379
- import { join as join10 } from "node:path";
30148
+ import { existsSync as existsSync11 } from "node:fs";
30149
+ import { join as join11 } from "node:path";
29380
30150
  import { createInterface as createInterface2 } from "node:readline";
29381
30151
  function loadState(ctx, root) {
29382
30152
  const cfg = ctx.config.load().init ?? {};
@@ -29396,7 +30166,7 @@ function saveState(ctx, root, patch) {
29396
30166
  async function ask2(question) {
29397
30167
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
29398
30168
  try {
29399
- return await new Promise((resolve9) => rl.question(question, resolve9));
30169
+ return await new Promise((resolve10) => rl.question(question, resolve10));
29400
30170
  } finally {
29401
30171
  rl.close();
29402
30172
  }
@@ -29474,7 +30244,7 @@ async function syncCheckoutConfigs(client, site, sessions, root) {
29474
30244
  const from = configFileOf(c.name);
29475
30245
  if (!from) continue;
29476
30246
  const sameFile = perFile.has(from.file);
29477
- const gone = !sameFile && !existsSync9(join10(root, from.file));
30247
+ const gone = !sameFile && !existsSync11(join11(root, from.file));
29478
30248
  const current = sameFile && from.call !== void 0 && from.call <= perFile.get(from.file) && c.name === configNameOf(from.file, from.call);
29479
30249
  if (!sameFile && !gone || current) continue;
29480
30250
  if (await client.sdk.stripeCheckoutConfigs.del(c.id).then(() => true, () => false)) {
@@ -29514,6 +30284,7 @@ function detectionSummary(d, siteUrl) {
29514
30284
  pay_buttons: d.payButtons.map((c) => ({ file: c.file, line: c.line })),
29515
30285
  checkout_sessions: d.checkoutSessions.map((s2) => ({ file: s2.file, line: s2.line, readable: !!s2.params, unresolved: s2.unresolved })),
29516
30286
  stripe_test_key: d.stripeTestKey ? { hint: d.stripeTestKey.hint, source: d.stripeTestKey.source, restricted: d.stripeTestKey.restricted } : null,
30287
+ ...d.stripeKeyRefused ? { stripe_key_refused: { ...d.stripeKeyRefused, message: stripeKeyRefusedMessage(d.stripeKeyRefused) } } : {},
29517
30288
  merchant_name: d.brand ?? null,
29518
30289
  plans: d.plans
29519
30290
  };
@@ -29608,7 +30379,7 @@ var init_init = __esm({
29608
30379
  " 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it)",
29609
30380
  " 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments)",
29610
30381
  " 5. registers the site, Terms / refund / cancellation policy URLs (fetched and checked by Checkbox)",
29611
- " 6. with your permission, uses the project's Stripe TEST key for a synthetic test subscription",
30382
+ " 6. with your permission, uses the project's Stripe restricted TEST key (rk_test_\u2026; sk_test_ and live keys are refused) for a synthetic test subscription",
29612
30383
  " 7. runs the first checks and prints the next step (`checkbox plan --json`); the human gets the summary email",
29613
30384
  " when the setup is finished (every check passes, or `checkbox init --complete`)",
29614
30385
  "",
@@ -29804,7 +30575,20 @@ var init_init = __esm({
29804
30575
  }
29805
30576
  const policyFromPaths = (kind) => siteUrl && d.policyPaths[kind] ? `${siteUrl}${d.policyPaths[kind]}` : void 0;
29806
30577
  const agent = detectAgent(ctx.env, agentName);
29807
- const plans = flagPlans ?? (d.plans.length ? d.plans.map(({ source: _source, ...p }) => p) : void 0);
30578
+ let plans = flagPlans ?? (d.plans.length ? d.plans.map(({ source: _source, ...p }) => p) : void 0);
30579
+ if (plans?.length && !planFlag) {
30580
+ let current = null;
30581
+ try {
30582
+ current = await client.sdk.setupSessions.retrieve(sessionId);
30583
+ } catch {
30584
+ current = null;
30585
+ }
30586
+ const saved = Array.isArray(current?.business?.plans) ? current.business.plans : [];
30587
+ if (saved.length) {
30588
+ plans = void 0;
30589
+ out.info(`Kept the ${saved.length} plan${saved.length === 1 ? "" : "s"} already saved for this site (pass --plan to replace ${saved.length === 1 ? "it" : "them"}).`);
30590
+ }
30591
+ }
29808
30592
  const brand = merchantName ?? d.brand?.name;
29809
30593
  const update = {
29810
30594
  ...siteUrl ? { site_url: siteUrl } : {},
@@ -29870,13 +30654,16 @@ var init_init = __esm({
29870
30654
  if (consent && d.stripeTestKey && !session.stripe.test_credential_stored) {
29871
30655
  try {
29872
30656
  await client.sdk.testCredentials.create(site, { type: "stripe_test", secret_key: d.stripeTestKey.key });
29873
- stripe.test_credential = { stored: true, key: d.stripeTestKey.hint, restricted: d.stripeTestKey.restricted };
29874
- if (!d.stripeTestKey.restricted) warnings.push(`Stored your full Stripe TEST key (${d.stripeTestKey.hint}) because no restricted key was found. Recommended: create a restricted test key (${session.stripe.restricted_key.create_url}) with ${session.stripe.restricted_key.permissions.map((p) => `${p.resource} ${p.access}`).join(", ")}, put it in STRIPE_RESTRICTED_KEY and run \`checkbox init\` again.`);
30657
+ stripe.test_credential = { stored: true, key: d.stripeTestKey.hint, restricted: true };
29875
30658
  } catch (err) {
29876
30659
  stripe.test_credential = { stored: false, error: err instanceof ApiError ? `${err.code ?? ""} ${err.message}`.trim() : String(err) };
29877
30660
  warnings.push(`Stripe test key not stored: ${stripe.test_credential.error}`);
29878
30661
  }
29879
30662
  }
30663
+ if (!d.stripeTestKey && d.stripeKeyRefused) {
30664
+ stripe.test_credential = { stored: false, refused: d.stripeKeyRefused.kind };
30665
+ warnings.push(stripeKeyRefusedMessage(d.stripeKeyRefused));
30666
+ }
29880
30667
  const configs = await syncCheckoutConfigs(client, site, d.checkoutSessions, root);
29881
30668
  if (configs.length) stripe.checkout_configs = configs;
29882
30669
  const registered = configs.find((c) => c.id);
@@ -29979,10 +30766,11 @@ var init_whoami = __esm({
29979
30766
  object: "whoami",
29980
30767
  key: creds ? maskKey(creds.apiKey) : null,
29981
30768
  key_source: creds?.source ?? null,
30769
+ ...creds?.file ? { key_file: creds.file } : {},
29982
30770
  api_base: client.apiBase,
29983
30771
  account: account.raw
29984
30772
  },
29985
- () => [describeAccount(account), ctx.out.c.dim(`key ${creds ? maskKey(creds.apiKey) : "?"} from ${creds?.source ?? "?"} \xB7 ${client.apiBase}`)]
30773
+ () => [describeAccount(account), ctx.out.c.dim(`key ${creds ? maskKey(creds.apiKey) : "?"} from ${creds ? describeKeySource(creds, ctx.env) : "?"} \xB7 ${client.apiBase}`)]
29986
30774
  );
29987
30775
  return EXIT.ok;
29988
30776
  }
@@ -29999,7 +30787,7 @@ import { createInterface as createInterface3 } from "node:readline";
29999
30787
  async function prompt(question) {
30000
30788
  const rl = createInterface3({ input: process.stdin, output: process.stderr, terminal: true });
30001
30789
  try {
30002
- return await new Promise((resolve9) => rl.question(question, resolve9));
30790
+ return await new Promise((resolve10) => rl.question(question, resolve10));
30003
30791
  } finally {
30004
30792
  rl.close();
30005
30793
  }
@@ -30093,74 +30881,6 @@ var init_logout = __esm({
30093
30881
  }
30094
30882
  });
30095
30883
 
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
30884
  // src/commands/plan/index.ts
30165
30885
  var plan_exports = {};
30166
30886
  __export(plan_exports, {
@@ -30201,11 +30921,11 @@ var init_plan = __esm({
30201
30921
  });
30202
30922
 
30203
30923
  // src/commands/plan/verify.ts
30204
- import { readFileSync as readFileSync11 } from "node:fs";
30205
- import { resolve as resolve8 } from "node:path";
30924
+ import { readFileSync as readFileSync12 } from "node:fs";
30925
+ import { resolve as resolve9 } from "node:path";
30206
30926
  function readJson2(ctx, file, what) {
30207
30927
  try {
30208
- const v = JSON.parse(readFileSync11(resolve8(ctx.cwd, file), "utf8"));
30928
+ const v = JSON.parse(readFileSync12(resolve9(ctx.cwd, file), "utf8"));
30209
30929
  if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
30210
30930
  return v;
30211
30931
  } catch (err) {
@@ -30251,7 +30971,7 @@ var init_verify2 = __esm({
30251
30971
  const htmlFile = str(flags, "html-file");
30252
30972
  if (htmlFile) {
30253
30973
  try {
30254
- body.html = readFileSync11(resolve8(ctx.cwd, htmlFile), "utf8");
30974
+ body.html = readFileSync12(resolve9(ctx.cwd, htmlFile), "utf8");
30255
30975
  } catch (err) {
30256
30976
  throw new UsageError(`Cannot read ${htmlFile}: ${err.code ?? err.message}`);
30257
30977
  }