@checkbox-site/cli 0.4.4 → 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 +618 -252
  3. package/package.json +4 -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
 
@@ -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
  */
@@ -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.2";
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
  }
@@ -13793,7 +13862,7 @@ function remediationFor(checkId, platform) {
13793
13862
  if (guide?.snippet) rem.snippet = { ...guide.snippet };
13794
13863
  return rem;
13795
13864
  }
13796
- 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;
13797
13866
  var init_remediation = __esm({
13798
13867
  "../rules/src/remediation.ts"() {
13799
13868
  "use strict";
@@ -14109,13 +14178,32 @@ and click \u201CCancel subscription\u201D. You can cancel anytime online.</p>`;
14109
14178
  ],
14110
14179
  snippet: { language: "html", code: CUSTOM_DISCLOSURE_HTML }
14111
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>`;
14112
14200
  PADDLE_CONSENT = {
14113
14201
  steps: [
14114
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.",
14115
- "Save when it was ticked and the terms that were shown, so you can prove consent later.",
14116
- "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."
14117
14205
  ],
14118
- snippet: { language: "html", code: CUSTOM_CONSENT_HTML }
14206
+ snippet: { language: "html", code: PADDLE_CONSENT_HTML }
14119
14207
  };
14120
14208
  PADDLE_ACK = {
14121
14209
  steps: [
@@ -16291,15 +16379,34 @@ function offlineOnlyCancelIn(text, cancelMethods) {
16291
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));
16292
16380
  return online ? null : offline;
16293
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
+ }
16294
16399
  function noticeContent(e, opts = {}) {
16295
16400
  const body = emailBody(e);
16296
16401
  const a = analyzeTerms(body);
16297
16402
  const quotes = {};
16298
16403
  const wrong = {};
16299
16404
  const plans = opts.plans ?? [];
16405
+ const inForce = opts.events ? inForceAmount(e, opts.events, plans[0]?.currency) : void 0;
16300
16406
  const expected = [
16301
16407
  ...plans.map((p) => p.amount / 10 ** currencyDigitsOf(p.currency)),
16302
- ...opts.extraAmounts ?? []
16408
+ ...opts.extraAmounts ?? [],
16409
+ ...inForce !== void 0 ? [inForce] : []
16303
16410
  ];
16304
16411
  const amount = a.recurringPrices[0]?.money ?? positiveAmounts(a)[0];
16305
16412
  const stated = positiveAmounts(a).filter((m) => m.amount !== null);
@@ -16364,7 +16471,7 @@ function contentFixFromText(checkId, text) {
16364
16471
  const m = new RegExp(`^the ${noun} \\(.*?\\) .*? does not state (.+?)(?: correctly \\(| \u2014 )`, "i").exec(text.trim());
16365
16472
  return m ? `Add ${m[1]} to the ${noun}; Checkbox's template has every required line.` : void 0;
16366
16473
  }
16367
- 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;
16368
16475
  var init_notice_content = __esm({
16369
16476
  "../rules/src/checks/notice-content.ts"() {
16370
16477
  "use strict";
@@ -16390,6 +16497,7 @@ var init_notice_content = __esm({
16390
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}/;
16391
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;
16392
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"]);
16393
16501
  NOTICE_NOUN = {
16394
16502
  "reminder.trial_ending": "trial-ending reminder",
16395
16503
  "reminder.annual_renewal": "renewal reminder",
@@ -16459,6 +16567,64 @@ var init_email_plan = __esm({
16459
16567
  }
16460
16568
  });
16461
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
+
16462
16628
  // ../rules/src/checks/confirmation.ts
16463
16629
  function isPostSignupEmail(e) {
16464
16630
  if (e.kind !== "other" || !e.scanId || !e.relatedSignupAt) return false;
@@ -16471,10 +16637,12 @@ function findAcknowledgments(emails) {
16471
16637
  if (cached) return cached;
16472
16638
  const acks = [];
16473
16639
  const receipts = [];
16474
- 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 !== ""));
16475
16642
  for (const e of emails) {
16476
16643
  if (e.kind === "acknowledgment") acks.push(e);
16477
16644
  else if (acknowledged.has(recipientOf(e))) continue;
16645
+ else if (explicit.length && e.kind === "receipt" && (forwardedInfo(e).forwarded || isMerchantOfRecordEmail(e) || sentStraightToCheckbox(e))) continue;
16478
16646
  else if (e.kind === "receipt" || isPostSignupEmail(e)) {
16479
16647
  (looksLikeSubscriptionTerms(analyzeTerms(emailBody(e))) ? acks : receipts).push(e);
16480
16648
  }
@@ -16484,6 +16652,8 @@ function findAcknowledgments(emails) {
16484
16652
  return set;
16485
16653
  }
16486
16654
  function recipientOf(e) {
16655
+ const original = forwardedInfo(e).originalTo;
16656
+ if (original) return original;
16487
16657
  const m = /[^\s<>,"]+@[^\s<>,"]+/.exec(e.to ?? "");
16488
16658
  return m ? m[0].toLowerCase() : "";
16489
16659
  }
@@ -16693,6 +16863,7 @@ var init_confirmation = __esm({
16693
16863
  init_intro_terms();
16694
16864
  init_offer();
16695
16865
  init_email_plan();
16866
+ init_forwarded();
16696
16867
  ACK_MAX_DELAY_HOURS = 24;
16697
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.";
16698
16869
  ackCache = /* @__PURE__ */ new WeakMap();
@@ -18309,6 +18480,17 @@ function requiredFor(reqs) {
18309
18480
  if (!out.size) for (const el of CHARGE_NOTICE_REQUIRED) out.add(el);
18310
18481
  return [...out];
18311
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
+ }
18312
18494
  async function runChargeNotice(input) {
18313
18495
  const id = "reminder.charge_notice";
18314
18496
  const configRefs = input.data.configRef ? [input.data.configRef] : [];
@@ -18336,6 +18518,12 @@ async function runChargeNotice(input) {
18336
18518
  }
18337
18519
  return c;
18338
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
+ };
18339
18527
  const counted = judged.filter((j) => j.state === "matched" || j.state === "partial" || j.state === "outside_window" || j.state === "missing");
18340
18528
  const matched = counted.filter((j) => j.state === "matched");
18341
18529
  const partial = counted.filter((j) => j.state === "partial");
@@ -18367,10 +18555,12 @@ async function runChargeNotice(input) {
18367
18555
  return makeResult(
18368
18556
  id,
18369
18557
  "fail",
18370
- `${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)})`,
18371
18559
  [...refs2(incompleteMatched.map((i) => i.j)), ...refs2(missing), ...refs2(outside)],
18372
18560
  0.75,
18373
- 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
18374
18564
  );
18375
18565
  }
18376
18566
  if (outside.length) {
@@ -20663,9 +20853,9 @@ function limiter(max) {
20663
20853
  run2();
20664
20854
  }
20665
20855
  };
20666
- return async (fn) => new Promise((resolve9, reject) => {
20856
+ return async (fn) => new Promise((resolve10, reject) => {
20667
20857
  queue.push(() => {
20668
- fn().then(resolve9, reject).finally(() => {
20858
+ fn().then(resolve10, reject).finally(() => {
20669
20859
  active--;
20670
20860
  next();
20671
20861
  });
@@ -22361,7 +22551,7 @@ var init_status = __esm({
22361
22551
  )
22362
22552
  })
22363
22553
  },
22364
- { 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".' }) },
22365
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."' }) },
22366
22556
  // Round 23 (pilot 22 nontech-founder done blocker): "does not appear to be required" means the capture could not see
22367
22557
  // whether the box is enforced (a script may block the purchase, as Stripe's own page does). A person who can try it
@@ -22804,7 +22994,7 @@ var init_status = __esm({
22804
22994
  "signup.disclosure.term_length": 'Say how often it renews ("renews every month until you cancel").',
22805
22995
  "signup.disclosure.proximity": "Put the renewal line under the price, before the card and email fields and the pay button.",
22806
22996
  "signup.disclosure.conspicuous": "Show the renewal line at body size, semibold, in your normal text colour.",
22807
- "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".',
22808
22998
  "signup.consent.not_prechecked": "Leave the consent checkbox unchecked by default.",
22809
22999
  "confirmation.email.sent": "Send an acknowledgment email right after sign-up (Checkbox has a template).",
22810
23000
  "confirmation.email.terms": "Restate the renewal terms (price, frequency, until cancelled) in the acknowledgment.",
@@ -25904,7 +26094,7 @@ __export(check_exports, {
25904
26094
  default: () => check_default
25905
26095
  });
25906
26096
  import { readFileSync as readFileSync3 } from "node:fs";
25907
- import { resolve } from "node:path";
26097
+ import { resolve as resolve2 } from "node:path";
25908
26098
  function operationFor(endpoint) {
25909
26099
  const op = Object.values(OPERATIONS).find((o) => o.httpMethod === "POST" && o.path === endpoint);
25910
26100
  if (!op) throw new Error(`No API operation for POST ${endpoint}`);
@@ -25913,7 +26103,7 @@ function operationFor(endpoint) {
25913
26103
  async function readInput(ctx, file) {
25914
26104
  if (file === "-") return ctx.readStdin();
25915
26105
  try {
25916
- return readFileSync3(resolve(ctx.cwd, file), "utf8");
26106
+ return readFileSync3(resolve2(ctx.cwd, file), "utf8");
25917
26107
  } catch (err) {
25918
26108
  throw new UsageError(`Cannot read ${file}: ${err.code ?? err.message}`);
25919
26109
  }
@@ -26201,7 +26391,7 @@ var init_check = __esm({
26201
26391
 
26202
26392
  // src/ci/config.ts
26203
26393
  import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
26204
- 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";
26205
26395
  function planList(config, ref) {
26206
26396
  if (Array.isArray(ref)) return ref;
26207
26397
  if (ref === "all" && !(config.plans ?? []).some((p) => p.name === "all")) return (config.plans ?? []).map((p) => p.name);
@@ -26209,7 +26399,7 @@ function planList(config, ref) {
26209
26399
  }
26210
26400
  function findConfig(cwd, explicit) {
26211
26401
  if (explicit) {
26212
- const p = isAbsolute(explicit) ? explicit : resolve2(cwd, explicit);
26402
+ const p = isAbsolute(explicit) ? explicit : resolve3(cwd, explicit);
26213
26403
  return existsSync3(p) ? p : void 0;
26214
26404
  }
26215
26405
  for (const f of CONFIG_FILES) if (existsSync3(join3(cwd, f))) return join3(cwd, f);
@@ -26230,7 +26420,7 @@ function loadConfig(path) {
26230
26420
  }
26231
26421
  const issues = validateConfig(value);
26232
26422
  if (issues.length) throw new ConfigError(path, issues);
26233
- return { config: value, path, dir: dirname2(path), raw };
26423
+ return { config: value, path, dir: dirname3(path), raw };
26234
26424
  }
26235
26425
  function validateConfig(value) {
26236
26426
  const is = new Issues();
@@ -26932,11 +27122,17 @@ function findStripeKey(env2, processEnv) {
26932
27122
  sources.push([`${k} (environment)`, processEnv[k]]);
26933
27123
  sources.push([`${k} (.env)`, env2[k]]);
26934
27124
  }
26935
- const found = sources.filter((s2) => typeof s2[1] === "string" && /^(sk|rk)_test_[A-Za-z0-9]{10,}$/.test(s2[1].trim()));
26936
- const pick = found.find((s2) => s2[1].startsWith("rk_test_")) ?? found[0];
26937
- if (!pick) return void 0;
26938
- const key = pick[1].trim();
26939
- 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.`;
26940
27136
  }
26941
27137
  function sessionLiteral(text, index) {
26942
27138
  const open2 = text.indexOf("{", index);
@@ -27018,7 +27214,8 @@ function detectProject(root, processEnv = {}) {
27018
27214
  checkoutCode,
27019
27215
  payButtons,
27020
27216
  checkoutSessions,
27021
- ...stripeKey ? { stripeTestKey: stripeKey } : {},
27217
+ ...stripeKey.key ? { stripeTestKey: stripeKey.key } : {},
27218
+ ...stripeKey.refused ? { stripeKeyRefused: stripeKey.refused } : {},
27022
27219
  ...brand ? { brand } : {},
27023
27220
  plans: plansFromSessions(checkoutSessions),
27024
27221
  env: env2
@@ -27059,7 +27256,7 @@ var init_detect = __esm({
27059
27256
 
27060
27257
  // src/ci/scaffold.ts
27061
27258
  import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
27062
- import { basename, dirname as dirname3, join as join5, relative as relative2 } from "node:path";
27259
+ import { basename, dirname as dirname4, join as join5, relative as relative2 } from "node:path";
27063
27260
  function walk3(root, visit) {
27064
27261
  let n = 0;
27065
27262
  const go = (dir, depth) => {
@@ -27128,7 +27325,7 @@ function paramsBuilder(root, file, line2) {
27128
27325
  const imp = new RegExp(`import\\s*\\{[^}]*\\b${fn}\\b[^}]*\\}\\s*from\\s*['"](\\.{1,2}/[^'"]+)['"]`).exec(text);
27129
27326
  let target;
27130
27327
  if (imp) {
27131
- const base = join5(dirname3(file), imp[1]).split("\\").join("/");
27328
+ const base = join5(dirname4(file), imp[1]).split("\\").join("/");
27132
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)));
27133
27330
  } else if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${fn}\\b|export\\s+const\\s+${fn}\\b`).test(text)) target = file;
27134
27331
  return target ? { module: `${target}#${fn}`, takesArgs } : void 0;
@@ -27315,7 +27512,7 @@ var init_scaffold = __esm({
27315
27512
 
27316
27513
  // src/ci/coverage.ts
27317
27514
  import { readFileSync as readFileSync7 } from "node:fs";
27318
- 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";
27319
27516
  function callsIn(text, file) {
27320
27517
  const hits = [];
27321
27518
  for (const [re, kind] of PATTERNS) {
@@ -27337,7 +27534,7 @@ function callsIn(text, file) {
27337
27534
  }
27338
27535
  function coverageOf(loaded, root, files) {
27339
27536
  const cfg = loaded.config;
27340
- const toRoot = (p) => relative3(root, resolve3(loaded.dir, p)).split("\\").join("/");
27537
+ const toRoot = (p) => relative3(root, resolve4(loaded.dir, p)).split("\\").join("/");
27341
27538
  const calls = [];
27342
27539
  const visit = (rel2) => {
27343
27540
  if (!CODE.test(rel2) || TESTISH.test(rel2)) return;
@@ -27438,10 +27635,10 @@ var init_coverage = __esm({
27438
27635
  // src/ci/inputs.ts
27439
27636
  import { execFile } from "node:child_process";
27440
27637
  import { existsSync as existsSync6, readFileSync as readFileSync8 } from "node:fs";
27441
- import { dirname as dirname4, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve4 } from "node:path";
27638
+ import { dirname as dirname5, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve5 } from "node:path";
27442
27639
  import { pathToFileURL as pathToFileURL2 } from "node:url";
27443
27640
  function readRel(dir, file) {
27444
- const abs = isAbsolute2(file) ? file : resolve4(dir, file);
27641
+ const abs = isAbsolute2(file) ? file : resolve5(dir, file);
27445
27642
  try {
27446
27643
  return readFileSync8(abs, "utf8");
27447
27644
  } catch (err) {
@@ -27452,26 +27649,42 @@ function readRel(dir, file) {
27452
27649
  function hasTsx(dir) {
27453
27650
  for (let d = dir, i = 0; i < 8; i++) {
27454
27651
  if (existsSync6(join7(d, "node_modules", "tsx", "package.json"))) return true;
27455
- const up = dirname4(d);
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);
27456
27666
  if (up === d) break;
27457
27667
  d = up;
27458
27668
  }
27459
27669
  return false;
27460
27670
  }
27671
+ function addTsx(dir, detail = "") {
27672
+ return declaresTsx(dir) ? NOT_INSTALLED : `Add tsx to the project (npm install -D tsx${detail})`;
27673
+ }
27461
27674
  function loaderFlags(file, dir, nodeVersion = process.versions.node, features = process.features) {
27462
27675
  if (/\.[cm]?js$/.test(file)) return [];
27463
27676
  if (hasTsx(dir)) return ["--import", "tsx"];
27464
- 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`;
27465
27678
  if (features.typescript) return [];
27466
27679
  const [major, minor] = nodeVersion.split(".").map(Number);
27467
27680
  if (major > 22 || major === 22 && minor >= 6) return ["--experimental-strip-types"];
27468
- 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`;
27469
27682
  }
27470
27683
  function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
27471
27684
  const hash = ref.lastIndexOf("#");
27472
27685
  const file = hash > 0 ? ref.slice(0, hash) : ref;
27473
27686
  const name = hash > 0 ? ref.slice(hash + 1) : "default";
27474
- const abs = resolve4(dir, file);
27687
+ const abs = resolve5(dir, file);
27475
27688
  if (!existsSync6(abs)) return Promise.reject(new InputError(`${file} does not exist (paths are relative to checkbox.json)`));
27476
27689
  const flags = loaderFlags(file, dir);
27477
27690
  if (typeof flags === "string") return Promise.reject(new InputError(flags));
@@ -27512,13 +27725,13 @@ function moduleErrorMessage(stderr, dir, flags = []) {
27512
27725
  }
27513
27726
  const noExt = !/\.[cm]?[jt]sx?$|\.json$/.test(target);
27514
27727
  if (noExt && !tsx) {
27515
- return `${rel2(from)} imports ${rel2(target)} without a file extension, which Node cannot resolve on its own. Add tsx to the project (npm install -D tsx; checkbox ci then imports modules the way your bundler does), or write the import with its extension (e.g. './${rel2(target).split("/").pop()}.ts').`;
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').`;
27516
27729
  }
27517
27730
  return `${rel2(from)} imports ${rel2(target)}, which does not exist${noExt ? " (tsx found no .ts / .js file of that name either)" : ""}.`;
27518
27731
  }
27519
27732
  if (/ERR_UNKNOWN_FILE_EXTENSION/.test(text)) {
27520
27733
  const file = /Unknown file extension "([^"]+)" for (\S+)/.exec(text);
27521
- return `Node cannot import ${file ? `${rel2(file[2])} (${file[1]})` : "a file the module imports"}. Add tsx to the project (npm install -D tsx), or point the entry at a .js / .ts file that imports no ${file?.[1] ?? "such"} files.`;
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.`;
27522
27735
  }
27523
27736
  const lines = text.split("\n").map((l) => l.trim()).filter((l) => l && !/^at /.test(l) && !/^Node\.js v\d/.test(l));
27524
27737
  const first2 = lines.find((l) => /^(?:[A-Z]\w*)?Error\b.*:/.test(l)) ?? lines.find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "";
@@ -27552,7 +27765,7 @@ function exportLine(dir, ref) {
27552
27765
  const name = hash > 0 ? ref.slice(hash + 1) : "default";
27553
27766
  let text;
27554
27767
  try {
27555
- text = readFileSync8(resolve4(dir, file), "utf8");
27768
+ text = readFileSync8(resolve5(dir, file), "utf8");
27556
27769
  } catch {
27557
27770
  return 1;
27558
27771
  }
@@ -27591,7 +27804,7 @@ function planTerms(config, plan2) {
27591
27804
  return t;
27592
27805
  }
27593
27806
  function rel(loaded, root, file) {
27594
- return relative4(root, resolve4(loaded.dir, file)).split("\\").join("/") || file;
27807
+ return relative4(root, resolve5(loaded.dir, file)).split("\\").join("/") || file;
27595
27808
  }
27596
27809
  function emailFromFile(content, file, subject) {
27597
27810
  let body = content;
@@ -27726,14 +27939,20 @@ function pageJob(loaded, e, o, root) {
27726
27939
  };
27727
27940
  }
27728
27941
  if (e.url) {
27729
- 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';
27730
27943
  else job.body = { url: e.url, ...common3 };
27731
27944
  return job;
27732
27945
  }
27733
27946
  try {
27734
27947
  if (!e.source) job.target = { file: rel(loaded, root, e.file), line: 1 };
27735
27948
  job.inputs.push(e.file);
27736
- const html = readRel(dir, e.file);
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
+ }
27737
27956
  if (!e.source) job.locate = (needles) => lineOfAny(html, needles);
27738
27957
  job.body = { html, ...common3 };
27739
27958
  } catch (err) {
@@ -27800,7 +28019,7 @@ async function resolveJobs(loaded, o) {
27800
28019
  }
27801
28020
  return jobs;
27802
28021
  }
27803
- var InputError, CHILD, jurisdictionsOf;
28022
+ var InputError, NOT_INSTALLED, CHILD, jurisdictionsOf;
27804
28023
  var init_inputs = __esm({
27805
28024
  "src/ci/inputs.ts"() {
27806
28025
  "use strict";
@@ -27813,6 +28032,7 @@ var init_inputs = __esm({
27813
28032
  this.name = "InputError";
27814
28033
  }
27815
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)";
27816
28036
  CHILD = `
27817
28037
  const [url, name, argsJson] = process.argv.slice(1);
27818
28038
  const mod = await import(url);
@@ -27831,106 +28051,6 @@ process.exit(0);
27831
28051
  }
27832
28052
  });
27833
28053
 
27834
- // src/ci/report.ts
27835
- function icon(s2, c) {
27836
- if (s2 === "pass") return c.green("\u2713");
27837
- if (s2 === "fail" || s2 === "error") return c.red("\u2717");
27838
- if (s2 === "needs_review") return c.yellow("?");
27839
- return c.dim("\u2013");
27840
- }
27841
- function overallLine(run2) {
27842
- const s2 = run2.summary;
27843
- const parts = [];
27844
- if (s2.error) parts.push(`${s2.error} could not run`);
27845
- if (s2.fail) parts.push(`${s2.fail} need${s2.fail === 1 ? "s" : ""} a fix`);
27846
- if (s2.needs_review) parts.push(`${s2.needs_review} need${s2.needs_review === 1 ? "s" : ""} review${run2.strict ? " (strict)" : ""}`);
27847
- if (s2.pass) parts.push(`${s2.pass} pass`);
27848
- if (s2.not_applicable) parts.push(`${s2.not_applicable} not required`);
27849
- if (s2.skipped) parts.push(`${s2.skipped} skipped`);
27850
- return parts.join(" \xB7 ") || "nothing checked";
27851
- }
27852
- function renderHuman(run2, c, opts = {}) {
27853
- const lines = [];
27854
- const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
27855
- lines.push(`${c.bold("Checkbox CI")} ${run2.config} \xB7 ${run2.mode}${versions} \xB7 ${run2.checks.length} check${run2.checks.length === 1 ? "" : "s"}`);
27856
- const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
27857
- for (const x of run2.checks) {
27858
- 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);
27859
- lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
27860
- if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
27861
- if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
27862
- if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(` fix: ${wrap(f, 110, " ")}`);
27863
- const alerts = x.alerts ?? [];
27864
- for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
27865
- for (const w of x.warnings.filter((w2) => !alerts.includes(w2)).slice(0, opts.verbose ? 10 : alerts.length ? 0 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
27866
- if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
27867
- }
27868
- const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
27869
- lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
27870
- 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."));
27871
- lines.push(c.dim(run2.disclaimer));
27872
- return lines;
27873
- }
27874
- function annotations(run2) {
27875
- const out = [];
27876
- for (const x of run2.checks) {
27877
- const alerts = x.alerts ?? [];
27878
- const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : alerts.length ? "warning" : void 0;
27879
- if (!level) continue;
27880
- const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
27881
- const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
27882
- ${x.fix}` : `Could not run: ${x.message ?? ""}` : [x.status === "pass" && !x.fix ? "" : x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), ...alerts.map((a) => `Warning: ${a}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
27883
- out.push(`::${level} ${props.join(",")}::${escData(body)}`);
27884
- }
27885
- return out;
27886
- }
27887
- function stepSummary(run2) {
27888
- const lines = [];
27889
- lines.push("## Checkbox compliance checks", "");
27890
- const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
27891
- lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
27892
- lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
27893
- for (const x of run2.checks) {
27894
- const fix0 = x.status === "error" || x.status === "skipped" ? [x.message, x.fix].filter(Boolean).join(" ") : x.fix ? `${x.fix}${x.fixes.length > 1 ? ` (+${x.fixes.length - 1} more)` : ""}` : x.status === "needs_review" && x.message ? x.message : "";
27895
- const fix = [fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`)].filter(Boolean).join("\n");
27896
- lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
27897
- }
27898
- const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
27899
- for (const x of detailed) {
27900
- lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
27901
- if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
27902
- if (x.corrected) {
27903
- const fence = x.corrected.body.includes("```") ? "````" : "```";
27904
- lines.push(`${fence}${x.corrected.format === "json" ? "json" : "text"}`, x.corrected.body, fence, "");
27905
- }
27906
- lines.push("</details>");
27907
- }
27908
- lines.push("", `<sub>${run2.disclaimer} Docs: https://checkbox.site/docs/guides/ci</sub>`, "");
27909
- return lines.join("\n");
27910
- }
27911
- function escapeHtml(s2) {
27912
- return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27913
- }
27914
- var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
27915
- var init_report = __esm({
27916
- "src/ci/report.ts"() {
27917
- "use strict";
27918
- init_output();
27919
- TYPE_LABEL = {
27920
- stripe_checkout: "Stripe Checkout",
27921
- disclosure: "Disclosure",
27922
- page: "Page",
27923
- email: "Email",
27924
- notice_schedule: "Notice schedule"
27925
- };
27926
- where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
27927
- escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
27928
- escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
27929
- cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
27930
- STATUS_MD = { pass: "\u2705", fail: "\u274C", needs_review: "\u26A0\uFE0F", error: "\u274C", skipped: "\u23ED\uFE0F", not_applicable: "\u2796" };
27931
- }
27932
- });
27933
-
27934
28054
  // ../rules/src/fixes/conflicts.ts
27935
28055
  var init_conflicts = __esm({
27936
28056
  "../rules/src/fixes/conflicts.ts"() {
@@ -28435,10 +28555,27 @@ function onlineEngine(client) {
28435
28555
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
28436
28556
  }
28437
28557
  function ciVocabulary(text) {
28438
- return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored");
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;
28439
28576
  }
28440
28577
  function stripeConsentFix(text, params) {
28441
- if (!/^Add one unchecked checkbox/.test(text)) return text;
28578
+ if (!/^(Ask for a separate yes to the renewal|Add one unchecked checkbox)/.test(text)) return text;
28442
28579
  const cc = params?.consent_collection;
28443
28580
  if (cc?.terms_of_service === "required") return text;
28444
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.';
@@ -28596,7 +28733,9 @@ async function runJob(job, engine, config, opts = {}) {
28596
28733
  if (alerts.length) Object.assign(base, { alerts });
28597
28734
  const status = ciStatusOf(report);
28598
28735
  const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
28599
- const fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
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);
28600
28739
  const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
28601
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)));
28602
28741
  if (unread) {
@@ -28621,7 +28760,15 @@ async function runJob(job, engine, config, opts = {}) {
28621
28760
  const line2 = needles.length ? job.locate(needles) : void 0;
28622
28761
  if (line2) base.target = { ...base.target, line: line2 };
28623
28762
  }
28624
- const finalStatus = unread ? literal.length ? "fail" : "needs_review" : status;
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;
28625
28772
  const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders);
28626
28773
  if (corrected?.format === "json" && job.placeholders) {
28627
28774
  const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
@@ -28632,17 +28779,17 @@ async function runJob(job, engine, config, opts = {}) {
28632
28779
  ...base,
28633
28780
  status: finalStatus,
28634
28781
  ...blocking ? { blocking: true } : {},
28635
- label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report),
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),
28636
28783
  ...fixes[0] ? { fix: fixes[0] } : {},
28637
28784
  fixes,
28638
28785
  ...corrected ? { corrected } : {},
28639
28786
  report
28640
28787
  };
28641
28788
  }
28642
- function countedLabel(label, r) {
28789
+ function countedLabel(label, r, adjust) {
28643
28790
  const m = /^(\d+) fix(?:es)? needed/.exec(label);
28644
28791
  if (!m) return label;
28645
- const n = fixesOf(r, true).length;
28792
+ const n = (adjust ? adjust(fixesOf(r, true)) : fixesOf(r, true)).length;
28646
28793
  if (!n || n === Number(m[1])) return label;
28647
28794
  return label.replace(m[0], `${n} fix${n === 1 ? "" : "es"} needed`);
28648
28795
  }
@@ -28652,6 +28799,7 @@ function exitCodeOf(checks, strict) {
28652
28799
  if (checks.some((c) => c.status === "error")) return { status: "error", exit: EXIT.usage };
28653
28800
  if (checks.some((c) => c.status === "fail")) return { status: "fail", exit: EXIT.fail };
28654
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 };
28655
28803
  return { status: "pass", exit: EXIT.ok };
28656
28804
  }
28657
28805
  function mergeSourcePatches(checks) {
@@ -28673,6 +28821,11 @@ function mergeSourcePatches(checks) {
28673
28821
  }
28674
28822
  }
28675
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
+ }
28676
28829
  async function runCi(jobs, engine, opts) {
28677
28830
  const checks = [...opts.extra ?? []];
28678
28831
  for (const j of jobs) checks.push(await runJob(j, engine, opts.config, { ...opts.notices ? { notices: opts.notices } : {} }));
@@ -28738,8 +28891,112 @@ var init_runner = __esm({
28738
28891
  }
28739
28892
  });
28740
28893
 
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"() {
28980
+ "use strict";
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
+
28741
28998
  // src/commands/plan/lib.ts
28742
- import { dirname as dirname5, resolve as resolve5 } from "node:path";
28999
+ import { dirname as dirname6, resolve as resolve6 } from "node:path";
28743
29000
  async function resolveSite(ctx, flag, client) {
28744
29001
  const explicit = flag ?? ctx.env.CHECKBOX_SITE;
28745
29002
  if (explicit) {
@@ -28747,10 +29004,10 @@ async function resolveSite(ctx, flag, client) {
28747
29004
  return explicit;
28748
29005
  }
28749
29006
  const projects = ctx.config.load().init?.projects ?? {};
28750
- for (let dir = resolve5(ctx.cwd); ; dir = dirname5(dir)) {
29007
+ for (let dir = resolve6(ctx.cwd); ; dir = dirname6(dir)) {
28751
29008
  const site = projects[dir]?.site;
28752
29009
  if (site) return site;
28753
- if (dirname5(dir) === dir) break;
29010
+ if (dirname6(dir) === dir) break;
28754
29011
  }
28755
29012
  const list2 = await client.sdk.sites.list({ limit: 3 });
28756
29013
  const sites = list2?.data ?? [];
@@ -28807,12 +29064,28 @@ var init_lib = __esm({
28807
29064
  });
28808
29065
 
28809
29066
  // src/ci/workflow.ts
28810
- var WORKFLOW_PATH, WORKFLOW_YAML;
28811
- var init_workflow = __esm({
28812
- "src/ci/workflow.ts"() {
28813
- "use strict";
28814
- WORKFLOW_PATH = ".github/workflows/checkbox.yml";
28815
- WORKFLOW_YAML = `# Checkbox: automatic-renewal law checks on every pull request (https://checkbox.site/docs/guides/ci).
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).
28816
29089
  # Runs the checks listed in checkbox.json with the Checkbox rules engine, offline (no key, no network), annotates the
28817
29090
  # lines that need a fix and writes a summary with the corrected params / text to the job page.
28818
29091
  name: Checkbox
@@ -28830,35 +29103,85 @@ jobs:
28830
29103
  runs-on: ubuntu-latest
28831
29104
  timeout-minutes: 10
28832
29105
  steps:
28833
- - uses: actions/checkout@v4
28834
- # Only needed when checkbox.json uses "module" entries that import your dependencies:
28835
- # - run: npm ci
28836
- - uses: checkbox-site/checkbox@v1
28837
- # with:
28838
- # strict: true # also fail when a check needs review
28839
- # args: --online # URL pages via the API; needs the env below
28840
- # env:
28841
- # CHECKBOX_SECRET_KEY: \${{ secrets.CHECKBOX_SECRET_KEY }}
29106
+ ${steps.join("\n")}
28842
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();
28843
29164
  }
28844
29165
  });
28845
29166
 
28846
29167
  // src/commands/ci/index.ts
28847
29168
  var ci_exports = {};
28848
29169
  __export(ci_exports, {
29170
+ attachSiteForCoupons: () => attachSiteForCoupons,
29171
+ couponIdsIn: () => couponIdsIn,
28849
29172
  default: () => ci_default,
28850
29173
  touchesConfig: () => touchesConfig,
28851
29174
  undiffableInputs: () => undiffableInputs
28852
29175
  });
28853
- import { execFileSync } from "node:child_process";
28854
- import { appendFileSync, existsSync as existsSync7, 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";
28855
29178
  import { createInterface } from "node:readline";
28856
- import { dirname as dirname6, join as join8, relative as relative5, resolve as resolve6 } from "node:path";
29179
+ import { dirname as dirname7, join as join9, relative as relative5, resolve as resolve7 } from "node:path";
28857
29180
  function gitChanged(cwd, base) {
28858
29181
  try {
28859
- const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
28860
- const out = execFileSync("git", ["diff", "--name-only", `${base}...HEAD`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
28861
- return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve6(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)) };
28862
29185
  } catch (err) {
28863
29186
  const msg2 = String(err.stderr || err.message).trim().split("\n")[0];
28864
29187
  return `git diff ${base}...HEAD failed (${msg2}); fetch the base branch (actions/checkout with fetch-depth: 0)`;
@@ -28868,13 +29191,13 @@ function undiffableInputs(loaded, jobs, gitRoot) {
28868
29191
  const need = [];
28869
29192
  const out = [];
28870
29193
  for (const e of loaded.config.checks.pages ?? []) {
28871
- if (e.source) need.push({ path: resolve6(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}` });
28872
29195
  else if (e.url) out.push(`pages "${e.name}" is a URL with no "source" file to diff`);
28873
- else if (e.file) need.push({ path: resolve6(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}` });
28874
29197
  }
28875
29198
  for (const j of jobs) {
28876
29199
  if (j.type === "page") continue;
28877
- for (const f of j.inputs) need.push({ path: resolve6(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}` });
28878
29201
  }
28879
29202
  if (!need.length) return out;
28880
29203
  const real = (p) => {
@@ -28889,8 +29212,8 @@ function undiffableInputs(loaded, jobs, gitRoot) {
28889
29212
  let tracked;
28890
29213
  try {
28891
29214
  const rels = [...new Set(need.map((n) => relative5(gitRoot, n.path)))];
28892
- const listed = execFileSync("git", ["ls-files", "-z", "--", ...rels], { cwd: gitRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
28893
- tracked = new Set(listed.split("\0").filter(Boolean).map((f) => resolve6(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)));
28894
29217
  } catch (err) {
28895
29218
  return `git ls-files failed (${String(err.stderr || err.message).trim().split("\n")[0]})`;
28896
29219
  }
@@ -28902,7 +29225,7 @@ function touchesConfig(loaded, jobs, changed) {
28902
29225
  if (set.has(loaded.path)) return true;
28903
29226
  const codeChanged = changed.some((f) => /\.(?:[cm]?[jt]sx?)$/.test(f));
28904
29227
  for (const j of jobs) {
28905
- for (const f of j.inputs) if (set.has(resolve6(loaded.dir, f))) return true;
29228
+ for (const f of j.inputs) if (set.has(resolve7(loaded.dir, f))) return true;
28906
29229
  }
28907
29230
  const hasModules = [...loaded.config.checks.stripe_checkout ?? [], ...loaded.config.checks.emails ?? []].some((e) => "module" in e && e.module);
28908
29231
  return hasModules && codeChanged;
@@ -28927,24 +29250,26 @@ async function runInit(ctx, argv) {
28927
29250
  const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
28928
29251
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
28929
29252
  const explicit = str(p.flags, "config");
28930
- const configPath = explicit ? resolve6(ctx.cwd, explicit) : CONFIG_FILES.map((f) => join8(ctx.cwd, f)).find((f) => existsSync7(f)) ?? join8(ctx.cwd, CONFIG_FILES[0]);
28931
- 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);
28932
29255
  const force = bool(p.flags, "force") === true;
28933
29256
  const withWorkflow = bool(p.flags, "workflow") !== false;
28934
29257
  const projects = ctx.config.load().init?.projects ?? {};
28935
29258
  let facts = {};
28936
- for (let dir = resolve6(ctx.cwd); ; dir = dirname6(dir)) {
29259
+ for (let dir = resolve7(ctx.cwd); ; dir = dirname7(dir)) {
28937
29260
  if (projects[dir]?.facts) {
28938
29261
  facts = projects[dir].facts;
28939
29262
  break;
28940
29263
  }
28941
- if (dirname6(dir) === dir) break;
29264
+ if (dirname7(dir) === dir) break;
28942
29265
  }
28943
29266
  const s2 = scaffoldConfig(ctx.cwd, ctx.env, facts);
28944
- const actionFor = (f) => existsSync7(f) ? force ? "overwrite" : "keep" : "create";
29267
+ const actionFor = (f) => existsSync8(f) ? force ? "overwrite" : "keep" : "create";
28945
29268
  const files = [{ path: configPath, content: `${JSON.stringify(s2.config, null, 2)}
28946
29269
  `, action: actionFor(configPath) }];
28947
- 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) });
28948
29273
  const toWrite = files.filter((f) => f.action !== "keep");
28949
29274
  const dry = bool(p.flags, "dry-run") === true;
28950
29275
  let write = !dry && toWrite.length > 0;
@@ -28956,7 +29281,7 @@ async function runInit(ctx, argv) {
28956
29281
  }
28957
29282
  if (write) {
28958
29283
  for (const f of toWrite) {
28959
- mkdirSync2(dirname6(f.path), { recursive: true });
29284
+ mkdirSync2(dirname7(f.path), { recursive: true });
28960
29285
  writeFileSync2(f.path, f.content);
28961
29286
  }
28962
29287
  }
@@ -28967,6 +29292,7 @@ async function runInit(ctx, argv) {
28967
29292
  kept: files.filter((f) => f.action === "keep").map((f) => relName(f.path)),
28968
29293
  ...write ? {} : { would_write: toWrite.map((f) => relName(f.path)) },
28969
29294
  found: s2.found,
29295
+ ...withWorkflow ? { workflow_steps: workflowSteps } : {},
28970
29296
  todos: s2.todos,
28971
29297
  config: s2.config,
28972
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." }
@@ -28976,6 +29302,8 @@ async function runInit(ctx, argv) {
28976
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)") : ""}`);
28977
29303
  if (!write && toWrite.length && !dry) lines.push(ctx.out.c.dim("Nothing written: add --yes to write without asking."));
28978
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".');
28979
29307
  if (s2.todos.length) lines.push("", "To do:", ...s2.todos.map((t) => ` - ${t}`));
28980
29308
  lines.push("", `${ctx.out.c.cyan("Next:")} ${result.next_action.message}`);
28981
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}`)));
@@ -28997,6 +29325,22 @@ async function readNotices(ctx, client, siteFlag) {
28997
29325
  return { error: `Could not read the Notice settings of ${site} (${err.message}).` };
28998
29326
  }
28999
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
+ }
29000
29344
  var HELP2, NOTICE_KIND_TO_EMAIL, ci_default;
29001
29345
  var init_ci = __esm({
29002
29346
  "src/commands/ci/index.ts"() {
@@ -29017,7 +29361,8 @@ var init_ci = __esm({
29017
29361
  "",
29018
29362
  "Flags:",
29019
29363
  " --config <path> Config file (default: checkbox.json, then .checkbox.json)",
29020
- " --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;',
29021
29366
  ` with "notices" in checkbox.json, also checks that the site's Checkbox Notices really send them`,
29022
29367
  " --site <site_\u2026> The site for --online (default: $CHECKBOX_SITE, the site `checkbox init` saved, or the only one)",
29023
29368
  ' --strict "Needs review" fails too (exit 2)',
@@ -29033,12 +29378,14 @@ var init_ci = __esm({
29033
29378
  "",
29034
29379
  "Exit: 0 pass (or needs review without --strict) \xB7 1 a check needs a fix \xB7 2 needs review with --strict, or a",
29035
29380
  " page / disclosure with no renewal terms to judge, a checkout whose coupon / trial is not verified, or a",
29036
- ' 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",
29037
29383
  " 3 config / usage error \xB7 4 auth (--online) \xB7 5 API or network error",
29038
29384
  "",
29039
29385
  "checkbox ci init [--yes] [--force] [--dry-run] [--no-workflow]",
29040
29386
  ' Writes checkbox.json from the project (Stripe Checkout calls, plans, email templates, pages; "TODO" where it',
29041
- " 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).",
29042
29389
  "",
29043
29390
  "Docs: https://checkbox.site/docs/guides/ci"
29044
29391
  ].join("\n");
@@ -29081,7 +29428,7 @@ var init_ci = __esm({
29081
29428
  const workspace = ctx.env.GITHUB_WORKSPACE ?? ctx.cwd;
29082
29429
  const extra = (loaded.config.coverage === false ? [] : coverageResults(coverageOf(loaded, loaded.dir))).map((r) => ({
29083
29430
  ...r,
29084
- 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 }
29085
29432
  }));
29086
29433
  if (bool(p.flags, "changed-only")) {
29087
29434
  const base = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
@@ -29118,6 +29465,7 @@ ${message}
29118
29465
  return EXIT.auth;
29119
29466
  }
29120
29467
  }
29468
+ if (client) await attachSiteForCoupons(ctx, client, jobs, str(p.flags, "site"));
29121
29469
  const engine = client ? onlineEngine(client) : offlineEngine();
29122
29470
  const notices = client && declaredByCheckbox(loaded.config).length ? await readNotices(ctx, client, str(p.flags, "site")) : void 0;
29123
29471
  const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
@@ -29353,8 +29701,8 @@ var init_patch = __esm({
29353
29701
  });
29354
29702
 
29355
29703
  // src/commands/plan/fix.ts
29356
- import { existsSync as existsSync8, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "node:fs";
29357
- import { relative as relative6, resolve as resolve7 } from "node:path";
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";
29358
29706
  function fixForTask(task, cwd, file) {
29359
29707
  const base = {
29360
29708
  object: "task_fix",
@@ -29366,12 +29714,12 @@ function fixForTask(task, cwd, file) {
29366
29714
  verify: task.verify
29367
29715
  };
29368
29716
  const verifyCmd = `checkbox verify ${task.id} --wait`;
29369
- const candidate = file ?? (task.where.file_hint && existsSync8(resolve7(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);
29370
29718
  if (task.change.type === "patch" && task.change.edits.length && candidate) {
29371
- const path = resolve7(cwd, candidate);
29719
+ const path = resolve8(cwd, candidate);
29372
29720
  let src;
29373
29721
  try {
29374
- src = readFileSync9(path, "utf8");
29722
+ src = readFileSync10(path, "utf8");
29375
29723
  } catch (err) {
29376
29724
  throw new UsageError(`Cannot read ${candidate}: ${err.code ?? err.message}`);
29377
29725
  }
@@ -29431,7 +29779,7 @@ var init_fix = __esm({
29431
29779
  const task = await ctx.client().sdk.tasks.retrieve(id);
29432
29780
  const result = fixForTask(task, ctx.cwd, str(flags, "file"));
29433
29781
  const out = str(flags, "out");
29434
- if (out && result.diff) writeFileSync3(resolve7(ctx.cwd, out), result.diff);
29782
+ if (out && result.diff) writeFileSync3(resolve8(ctx.cwd, out), result.diff);
29435
29783
  ctx.out.result({ ...result, task_object: task }, () => {
29436
29784
  const c = ctx.out.c;
29437
29785
  const lines = [`${c.bold(task.title)} ${c.dim(`(${task.priority}, ${task.id})`)}`, c.dim(`${task.requirement.citation}`), task.why, ""];
@@ -29533,12 +29881,12 @@ var init_device = __esm({
29533
29881
  });
29534
29882
 
29535
29883
  // src/commands/init/patch.ts
29536
- import { execFileSync as execFileSync2 } from "node:child_process";
29537
- import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "node:fs";
29538
- import { dirname as dirname7, join as join9 } from "node:path";
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";
29539
29887
  function read(root, rel2) {
29540
29888
  try {
29541
- return readFileSync10(join9(root, rel2), "utf8");
29889
+ return readFileSync11(join10(root, rel2), "utf8");
29542
29890
  } catch {
29543
29891
  return null;
29544
29892
  }
@@ -29716,7 +30064,7 @@ function mask(v) {
29716
30064
  }
29717
30065
  function isGitTracked(root, file) {
29718
30066
  try {
29719
- 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 });
29720
30068
  return true;
29721
30069
  } catch {
29722
30070
  return false;
@@ -29733,7 +30081,7 @@ function planGitignore(d) {
29733
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*");
29734
30082
  if (covered) return null;
29735
30083
  }
29736
- if (existing === null && !existsSync9(join9(d.root, ".git"))) return null;
30084
+ if (existing === null && !existsSync10(join10(d.root, ".git"))) return null;
29737
30085
  const text = `${existing ?? ""}${existing && !existing.endsWith("\n") ? "\n" : ""}# Checkbox keys (checkbox init)
29738
30086
  ${env2}
29739
30087
  `;
@@ -29749,8 +30097,8 @@ function planWellKnown(d, token) {
29749
30097
  }
29750
30098
  function applyChange(root, c) {
29751
30099
  if (c.action === "unchanged" || c.contents === void 0) return;
29752
- const abs = join9(root, c.file);
29753
- mkdirSync3(dirname7(abs), { recursive: true });
30100
+ const abs = join10(root, c.file);
30101
+ mkdirSync3(dirname8(abs), { recursive: true });
29754
30102
  writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
29755
30103
  }
29756
30104
  var MARK_START, MARK_END, NEXT_DOCUMENT, NUXT_PLUGIN;
@@ -29797,8 +30145,8 @@ __export(init_exports, {
29797
30145
  parsePlanFlag: () => parsePlanFlag,
29798
30146
  syncCheckoutConfigs: () => syncCheckoutConfigs
29799
30147
  });
29800
- import { existsSync as existsSync10 } from "node:fs";
29801
- import { join as join10 } from "node:path";
30148
+ import { existsSync as existsSync11 } from "node:fs";
30149
+ import { join as join11 } from "node:path";
29802
30150
  import { createInterface as createInterface2 } from "node:readline";
29803
30151
  function loadState(ctx, root) {
29804
30152
  const cfg = ctx.config.load().init ?? {};
@@ -29818,7 +30166,7 @@ function saveState(ctx, root, patch) {
29818
30166
  async function ask2(question) {
29819
30167
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
29820
30168
  try {
29821
- return await new Promise((resolve9) => rl.question(question, resolve9));
30169
+ return await new Promise((resolve10) => rl.question(question, resolve10));
29822
30170
  } finally {
29823
30171
  rl.close();
29824
30172
  }
@@ -29896,7 +30244,7 @@ async function syncCheckoutConfigs(client, site, sessions, root) {
29896
30244
  const from = configFileOf(c.name);
29897
30245
  if (!from) continue;
29898
30246
  const sameFile = perFile.has(from.file);
29899
- const gone = !sameFile && !existsSync10(join10(root, from.file));
30247
+ const gone = !sameFile && !existsSync11(join11(root, from.file));
29900
30248
  const current = sameFile && from.call !== void 0 && from.call <= perFile.get(from.file) && c.name === configNameOf(from.file, from.call);
29901
30249
  if (!sameFile && !gone || current) continue;
29902
30250
  if (await client.sdk.stripeCheckoutConfigs.del(c.id).then(() => true, () => false)) {
@@ -29936,6 +30284,7 @@ function detectionSummary(d, siteUrl) {
29936
30284
  pay_buttons: d.payButtons.map((c) => ({ file: c.file, line: c.line })),
29937
30285
  checkout_sessions: d.checkoutSessions.map((s2) => ({ file: s2.file, line: s2.line, readable: !!s2.params, unresolved: s2.unresolved })),
29938
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) } } : {},
29939
30288
  merchant_name: d.brand ?? null,
29940
30289
  plans: d.plans
29941
30290
  };
@@ -30030,7 +30379,7 @@ var init_init = __esm({
30030
30379
  " 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it)",
30031
30380
  " 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments)",
30032
30381
  " 5. registers the site, Terms / refund / cancellation policy URLs (fetched and checked by Checkbox)",
30033
- " 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",
30034
30383
  " 7. runs the first checks and prints the next step (`checkbox plan --json`); the human gets the summary email",
30035
30384
  " when the setup is finished (every check passes, or `checkbox init --complete`)",
30036
30385
  "",
@@ -30226,7 +30575,20 @@ var init_init = __esm({
30226
30575
  }
30227
30576
  const policyFromPaths = (kind) => siteUrl && d.policyPaths[kind] ? `${siteUrl}${d.policyPaths[kind]}` : void 0;
30228
30577
  const agent = detectAgent(ctx.env, agentName);
30229
- 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
+ }
30230
30592
  const brand = merchantName ?? d.brand?.name;
30231
30593
  const update = {
30232
30594
  ...siteUrl ? { site_url: siteUrl } : {},
@@ -30292,13 +30654,16 @@ var init_init = __esm({
30292
30654
  if (consent && d.stripeTestKey && !session.stripe.test_credential_stored) {
30293
30655
  try {
30294
30656
  await client.sdk.testCredentials.create(site, { type: "stripe_test", secret_key: d.stripeTestKey.key });
30295
- stripe.test_credential = { stored: true, key: d.stripeTestKey.hint, restricted: d.stripeTestKey.restricted };
30296
- 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 };
30297
30658
  } catch (err) {
30298
30659
  stripe.test_credential = { stored: false, error: err instanceof ApiError ? `${err.code ?? ""} ${err.message}`.trim() : String(err) };
30299
30660
  warnings.push(`Stripe test key not stored: ${stripe.test_credential.error}`);
30300
30661
  }
30301
30662
  }
30663
+ if (!d.stripeTestKey && d.stripeKeyRefused) {
30664
+ stripe.test_credential = { stored: false, refused: d.stripeKeyRefused.kind };
30665
+ warnings.push(stripeKeyRefusedMessage(d.stripeKeyRefused));
30666
+ }
30302
30667
  const configs = await syncCheckoutConfigs(client, site, d.checkoutSessions, root);
30303
30668
  if (configs.length) stripe.checkout_configs = configs;
30304
30669
  const registered = configs.find((c) => c.id);
@@ -30401,10 +30766,11 @@ var init_whoami = __esm({
30401
30766
  object: "whoami",
30402
30767
  key: creds ? maskKey(creds.apiKey) : null,
30403
30768
  key_source: creds?.source ?? null,
30769
+ ...creds?.file ? { key_file: creds.file } : {},
30404
30770
  api_base: client.apiBase,
30405
30771
  account: account.raw
30406
30772
  },
30407
- () => [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}`)]
30408
30774
  );
30409
30775
  return EXIT.ok;
30410
30776
  }
@@ -30421,7 +30787,7 @@ import { createInterface as createInterface3 } from "node:readline";
30421
30787
  async function prompt(question) {
30422
30788
  const rl = createInterface3({ input: process.stdin, output: process.stderr, terminal: true });
30423
30789
  try {
30424
- return await new Promise((resolve9) => rl.question(question, resolve9));
30790
+ return await new Promise((resolve10) => rl.question(question, resolve10));
30425
30791
  } finally {
30426
30792
  rl.close();
30427
30793
  }
@@ -30555,11 +30921,11 @@ var init_plan = __esm({
30555
30921
  });
30556
30922
 
30557
30923
  // src/commands/plan/verify.ts
30558
- import { readFileSync as readFileSync11 } from "node:fs";
30559
- import { resolve as resolve8 } from "node:path";
30924
+ import { readFileSync as readFileSync12 } from "node:fs";
30925
+ import { resolve as resolve9 } from "node:path";
30560
30926
  function readJson2(ctx, file, what) {
30561
30927
  try {
30562
- const v = JSON.parse(readFileSync11(resolve8(ctx.cwd, file), "utf8"));
30928
+ const v = JSON.parse(readFileSync12(resolve9(ctx.cwd, file), "utf8"));
30563
30929
  if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
30564
30930
  return v;
30565
30931
  } catch (err) {
@@ -30605,7 +30971,7 @@ var init_verify2 = __esm({
30605
30971
  const htmlFile = str(flags, "html-file");
30606
30972
  if (htmlFile) {
30607
30973
  try {
30608
- body.html = readFileSync11(resolve8(ctx.cwd, htmlFile), "utf8");
30974
+ body.html = readFileSync12(resolve9(ctx.cwd, htmlFile), "utf8");
30609
30975
  } catch (err) {
30610
30976
  throw new UsageError(`Cannot read ${htmlFile}: ${err.code ?? err.message}`);
30611
30977
  }