@checkbox-site/cli 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/checkbox.js +1086 -366
- package/package.json +5 -2
package/dist/checkbox.js
CHANGED
|
@@ -123,7 +123,37 @@ var init_args = __esm({
|
|
|
123
123
|
// src/config.ts
|
|
124
124
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
125
125
|
import { homedir } from "node:os";
|
|
126
|
-
import { join } from "node:path";
|
|
126
|
+
import { dirname, join, resolve } from "node:path";
|
|
127
|
+
function readEnvValue(text, name) {
|
|
128
|
+
const re = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=[ \\t]*(?:"([^"\\r\\n]*)"|'([^'\\r\\n]*)'|([^\\s#]*))[ \\t]*(?:#.*)?$`, "gm");
|
|
129
|
+
let found;
|
|
130
|
+
for (let m = re.exec(text); m; m = re.exec(text)) found = (m[1] ?? m[2] ?? m[3] ?? "").trim();
|
|
131
|
+
return found || void 0;
|
|
132
|
+
}
|
|
133
|
+
function findProjectKey(cwd, env2 = {}) {
|
|
134
|
+
const home = resolve(env2.HOME || homedir());
|
|
135
|
+
let dir = resolve(cwd);
|
|
136
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
137
|
+
for (const f of PROJECT_KEY_FILES) {
|
|
138
|
+
let text;
|
|
139
|
+
try {
|
|
140
|
+
text = readFileSync(join(dir, f), "utf8");
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const v = readEnvValue(text, "CHECKBOX_SECRET_KEY");
|
|
145
|
+
if (v && looksLikeApiKey(v)) {
|
|
146
|
+
const up = depth === 0 ? "" : "../".repeat(depth);
|
|
147
|
+
return { apiKey: v, file: `${up}${f}` };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (existsSync(join(dir, ".git")) || dir === home) break;
|
|
151
|
+
const parent = dirname(dir);
|
|
152
|
+
if (parent === dir) break;
|
|
153
|
+
dir = parent;
|
|
154
|
+
}
|
|
155
|
+
return void 0;
|
|
156
|
+
}
|
|
127
157
|
function configDir(env2) {
|
|
128
158
|
if (env2.CHECKBOX_CONFIG_DIR) return env2.CHECKBOX_CONFIG_DIR;
|
|
129
159
|
const base = env2.XDG_CONFIG_HOME || join(env2.HOME || homedir(), ".config");
|
|
@@ -167,27 +197,35 @@ function createConfigStore(env2) {
|
|
|
167
197
|
}
|
|
168
198
|
};
|
|
169
199
|
}
|
|
170
|
-
function resolveCredentials(env2, stored, flags) {
|
|
200
|
+
function resolveCredentials(env2, stored, flags, project) {
|
|
171
201
|
const apiBase = (flags.apiBase || env2.CHECKBOX_API_BASE || stored.api_base || DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
172
202
|
if (flags.apiKey) return { apiKey: flags.apiKey, apiBase, source: "flag" };
|
|
173
203
|
if (env2.CHECKBOX_API_KEY) return { apiKey: env2.CHECKBOX_API_KEY, apiBase, source: "env" };
|
|
174
204
|
if (env2.CHECKBOX_SECRET_KEY) return { apiKey: env2.CHECKBOX_SECRET_KEY, apiBase, source: "env" };
|
|
205
|
+
if (project) return { apiKey: project.apiKey, apiBase, source: "project", file: project.file };
|
|
175
206
|
if (stored.api_key) return { apiKey: stored.api_key, apiBase, source: "config" };
|
|
176
207
|
return void 0;
|
|
177
208
|
}
|
|
209
|
+
function describeKeySource(c, env2 = {}) {
|
|
210
|
+
if (c.source === "flag") return "--api-key";
|
|
211
|
+
if (c.source === "env") return env2.CHECKBOX_API_KEY ? "CHECKBOX_API_KEY" : "CHECKBOX_SECRET_KEY in the environment";
|
|
212
|
+
if (c.source === "project") return `this project's ${c.file ?? ".env.local"}`;
|
|
213
|
+
return "your saved login (checkbox login)";
|
|
214
|
+
}
|
|
178
215
|
function maskKey(key) {
|
|
179
|
-
const m = /^([a-z]{2}_(?:live|test)_)/.exec(key);
|
|
216
|
+
const m = /^([a-z]{2,3}_(?:live|test)_)/.exec(key);
|
|
180
217
|
const prefix = m?.[1] ?? key.slice(0, 3);
|
|
181
218
|
return `${prefix}\u2026${key.slice(-4)}`;
|
|
182
219
|
}
|
|
183
220
|
function looksLikeApiKey(key) {
|
|
184
221
|
return /^(?:ckr|ck|rk|sk)_(?:live|test)_[A-Za-z0-9_-]{8,}$/.test(key.trim());
|
|
185
222
|
}
|
|
186
|
-
var DEFAULT_API_BASE;
|
|
223
|
+
var DEFAULT_API_BASE, PROJECT_KEY_FILES;
|
|
187
224
|
var init_config = __esm({
|
|
188
225
|
"src/config.ts"() {
|
|
189
226
|
"use strict";
|
|
190
227
|
DEFAULT_API_BASE = "https://app.checkbox.site";
|
|
228
|
+
PROJECT_KEY_FILES = [".env.local", ".env", ".checkbox/keys.env"];
|
|
191
229
|
}
|
|
192
230
|
});
|
|
193
231
|
|
|
@@ -2444,7 +2482,7 @@ var init_operations = __esm({
|
|
|
2444
2482
|
/**
|
|
2445
2483
|
* Connect a billing platform.
|
|
2446
2484
|
*
|
|
2447
|
-
* Connects the site's billing platform with the one restricted key (a Stripe restricted key, a Recharge token, a Shopify Admin API token, a Chargebee read-only API key, a Recurly private API key or a Paddle API key with read permissions), so Checkbox reads the customer-portal and reminder settings. Stripe: One Stripe restricted key per site: Read permissions for the checks, and Write permissions that Checkbox uses only after you click Approve on a fix, or in test mode (the one exception: Webhook Endpoints, used once to add Checkbox's webhook endpoint when you connect the key) (checklist: https://checkbox.site/docs/guides/stripe-key). A full secret key (sk_test_ / sk_live_) is refused on every path with test_key_not_restricted: use a restricted key. API keys and AI agents may connect TEST-mode keys (rk_test_…) only; a live key (rk_live_ / sk_live_) is refused with live_key_requires_dashboard, because only a person signed in to the Checkbox dashboard can connect one. The credentials are validated by reading the settings once and stored encrypted. When the key's Stripe account looks like another site's (the site's prices or Payment Links are not in it, or it is already connected in another workspace), nothing is connected and the error billing_account_mismatch asks the human to confirm; retry with confirm_account_mismatch: true only after they said yes. The account owner is emailed about every key connected.
|
|
2485
|
+
* Connects the site's billing platform with the one restricted key (a Stripe restricted key, a Recharge token, a Shopify Admin API token, a Chargebee read-only API key, a Recurly private API key or a Paddle API key with read permissions), so Checkbox reads the customer-portal and reminder settings. Stripe: One Stripe restricted key per site: Read permissions for the checks, and Write permissions that Checkbox uses only after you click Approve on a fix, or in test mode (the one exception: Webhook Endpoints, used once to add Checkbox's webhook endpoint when you connect the key) (checklist: https://checkbox.site/docs/guides/stripe-key). A full secret key (sk_test_ / sk_live_) is refused on every path with test_key_not_restricted: use a restricted key. API keys and AI agents may connect TEST-mode keys (rk_test_…) only; a live key (rk_live_ / sk_live_) is refused with live_key_requires_dashboard, because only a person signed in to the Checkbox dashboard can connect one. The credentials are validated by reading the settings once and stored encrypted. When the key's Stripe account looks like another site's (the site's prices or Payment Links are not in it, or it is already connected in another workspace), nothing is connected and the error billing_account_mismatch asks the human to confirm; retry with confirm_account_mismatch: true only after they said yes; the key is then held until a person confirms the account in the dashboard (an agent cannot confirm it). The account owner is emailed about every key connected.
|
|
2448
2486
|
*
|
|
2449
2487
|
* @param site Unique identifier of the site (site_…).
|
|
2450
2488
|
* @see POST /v1/sites/{site}/integrations (operation `connectBillingPlatform`, scope `sites:write`)
|
|
@@ -2874,7 +2912,7 @@ var init_operations = __esm({
|
|
|
2874
2912
|
/**
|
|
2875
2913
|
* List due notices.
|
|
2876
2914
|
*
|
|
2877
|
-
* The legally required emails you owe subscribers (sites in "You send" mode, the default): kind, the subscriber (billing-platform ids), the legal window for the subscriber's state, the recommended send date, the
|
|
2915
|
+
* The legally required emails you owe subscribers (sites in "You send" mode, the default): kind, the subscriber (billing-platform ids), the legal window for the subscriber's state, the recommended send date, the email (ready to send once email.missing_fields is empty) and the BCC address. Default: everything not yet sent (upcoming, due, overdue), soonest first. Live keys list live subscribers' notices, test keys test-mode ones. Send each one on its send_date (inside its window), then call markNoticeSent (or BCC the notice's bcc address). Subscribe to the notice.due webhook to get each one on its send date.
|
|
2878
2916
|
*
|
|
2879
2917
|
* @see GET /v1/notices/due (operation `listDueNotices`, scope `notices:read`)
|
|
2880
2918
|
*/
|
|
@@ -2904,7 +2942,7 @@ var init_operations = __esm({
|
|
|
2904
2942
|
/**
|
|
2905
2943
|
* Retrieve a due notice.
|
|
2906
2944
|
*
|
|
2907
|
-
* One notice you owe a subscriber, with its window, recommended send date,
|
|
2945
|
+
* One notice you owe a subscriber, with its window, recommended send date, email (ready to send once email.missing_fields is empty), BCC address and send status.
|
|
2908
2946
|
*
|
|
2909
2947
|
* @param notice Unique identifier of the due notice (ntd_…).
|
|
2910
2948
|
* @see GET /v1/notices/{notice} (operation `retrieveDueNotice`, scope `notices:read`)
|
|
@@ -2972,7 +3010,7 @@ var init_operations = __esm({
|
|
|
2972
3010
|
/**
|
|
2973
3011
|
* Check Stripe Checkout Session params.
|
|
2974
3012
|
*
|
|
2975
|
-
*
|
|
3013
|
+
* Body: `{ params, merchant }` (or `{ params, site }`). `params` is exactly the object your code gives stripe.checkout.sessions.create; `merchant` adds merchant_name, cancel_url, and price/interval when line_items use a Price id. Checkbox models the Stripe-hosted page those params render and returns findings plus `correction`: a JSON Patch (RFC 6902) and the full corrected params (custom_text.submit with the renewal terms, consent_collection.terms_of_service = "required", custom_text.terms_of_service_acceptance as the consent label), verified by the engine. Stripe's own small grey styling keeps "clear and conspicuous" at needs_review on any Stripe-hosted page; also show the terms on your page before redirecting. Stateless and deterministic: nothing is stored, the same input on the same as_of date gives the same verdicts. Every verdict comes from the Checkbox rules engine over the legal catalog (federal ROSCA / EFTA plus the state automatic-renewal laws); the response carries rules_version, catalog_version and a disclaimer (not legal advice). Read `verdict`, then `next_action`: it tells you exactly what to change and to call the check again until verdict is pass.
|
|
2976
3014
|
*
|
|
2977
3015
|
* @see POST /v1/checks/stripe_checkout_session (operation `checkStripeCheckoutSession`, scope `checks:read`)
|
|
2978
3016
|
*/
|
|
@@ -3257,7 +3295,7 @@ var init_operations = __esm({
|
|
|
3257
3295
|
/**
|
|
3258
3296
|
* Store a billing test-mode key.
|
|
3259
3297
|
*
|
|
3260
|
-
* Stores the site's Stripe TEST-mode restricted key (rk_test_…), after checking it with Stripe the way the dashboard's Connect Stripe does. An agent may call this itself once the owner approved the setup; every store is logged on the site's setup timeline and Billing connection line, and the human can revoke it in one click. Live keys are refused (test_key_not_test_mode: a human adds live keys in the dashboard); a key whose Stripe account looks like another site's is refused with billing_account_mismatch
|
|
3298
|
+
* Stores the site's Stripe TEST-mode restricted key (rk_test_…), after checking it with Stripe the way the dashboard's Connect Stripe does. An agent may call this itself once the owner approved the setup; every store is logged on the site's setup timeline and Billing connection line, and the human can revoke it in one click. Live keys are refused (test_key_not_test_mode: a human adds live keys in the dashboard); the owner must have ticked "Let your agent store a Stripe test-mode key" on the approval page of the agent's sign-in or connection (else test_key_declined); a key whose Stripe account looks like another site's is refused with billing_account_mismatch (with confirm_account_mismatch: true the key is connected on hold until a person confirms the account in the dashboard; only a person can confirm it); secret keys (sk_test_…) are refused (test_key_not_restricted). On a test-mode site without a Stripe connection the key also becomes the site's Stripe connection: one key for checks, fixes (only after the human approves each) and test runs. The key's checklist: Customers: Write, Products: Read, Prices: Read, Coupons: Read, Subscriptions: Write, Test clocks: Write, Customer portal: Write, Checkout Sessions: Write, Payment Links: Write, Webhook Endpoints: Write. Replaces an existing key. Next: createTestRun.
|
|
3261
3299
|
*
|
|
3262
3300
|
* @param site Unique identifier of the site (site_…).
|
|
3263
3301
|
* @see POST /v1/sites/{site}/test_credentials (operation `createTestCredential`, scope `test_runs:write`)
|
|
@@ -3365,7 +3403,7 @@ var init_operations = __esm({
|
|
|
3365
3403
|
/**
|
|
3366
3404
|
* Start a lifecycle test run.
|
|
3367
3405
|
*
|
|
3368
|
-
* Starts a synthetic test subscription in TEST mode and returns it queued. Stripe runs mirror your checkout into a test-mode Checkout Session, subscribe a test customer (card 4242) whose email is a Checkbox inbox, wait for your acknowledgment, advance a test clock through every legal notice window before trial end and renewal, cancel online in the customer portal and wait for the confirmation; then delete the test clock. Usually takes 2
|
|
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.
|
|
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((
|
|
3809
|
+
sleep = (ms, signal) => new Promise((resolve10, reject) => {
|
|
3772
3810
|
if (signal?.aborted) return reject(signal.reason);
|
|
3773
|
-
const t = setTimeout(
|
|
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((
|
|
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", () =>
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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: "
|
|
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.
|
|
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 :
|
|
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
|
|
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
|
|
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
|
|
13009
|
+
const cache2 = /* @__PURE__ */ new Map();
|
|
12941
13010
|
return {
|
|
12942
13011
|
mode: "heuristic",
|
|
12943
13012
|
async analyze(text) {
|
|
12944
|
-
let a =
|
|
13013
|
+
let a = cache2.get(text);
|
|
12945
13014
|
if (!a) {
|
|
12946
13015
|
a = analyzeTerms(text);
|
|
12947
|
-
|
|
13016
|
+
cache2.set(text, a);
|
|
12948
13017
|
}
|
|
12949
13018
|
return a;
|
|
12950
13019
|
}
|
|
@@ -13103,6 +13172,14 @@ function offerSlice(c, g) {
|
|
|
13103
13172
|
links: keep(c.links)
|
|
13104
13173
|
};
|
|
13105
13174
|
}
|
|
13175
|
+
function offerCardName(text, cta, index) {
|
|
13176
|
+
const t = normalizeText(text).replace(/\s+/g, " ").trim();
|
|
13177
|
+
const money3 = /(?:[$€£]\s?\d[\d,]*(?:\.\d{1,2})?|\d[\d,]*(?:\.\d{1,2})?\s?(?:USD|EUR|GBP))(?:\s*(?:\/|per|a|an|every)\s*(?:month|mo|year|yr|week|day|quarter)\b)?/i.exec(t);
|
|
13178
|
+
const before = (money3 ? t.slice(0, money3.index) : "").replace(new RegExp(`^${cta.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "").replace(/[\s:·|–—-]+$/, "").trim();
|
|
13179
|
+
const heading = before && before.length <= 40 ? before : before ? before.split(" ").slice(0, 4).join(" ") : "";
|
|
13180
|
+
const price3 = money3?.[0].trim();
|
|
13181
|
+
return heading && price3 ? `${heading}, ${price3}` : heading || price3 || `card ${index + 1}`;
|
|
13182
|
+
}
|
|
13106
13183
|
function buildSignupView(capture, offer) {
|
|
13107
13184
|
const c = offer === void 0 ? capture : offerSlice(capture, offer);
|
|
13108
13185
|
const device = c.device === "mobile" ? "mobile" : "desktop";
|
|
@@ -13127,9 +13204,11 @@ function buildSignupView(capture, offer) {
|
|
|
13127
13204
|
[...c.disclosures ?? [], ...checkboxes, ...allLinks].filter(isVisible).map((e) => e.style?.fontSizePx ?? NaN)
|
|
13128
13205
|
);
|
|
13129
13206
|
const offerCta = offer === void 0 ? void 0 : capture.offers?.[offer]?.cta ?? primaryCta2?.text;
|
|
13207
|
+
const sameCta = offer !== void 0 && offerCta ? (capture.offers ?? []).filter((o) => normalizeText(o.cta) === normalizeText(offerCta)).length > 1 : false;
|
|
13208
|
+
const card = sameCta ? offerCardName(capture.offers[offer].text, offerCta, offer) : void 0;
|
|
13130
13209
|
return {
|
|
13131
13210
|
capture,
|
|
13132
|
-
label: offerCta ? `${captureLabel(c)} \u2014 plan button \u201C${normalizeText(offerCta).slice(0, 60)}\u201D` : captureLabel(c),
|
|
13211
|
+
label: offerCta ? `${captureLabel(c)} \u2014 plan button \u201C${normalizeText(offerCta).slice(0, 60)}\u201D${card ? ` in the card \u201C${card}\u201D` : ""}` : captureLabel(c),
|
|
13133
13212
|
...offer !== void 0 ? { offer: { index: offer, cta: normalizeText(offerCta ?? "").slice(0, 120) } } : {},
|
|
13134
13213
|
device,
|
|
13135
13214
|
viewport,
|
|
@@ -13783,7 +13862,7 @@ function remediationFor(checkId, platform) {
|
|
|
13783
13862
|
if (guide?.snippet) rem.snippet = { ...guide.snippet };
|
|
13784
13863
|
return rem;
|
|
13785
13864
|
}
|
|
13786
|
-
var DISCLOSURE_COPY_TEMPLATE, CONSENT_COPY_TEMPLATE, ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_REMINDER_COPY_TEMPLATE, RENEWAL_REMINDER_COPY_TEMPLATE, PRICE_CHANGE_COPY_TEMPLATE, ANNUAL_TERMS_COPY_TEMPLATE, CHARGE_NOTICE_COPY_TEMPLATE, CANCELLATION_CONFIRMATION_COPY_TEMPLATE, TERMS_CANCELLATION_COPY_TEMPLATE, TERMS_REFUND_COPY_TEMPLATE, TERMS_RENEWAL_COPY_TEMPLATE, PLACEHOLDER_STEP, CUSTOM_DISCLOSURE_HTML, CUSTOM_CONSENT_HTML, STRIPE_CHECKOUT_TS, STRIPE_ANNUAL_REMINDER_TS, STRIPE_CHARGE_NOTICE_TS, STRIPE_PORTAL_TS, STRIPE_ACK_WEBHOOK_TS, STRIPE_CANCEL_CONFIRMATION_TS, SHOPIFY_LIQUID, SHOPIFY_CHECKOUT_EXTENSION_TSX, CUSTOM_ACCOUNT_CANCEL_HTML, CUSTOM_ACK_EMAIL_HTML, VERIFY, VERIFY_TERMS, VERIFY_EMAIL, CB_CHECKOUT, CB_PORTAL, CB_EMAILS, CHARGEBEE_DISCLOSURE, CHARGEBEE_CONSENT, CHARGEBEE_ACK, CHARGEBEE_PORTAL, CHARGEBEE_TRIAL_REMINDER, CHARGEBEE_RENEWAL_REMINDER, CHARGEBEE_ANNUAL_TERMS, CHARGEBEE_CHARGE_NOTICE, RC_ACCOUNT_PAGES, RC_EMAILS, RECURLY_DISCLOSURE, RECURLY_CONSENT, RECURLY_ACK, RECURLY_PORTAL, RECURLY_TRIAL_REMINDER, RECURLY_RENEWAL_REMINDER, RECURLY_ANNUAL_TERMS, RECURLY_CHARGE_NOTICE, PD_RETAIN, PADDLE_DISCLOSURE, PADDLE_CONSENT, PADDLE_ACK, PADDLE_PORTAL, PADDLE_TRIAL_REMINDER, PADDLE_RENEWAL_REMINDER, PADDLE_ANNUAL_TERMS, PADDLE_CHARGE_NOTICE, STRIPE_DISCLOSURE, SHOPIFY_DISCLOSURE, RECHARGE_DISCLOSURE, CUSTOM_DISCLOSURE, DISCLOSURE_PLATFORMS, STRIPE_ACK, SHOPIFY_ACK, RECHARGE_ACK, CUSTOM_ACK, ACK_PLATFORMS, STRIPE_PORTAL, SHOPIFY_PORTAL, RECHARGE_PORTAL, CUSTOM_PORTAL, PORTAL_PLATFORMS, STRIPE_TRIAL_REMINDER, STRIPE_RENEWAL_REMINDER, STRIPE_ANNUAL_TERMS, STRIPE_CHARGE_NOTICE, SHOPIFY_ANNUAL_TERMS, SHOPIFY_CHARGE_NOTICE, RECHARGE_ANNUAL_TERMS, RECHARGE_CHARGE_NOTICE, CUSTOM_ANNUAL_TERMS, CUSTOM_CHARGE_NOTICE, SHOPIFY_REMINDER, RECHARGE_REMINDER, CUSTOM_REMINDER, SPECS, PLATFORM_LABELS, OFFER_PERMISSION_FIX, OFFER_QUESTION_STOP_FIX, OFFER_PERMISSION_SNIPPET, CANCEL_HANDLER_REMEDIATION, OFFER_PERMISSION_REMEDIATION;
|
|
13865
|
+
var DISCLOSURE_COPY_TEMPLATE, CONSENT_COPY_TEMPLATE, ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_REMINDER_COPY_TEMPLATE, RENEWAL_REMINDER_COPY_TEMPLATE, PRICE_CHANGE_COPY_TEMPLATE, ANNUAL_TERMS_COPY_TEMPLATE, CHARGE_NOTICE_COPY_TEMPLATE, CANCELLATION_CONFIRMATION_COPY_TEMPLATE, TERMS_CANCELLATION_COPY_TEMPLATE, TERMS_REFUND_COPY_TEMPLATE, TERMS_RENEWAL_COPY_TEMPLATE, PLACEHOLDER_STEP, CUSTOM_DISCLOSURE_HTML, CUSTOM_CONSENT_HTML, STRIPE_CHECKOUT_TS, STRIPE_ANNUAL_REMINDER_TS, STRIPE_CHARGE_NOTICE_TS, STRIPE_PORTAL_TS, STRIPE_ACK_WEBHOOK_TS, STRIPE_CANCEL_CONFIRMATION_TS, SHOPIFY_LIQUID, SHOPIFY_CHECKOUT_EXTENSION_TSX, CUSTOM_ACCOUNT_CANCEL_HTML, CUSTOM_ACK_EMAIL_HTML, VERIFY, VERIFY_TERMS, VERIFY_EMAIL, CB_CHECKOUT, CB_PORTAL, CB_EMAILS, CHARGEBEE_DISCLOSURE, CHARGEBEE_CONSENT, CHARGEBEE_ACK, CHARGEBEE_PORTAL, CHARGEBEE_TRIAL_REMINDER, CHARGEBEE_RENEWAL_REMINDER, CHARGEBEE_ANNUAL_TERMS, CHARGEBEE_CHARGE_NOTICE, RC_ACCOUNT_PAGES, RC_EMAILS, RECURLY_DISCLOSURE, RECURLY_CONSENT, RECURLY_ACK, RECURLY_PORTAL, RECURLY_TRIAL_REMINDER, RECURLY_RENEWAL_REMINDER, RECURLY_ANNUAL_TERMS, RECURLY_CHARGE_NOTICE, PD_RETAIN, PADDLE_DISCLOSURE, PADDLE_CONSENT_HTML, PADDLE_CONSENT, PADDLE_ACK, PADDLE_PORTAL, PADDLE_TRIAL_REMINDER, PADDLE_RENEWAL_REMINDER, PADDLE_ANNUAL_TERMS, PADDLE_CHARGE_NOTICE, STRIPE_DISCLOSURE, SHOPIFY_DISCLOSURE, RECHARGE_DISCLOSURE, CUSTOM_DISCLOSURE, DISCLOSURE_PLATFORMS, STRIPE_ACK, SHOPIFY_ACK, RECHARGE_ACK, CUSTOM_ACK, ACK_PLATFORMS, STRIPE_PORTAL, SHOPIFY_PORTAL, RECHARGE_PORTAL, CUSTOM_PORTAL, PORTAL_PLATFORMS, STRIPE_TRIAL_REMINDER, STRIPE_RENEWAL_REMINDER, STRIPE_ANNUAL_TERMS, STRIPE_CHARGE_NOTICE, SHOPIFY_ANNUAL_TERMS, SHOPIFY_CHARGE_NOTICE, RECHARGE_ANNUAL_TERMS, RECHARGE_CHARGE_NOTICE, CUSTOM_ANNUAL_TERMS, CUSTOM_CHARGE_NOTICE, SHOPIFY_REMINDER, RECHARGE_REMINDER, CUSTOM_REMINDER, SPECS, PLATFORM_LABELS, OFFER_PERMISSION_FIX, OFFER_QUESTION_STOP_FIX, OFFER_PERMISSION_SNIPPET, CANCEL_HANDLER_REMEDIATION, OFFER_PERMISSION_REMEDIATION;
|
|
13787
13866
|
var init_remediation = __esm({
|
|
13788
13867
|
"../rules/src/remediation.ts"() {
|
|
13789
13868
|
"use strict";
|
|
@@ -14099,13 +14178,32 @@ and click \u201CCancel subscription\u201D. You can cancel anytime online.</p>`;
|
|
|
14099
14178
|
],
|
|
14100
14179
|
snippet: { language: "html", code: CUSTOM_DISCLOSURE_HTML }
|
|
14101
14180
|
};
|
|
14181
|
+
PADDLE_CONSENT_HTML = `<!-- Replace your button that opens Paddle Checkout with this form (Paddle.js must be loaded and initialized). -->
|
|
14182
|
+
<form id="checkbox-paddle-checkout">
|
|
14183
|
+
<label style="display:flex;gap:8px;align-items:flex-start;font-size:14px;color:#1f2937;">
|
|
14184
|
+
<!-- Never add the "checked" attribute: consent must be an affirmative act -->
|
|
14185
|
+
<input type="checkbox" name="auto_renew_consent" value="yes" required>
|
|
14186
|
+
<span>I agree that my subscription will automatically renew every {interval} at {price} until I cancel.</span>
|
|
14187
|
+
</label>
|
|
14188
|
+
<button type="submit">Subscribe</button>
|
|
14189
|
+
</form>
|
|
14190
|
+
<script>
|
|
14191
|
+
document.getElementById('checkbox-paddle-checkout').addEventListener('submit', function (event) {
|
|
14192
|
+
event.preventDefault(); // the browser only gets here once the required box is ticked
|
|
14193
|
+
var label = this.querySelector('label span').textContent.trim();
|
|
14194
|
+
Paddle.Checkout.open({
|
|
14195
|
+
items: [{ priceId: 'pri_REPLACE_ME', quantity: 1 }],
|
|
14196
|
+
customData: { auto_renew_consent: { at: new Date().toISOString(), label: label } },
|
|
14197
|
+
});
|
|
14198
|
+
});
|
|
14199
|
+
</script>`;
|
|
14102
14200
|
PADDLE_CONSENT = {
|
|
14103
14201
|
steps: [
|
|
14104
14202
|
"Add a required, unchecked renewal-consent checkbox (the wording below) on your page, directly above the button that opens Paddle Checkout, and only open checkout once it is ticked.",
|
|
14105
|
-
"
|
|
14106
|
-
"
|
|
14203
|
+
'Paddle opens checkout from a button, not a form, so a "required" box alone blocks nothing: put the box and the button in one form (the snippet does) or keep the button disabled until the box is ticked.',
|
|
14204
|
+
"Save when it was ticked and the terms that were shown, so you can prove consent later: the snippet passes them in customData, which Paddle stores on the transaction. On Lemon Squeezy, pass them as checkout[custom][\u2026] on the checkout URL."
|
|
14107
14205
|
],
|
|
14108
|
-
snippet: { language: "html", code:
|
|
14206
|
+
snippet: { language: "html", code: PADDLE_CONSENT_HTML }
|
|
14109
14207
|
};
|
|
14110
14208
|
PADDLE_ACK = {
|
|
14111
14209
|
steps: [
|
|
@@ -15056,6 +15154,11 @@ function offerQuestionStop(flow) {
|
|
|
15056
15154
|
const last = [...flow.steps ?? []].reverse().find((s2) => s2.pageKind !== "account");
|
|
15057
15155
|
return last && OFFER_PERMISSION_RE.test(normalizeText(last.text)) ? last : void 0;
|
|
15058
15156
|
}
|
|
15157
|
+
function stuckHint(flow) {
|
|
15158
|
+
const blockers = flow.blockers ?? [];
|
|
15159
|
+
if (flow.completed || blockers.some((b) => /answered the cancel request with an error/i.test(b))) return "";
|
|
15160
|
+
return blockers.some((b) => /no further cancel option|not completed within \d+ steps/i.test(b)) ? `. ${CANCEL_STEPS_HINT}` : "";
|
|
15161
|
+
}
|
|
15059
15162
|
function offerQuestionStopResult(id, stop, refs2, data) {
|
|
15060
15163
|
return makeResult(
|
|
15061
15164
|
id,
|
|
@@ -15345,7 +15448,7 @@ function notRefreshedUnits(stale, fresh) {
|
|
|
15345
15448
|
};
|
|
15346
15449
|
});
|
|
15347
15450
|
}
|
|
15348
|
-
var CANCEL_MAX_STEPS, CANCEL_MAX_RETENTION_OFFERS, CANCEL_LINK_MIN_FONT_PX, CANCEL_LINK_MIN_CONTRAST, CANCEL_FLOW_MAX_AGE_DAYS, CLICK_TO_CANCEL_JURISDICTIONS, OFFER_PERMISSION_JURISDICTIONS, RETENTION_PAGE_RE, OFFER_PERMISSION_RE, ONLINE_ALTERNATIVE_RE, NO_CANCEL_ATTEMPT_MESSAGE, OBSTACLE_BLOCKER_RE, CONTACT_BLOCKER_RE, NOT_FOUND_BLOCKER_RE, RETENTION_BLOCKER_RE, TECHNICAL_BLOCKER_RE, CONTACT_TO_CANCEL_RE, CANCEL_CONTROL_RE, ORDER_ONLY_RE, MANAGE_RE, HOSTED_FLOW_NOTE, onlineAvailable, SUBSCRIPTION_CANCEL_LINK_RE, BILLING_ITEM_RE, NO_ACTIVE_SUBSCRIPTION_RE, FIND_RANK, mechanismFindable, flowFriction, CANCEL_CONFIRM_EMAIL_RE, confirmation, CANCELLATION_CHECKS;
|
|
15451
|
+
var CANCEL_MAX_STEPS, CANCEL_MAX_RETENTION_OFFERS, CANCEL_LINK_MIN_FONT_PX, CANCEL_LINK_MIN_CONTRAST, CANCEL_FLOW_MAX_AGE_DAYS, CLICK_TO_CANCEL_JURISDICTIONS, OFFER_PERMISSION_JURISDICTIONS, RETENTION_PAGE_RE, OFFER_PERMISSION_RE, ONLINE_ALTERNATIVE_RE, NO_CANCEL_ATTEMPT_MESSAGE, OBSTACLE_BLOCKER_RE, CONTACT_BLOCKER_RE, NOT_FOUND_BLOCKER_RE, RETENTION_BLOCKER_RE, TECHNICAL_BLOCKER_RE, CONTACT_TO_CANCEL_RE, CANCEL_CONTROL_RE, ORDER_ONLY_RE, MANAGE_RE, HOSTED_FLOW_NOTE, CANCEL_STEPS_HINT, onlineAvailable, SUBSCRIPTION_CANCEL_LINK_RE, BILLING_ITEM_RE, NO_ACTIVE_SUBSCRIPTION_RE, FIND_RANK, mechanismFindable, flowFriction, CANCEL_CONFIRM_EMAIL_RE, confirmation, CANCELLATION_CHECKS;
|
|
15349
15452
|
var init_cancellation = __esm({
|
|
15350
15453
|
"../rules/src/checks/cancellation.ts"() {
|
|
15351
15454
|
"use strict";
|
|
@@ -15379,6 +15482,7 @@ var init_cancellation = __esm({
|
|
|
15379
15482
|
ORDER_ONLY_RE = /\bcancel\s+(?:this\s+|my\s+)?order\b/i;
|
|
15380
15483
|
MANAGE_RE = /\b(?:manage\s+(?:my\s+|your\s+)?(?:subscriptions?|membership|plan|billing)|billing\s+portal|customer\s+portal|subscription\s+settings|my\s+subscriptions?|membership\s+settings|billing\s+(?:&|and)\s+plans?)\b/i;
|
|
15381
15484
|
HOSTED_FLOW_NOTE = "Checkbox-hosted cancellation page: subscribers confirm their email through a one-time link, then cancel in two clicks (choose the subscription, confirm), with at most one save offer shown behind a continuously displayed cancel button and an on-screen and emailed confirmation";
|
|
15485
|
+
CANCEL_STEPS_HINT = `If Checkbox took a wrong turn on your pages, list the clicks in the site's scan settings as cancel_steps (for example: click "Cancel membership", then click "No thanks, cancel") and re-check`;
|
|
15382
15486
|
onlineAvailable = {
|
|
15383
15487
|
id: "cancel.online_available",
|
|
15384
15488
|
usesText: false,
|
|
@@ -15485,10 +15589,10 @@ var init_cancellation = __esm({
|
|
|
15485
15589
|
return makeResult(
|
|
15486
15590
|
id,
|
|
15487
15591
|
"needs_review",
|
|
15488
|
-
`The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}, so online cancellation could not be confirmed`,
|
|
15592
|
+
`The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}, so online cancellation could not be confirmed${stuckHint(flow)}`,
|
|
15489
15593
|
refs2,
|
|
15490
15594
|
0.4,
|
|
15491
|
-
data
|
|
15595
|
+
stuckHint(flow) ? { ...data, suggestCancelSteps: true } : data
|
|
15492
15596
|
);
|
|
15493
15597
|
}
|
|
15494
15598
|
const offline = offlineOnlyCancellation(input);
|
|
@@ -15734,13 +15838,13 @@ var init_cancellation = __esm({
|
|
|
15734
15838
|
const offerStop = offerQuestionStop(flow);
|
|
15735
15839
|
if (offerStop) return offerQuestionStopResult(id, offerStop, refs2, data);
|
|
15736
15840
|
if (b.other.length) {
|
|
15737
|
-
return makeResult(id, "needs_review", `The cancellation flow reported: ${b.other.join("; ")}`, refs2, 0.6, data);
|
|
15841
|
+
return makeResult(id, "needs_review", `The cancellation flow reported: ${b.other.join("; ")}${stuckHint(flow)}`, refs2, 0.6, stuckHint(flow) ? { ...data, suggestCancelSteps: true } : data);
|
|
15738
15842
|
}
|
|
15739
15843
|
if (!flow.completed) {
|
|
15740
15844
|
return makeResult(
|
|
15741
15845
|
id,
|
|
15742
15846
|
"needs_review",
|
|
15743
|
-
`The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}; friction could not be fully assessed`,
|
|
15847
|
+
`The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}; friction could not be fully assessed${stuckHint(flow)}`,
|
|
15744
15848
|
refs2,
|
|
15745
15849
|
0.4,
|
|
15746
15850
|
data
|
|
@@ -15999,6 +16103,19 @@ var init_config2 = __esm({
|
|
|
15999
16103
|
}
|
|
16000
16104
|
return makeResult(id, "fail", `Self-serve cancellation is turned off in ${name}'s customer portal`, refs(input), 0.9, { ...data, cancelPath: "portal_only" });
|
|
16001
16105
|
}
|
|
16106
|
+
if (model.otherOnlineWorks) {
|
|
16107
|
+
const flow = input.ctx.cancellation;
|
|
16108
|
+
const works = inApp?.state === "works" && !!flow;
|
|
16109
|
+
const via = works ? "subscribers cancel online through your own page (a test cancellation completed; see \u201Ccancel online\u201D)" : model.hosted?.state === "offered" ? `subscribers cancel through the Checkbox hosted cancellation page (${model.hosted.url}); see \u201Ccancel online\u201D` : `your own page offers a cancel option (${inApp?.url}); online cancellation is judged on that page (see \u201Ccancel online\u201D), so the portal setting is not required`;
|
|
16110
|
+
return makeResult(
|
|
16111
|
+
id,
|
|
16112
|
+
"not_applicable",
|
|
16113
|
+
`Checkbox could not read whether self-serve cancellation is on in ${name}'s portal, but ${via}`,
|
|
16114
|
+
[...refs(input), ...works ? [flow.id] : []],
|
|
16115
|
+
works ? 0.75 : 0.6,
|
|
16116
|
+
{ ...data, verifiedBy: "cancel.online_available", ...works ? { cancellationId: flow.id } : { ownCancelPage: true }, ...inApp ? { cancelsInApp: true } : {} }
|
|
16117
|
+
);
|
|
16118
|
+
}
|
|
16002
16119
|
return makeResult(id, "needs_review", `Could not read whether self-serve cancellation is enabled in ${name}`, refs(input), 0.4, data);
|
|
16003
16120
|
}
|
|
16004
16121
|
};
|
|
@@ -16262,15 +16379,34 @@ function offlineOnlyCancelIn(text, cancelMethods) {
|
|
|
16262
16379
|
const online = cancelMethods.some((m) => !CONTACT_TO_CANCEL_RE.test(m.text) && !/@|\d{3}[\s.-]\d{4}|\b(?:call|phone|chat|e-?mail|contact|mail|reply)\b/i.test(m.text));
|
|
16263
16380
|
return online ? null : offline;
|
|
16264
16381
|
}
|
|
16382
|
+
function inForceAmount(e, events, fallbackCurrency = "usd") {
|
|
16383
|
+
const own = e.noticeTerms;
|
|
16384
|
+
if (own && typeof own.amount === "number" && own.amount > 0) return own.amount / 10 ** currencyDigitsOf(typeof own.currency === "string" ? own.currency : fallbackCurrency);
|
|
16385
|
+
const ref = typeof e.subscriptionRef === "string" ? e.subscriptionRef.trim() : "";
|
|
16386
|
+
const sent2 = toDate(e.receivedAt)?.getTime();
|
|
16387
|
+
if (!ref || sent2 === void 0) return void 0;
|
|
16388
|
+
let best;
|
|
16389
|
+
let bestAt = -Infinity;
|
|
16390
|
+
for (const ev of events) {
|
|
16391
|
+
if (ev.subscriptionRef !== ref || !CHARGE_AMOUNT_EVENTS.has(ev.type) || !(typeof ev.amount === "number" && ev.amount > 0)) continue;
|
|
16392
|
+
const at = toDate(ev.occurredAt)?.getTime();
|
|
16393
|
+
if (at === void 0 || at > sent2 + 36e5 || at < bestAt) continue;
|
|
16394
|
+
best = ev;
|
|
16395
|
+
bestAt = at;
|
|
16396
|
+
}
|
|
16397
|
+
return best ? best.amount / 10 ** currencyDigitsOf(best.currency ?? fallbackCurrency) : void 0;
|
|
16398
|
+
}
|
|
16265
16399
|
function noticeContent(e, opts = {}) {
|
|
16266
16400
|
const body = emailBody(e);
|
|
16267
16401
|
const a = analyzeTerms(body);
|
|
16268
16402
|
const quotes = {};
|
|
16269
16403
|
const wrong = {};
|
|
16270
16404
|
const plans = opts.plans ?? [];
|
|
16405
|
+
const inForce = opts.events ? inForceAmount(e, opts.events, plans[0]?.currency) : void 0;
|
|
16271
16406
|
const expected = [
|
|
16272
16407
|
...plans.map((p) => p.amount / 10 ** currencyDigitsOf(p.currency)),
|
|
16273
|
-
...opts.extraAmounts ?? []
|
|
16408
|
+
...opts.extraAmounts ?? [],
|
|
16409
|
+
...inForce !== void 0 ? [inForce] : []
|
|
16274
16410
|
];
|
|
16275
16411
|
const amount = a.recurringPrices[0]?.money ?? positiveAmounts(a)[0];
|
|
16276
16412
|
const stated = positiveAmounts(a).filter((m) => m.amount !== null);
|
|
@@ -16335,7 +16471,7 @@ function contentFixFromText(checkId, text) {
|
|
|
16335
16471
|
const m = new RegExp(`^the ${noun} \\(.*?\\) .*? does not state (.+?)(?: correctly \\(| \u2014 )`, "i").exec(text.trim());
|
|
16336
16472
|
return m ? `Add ${m[1]} to the ${noun}; Checkbox's template has every required line.` : void 0;
|
|
16337
16473
|
}
|
|
16338
|
-
var ELEMENT_LABELS, CHARGE_FOLLOWS_RE, DATE_TAG_RE, TRIAL_END_WHEN_RE, PRODUCT_RE, CONTACT_RE, CANCEL_URL_RE, NOTICE_NOUN;
|
|
16474
|
+
var ELEMENT_LABELS, CHARGE_FOLLOWS_RE, DATE_TAG_RE, TRIAL_END_WHEN_RE, PRODUCT_RE, CONTACT_RE, CANCEL_URL_RE, CHARGE_AMOUNT_EVENTS, NOTICE_NOUN;
|
|
16339
16475
|
var init_notice_content = __esm({
|
|
16340
16476
|
"../rules/src/checks/notice-content.ts"() {
|
|
16341
16477
|
"use strict";
|
|
@@ -16361,6 +16497,7 @@ var init_notice_content = __esm({
|
|
|
16361
16497
|
PRODUCT_RE = /\b(?:[Yy]our|[Tt]he|[Tt]his)\s+(?:[A-Z0-9][\w&'’.+-]*\s+){1,5}(?:[Ss]ubscription|[Mm]embership|[Pp]lan|[Bb]ox|[Ss]ervice|[Cc]lub|[Pp]ackage)\b|\b(?:[Pp]lan|[Pp]roduct|[Ss]ubscription|[Mm]embership)\s*:\s*\S[^\n]{1,60}/;
|
|
16362
16498
|
CONTACT_RE = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+|(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b|\bcontact\s+(?:us|support|our\s+(?:support|customer|help))\b[^\n]{0,80}(?:https?:\/\/\S+|@)|\b(?:support|help)\s+(?:center|centre)\s*:?\s*https?:\/\/\S+/i;
|
|
16363
16499
|
CANCEL_URL_RE = /https?:\/\/\S*(?:cancel|billing\.stripe\.com|customer[-_]?portal|\/portal|manage|subscription|account)\S*/i;
|
|
16500
|
+
CHARGE_AMOUNT_EVENTS = /* @__PURE__ */ new Set(["subscription_created", "subscription_renewed", "upcoming_renewal", "trial_will_end"]);
|
|
16364
16501
|
NOTICE_NOUN = {
|
|
16365
16502
|
"reminder.trial_ending": "trial-ending reminder",
|
|
16366
16503
|
"reminder.annual_renewal": "renewal reminder",
|
|
@@ -16430,6 +16567,64 @@ var init_email_plan = __esm({
|
|
|
16430
16567
|
}
|
|
16431
16568
|
});
|
|
16432
16569
|
|
|
16570
|
+
// ../rules/src/checks/forwarded.ts
|
|
16571
|
+
function headerAddress(block, name) {
|
|
16572
|
+
const m = new RegExp(String.raw`^\s*[*>]*\s*${name}\s*:\s*\*?\s*(.+)$`, "im").exec(block);
|
|
16573
|
+
if (!m) return void 0;
|
|
16574
|
+
const a = ADDRESS_RE.exec(m[1]);
|
|
16575
|
+
return a ? a[0].toLowerCase().replace(/[.>]+$/, "") : void 0;
|
|
16576
|
+
}
|
|
16577
|
+
function forwardedInfo(e) {
|
|
16578
|
+
const hit = cache.get(e);
|
|
16579
|
+
if (hit) return hit;
|
|
16580
|
+
const text = e.text ?? "";
|
|
16581
|
+
const marker = FWD_MARKER_RE.exec(text);
|
|
16582
|
+
const forwarded = !!marker || FWD_SUBJECT_RE.test(e.subject ?? "");
|
|
16583
|
+
const info = { forwarded };
|
|
16584
|
+
if (forwarded) {
|
|
16585
|
+
const start = marker ? marker.index : 0;
|
|
16586
|
+
const block = text.slice(start, start + 1200);
|
|
16587
|
+
const from = headerAddress(block, "From");
|
|
16588
|
+
const to = headerAddress(block, "To");
|
|
16589
|
+
if (from) info.originalFrom = from;
|
|
16590
|
+
if (to) info.originalTo = to;
|
|
16591
|
+
}
|
|
16592
|
+
cache.set(e, info);
|
|
16593
|
+
return info;
|
|
16594
|
+
}
|
|
16595
|
+
function domainOf(address) {
|
|
16596
|
+
const m = /@([^\s<>"]+)/.exec(address ?? "");
|
|
16597
|
+
return m ? m[1].toLowerCase().replace(/[>.]+$/, "") : "";
|
|
16598
|
+
}
|
|
16599
|
+
function isMorDomain(domain) {
|
|
16600
|
+
return !!domain && MERCHANT_OF_RECORD_DOMAINS.some((d) => domain === d || domain.endsWith(`.${d}`));
|
|
16601
|
+
}
|
|
16602
|
+
function isMerchantOfRecordEmail(e) {
|
|
16603
|
+
return isMorDomain(domainOf(e.from)) || isMorDomain(domainOf(forwardedInfo(e).originalFrom));
|
|
16604
|
+
}
|
|
16605
|
+
function inboundDomain() {
|
|
16606
|
+
const env2 = typeof process !== "undefined" ? process.env.INBOUND_EMAIL_DOMAIN : void 0;
|
|
16607
|
+
return (env2 ?? "").trim().replace(/^@/, "").replace(/\.$/, "").toLowerCase() || "inbound.subcomply.localhost";
|
|
16608
|
+
}
|
|
16609
|
+
function sentStraightToCheckbox(e) {
|
|
16610
|
+
if (forwardedInfo(e).originalTo) return false;
|
|
16611
|
+
if (e.scanId || e.relatedSignupAt || e.subscriptionRef || e.testRun) return false;
|
|
16612
|
+
const addresses = (e.to ?? "").match(/[^\s<>,"]+@[^\s<>,"]+/g) ?? [];
|
|
16613
|
+
const domain = inboundDomain();
|
|
16614
|
+
return addresses.length > 0 && addresses.every((a) => domainOf(a) === domain);
|
|
16615
|
+
}
|
|
16616
|
+
var FWD_SUBJECT_RE, FWD_MARKER_RE, ADDRESS_RE, cache, MERCHANT_OF_RECORD_DOMAINS;
|
|
16617
|
+
var init_forwarded = __esm({
|
|
16618
|
+
"../rules/src/checks/forwarded.ts"() {
|
|
16619
|
+
"use strict";
|
|
16620
|
+
FWD_SUBJECT_RE = /^\s*(?:(?:re|aw|sv)\s*:\s*)*(?:fwd?|fw|wg|tr)\s*:/i;
|
|
16621
|
+
FWD_MARKER_RE = /(?:-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message\s*:|-{2,}\s*Original Message\s*-{2,}|-{2,}\s*Forwarded Message\s*-{2,}|^\s*_{8,}\s*$\s*^\s*From\s*:)/im;
|
|
16622
|
+
ADDRESS_RE = /[^\s<>,"':;()[\]]+@[^\s<>,"':;()[\]]+\.[a-z]{2,}/i;
|
|
16623
|
+
cache = /* @__PURE__ */ new WeakMap();
|
|
16624
|
+
MERCHANT_OF_RECORD_DOMAINS = ["paddle.com", "paddle.net", "lemonsqueezy.com", "fastspring.com"];
|
|
16625
|
+
}
|
|
16626
|
+
});
|
|
16627
|
+
|
|
16433
16628
|
// ../rules/src/checks/confirmation.ts
|
|
16434
16629
|
function isPostSignupEmail(e) {
|
|
16435
16630
|
if (e.kind !== "other" || !e.scanId || !e.relatedSignupAt) return false;
|
|
@@ -16442,10 +16637,12 @@ function findAcknowledgments(emails) {
|
|
|
16442
16637
|
if (cached) return cached;
|
|
16443
16638
|
const acks = [];
|
|
16444
16639
|
const receipts = [];
|
|
16445
|
-
const
|
|
16640
|
+
const explicit = emails.filter((e) => e.kind === "acknowledgment");
|
|
16641
|
+
const acknowledged = new Set(explicit.map((e) => recipientOf(e)).filter((r) => r !== ""));
|
|
16446
16642
|
for (const e of emails) {
|
|
16447
16643
|
if (e.kind === "acknowledgment") acks.push(e);
|
|
16448
16644
|
else if (acknowledged.has(recipientOf(e))) continue;
|
|
16645
|
+
else if (explicit.length && e.kind === "receipt" && (forwardedInfo(e).forwarded || isMerchantOfRecordEmail(e) || sentStraightToCheckbox(e))) continue;
|
|
16449
16646
|
else if (e.kind === "receipt" || isPostSignupEmail(e)) {
|
|
16450
16647
|
(looksLikeSubscriptionTerms(analyzeTerms(emailBody(e))) ? acks : receipts).push(e);
|
|
16451
16648
|
}
|
|
@@ -16455,6 +16652,8 @@ function findAcknowledgments(emails) {
|
|
|
16455
16652
|
return set;
|
|
16456
16653
|
}
|
|
16457
16654
|
function recipientOf(e) {
|
|
16655
|
+
const original = forwardedInfo(e).originalTo;
|
|
16656
|
+
if (original) return original;
|
|
16458
16657
|
const m = /[^\s<>,"]+@[^\s<>,"]+/.exec(e.to ?? "");
|
|
16459
16658
|
return m ? m[0].toLowerCase() : "";
|
|
16460
16659
|
}
|
|
@@ -16664,6 +16863,7 @@ var init_confirmation = __esm({
|
|
|
16664
16863
|
init_intro_terms();
|
|
16665
16864
|
init_offer();
|
|
16666
16865
|
init_email_plan();
|
|
16866
|
+
init_forwarded();
|
|
16667
16867
|
ACK_MAX_DELAY_HOURS = 24;
|
|
16668
16868
|
NO_ACK_EMAIL_MESSAGE = "No acknowledgment email captured yet \u2014 BCC your sign-up confirmation email to your Checkbox inbound address (see Install), or add a test purchase to your scan settings so our shopper inbox receives it.";
|
|
16669
16869
|
ackCache = /* @__PURE__ */ new WeakMap();
|
|
@@ -18280,6 +18480,17 @@ function requiredFor(reqs) {
|
|
|
18280
18480
|
if (!out.size) for (const el of CHARGE_NOTICE_REQUIRED) out.add(el);
|
|
18281
18481
|
return [...out];
|
|
18282
18482
|
}
|
|
18483
|
+
function chargeGapPhrase(content, gaps, expected) {
|
|
18484
|
+
const wrong = gaps.filter((g) => content.wrong?.[g]);
|
|
18485
|
+
const absent = gaps.filter((g) => !content.wrong?.[g]);
|
|
18486
|
+
const parts = [];
|
|
18487
|
+
if (absent.length) parts.push(`do not state ${describeMissing(absent)}`);
|
|
18488
|
+
for (const g of wrong) {
|
|
18489
|
+
const said = content.wrong[g];
|
|
18490
|
+
parts.push(g === "amount" ? `states ${ELEMENT_LABELS[g]} as \u201C${said}\u201D${expected ? `, but ${expected}` : ""}` : `states ${ELEMENT_LABELS[g]} as \u201C${said}\u201D, which does not count`);
|
|
18491
|
+
}
|
|
18492
|
+
return parts.join("; ");
|
|
18493
|
+
}
|
|
18283
18494
|
async function runChargeNotice(input) {
|
|
18284
18495
|
const id = "reminder.charge_notice";
|
|
18285
18496
|
const configRefs = input.data.configRef ? [input.data.configRef] : [];
|
|
@@ -18307,6 +18518,12 @@ async function runChargeNotice(input) {
|
|
|
18307
18518
|
}
|
|
18308
18519
|
return c;
|
|
18309
18520
|
};
|
|
18521
|
+
const chargeExpected = (e) => {
|
|
18522
|
+
const own = inForceAmount(e, input.ctx.events ?? [], chargePlanOpts.plans[0]?.currency);
|
|
18523
|
+
const cur = (chargePlanOpts.plans[0]?.currency ?? "usd").toUpperCase();
|
|
18524
|
+
if (own !== void 0) return `this subscription\u2019s charge is ${own.toFixed(2)} ${cur}`;
|
|
18525
|
+
return chargePlanOpts.plans.length ? `the plan is ${chargePlanOpts.plans.map((p) => `${planAmount(p).toFixed(2)} ${p.currency.toUpperCase()} ${planEvery(p)}`).join(", ")}` : "";
|
|
18526
|
+
};
|
|
18310
18527
|
const counted = judged.filter((j) => j.state === "matched" || j.state === "partial" || j.state === "outside_window" || j.state === "missing");
|
|
18311
18528
|
const matched = counted.filter((j) => j.state === "matched");
|
|
18312
18529
|
const partial = counted.filter((j) => j.state === "partial");
|
|
@@ -18338,10 +18555,12 @@ async function runChargeNotice(input) {
|
|
|
18338
18555
|
return makeResult(
|
|
18339
18556
|
id,
|
|
18340
18557
|
"fail",
|
|
18341
|
-
`${incompleteMatched.length} of ${matched.length} charge notice(s)
|
|
18558
|
+
`${incompleteMatched.length} of ${matched.length} charge notice(s) ${chargeGapPhrase(content(x.j.email), x.missing, chargeExpected(x.j.email))} (e.g. ${emailLabel(x.j.email)})`,
|
|
18342
18559
|
[...refs2(incompleteMatched.map((i) => i.j)), ...refs2(missing), ...refs2(outside)],
|
|
18343
18560
|
0.75,
|
|
18344
|
-
|
|
18561
|
+
// Round 29 (pilot 28 item 11): when every failing notice is Checkbox Notices' own send, the plan fixes Checkbox's
|
|
18562
|
+
// template and settings; it never asks the merchant's agent to change an email it does not send.
|
|
18563
|
+
incompleteMatched.every((x2) => isCheckboxSentEmail(x2.j.email)) && !missing.length && !outside.length ? { ...data, contentByCheckbox: true } : data
|
|
18345
18564
|
);
|
|
18346
18565
|
}
|
|
18347
18566
|
if (outside.length) {
|
|
@@ -18529,7 +18748,12 @@ async function overViews(checkId, input, headlines, evaluateView) {
|
|
|
18529
18748
|
for (const view of views) outcomes.push({ view, unit: await evaluateView(view) });
|
|
18530
18749
|
const failing = outcomes.filter((o) => o.unit.status === "fail");
|
|
18531
18750
|
const introOnly = checkId === "signup.disclosure.price" && failing.length > 0 && failing.every((o) => typeof o.unit.meta?.introFix === "string");
|
|
18532
|
-
|
|
18751
|
+
const mismatchOnly = checkId === "signup.disclosure.price" && !introOnly && failing.length > 0 && failing.every((o) => o.unit.meta?.priceMismatch === true);
|
|
18752
|
+
return aggregateViews(
|
|
18753
|
+
checkId,
|
|
18754
|
+
outcomes,
|
|
18755
|
+
introOnly ? { ...headlines, fail: "The introductory offer next to the purchase button is missing or misstated" } : mismatchOnly ? { ...headlines, fail: "The recurring charge stated next to the purchase button is not what the plan charges" } : headlines
|
|
18756
|
+
);
|
|
18533
18757
|
}
|
|
18534
18758
|
function pageAnalysis(view) {
|
|
18535
18759
|
const entry = heuristicCache.get(view) ?? {};
|
|
@@ -18821,6 +19045,12 @@ function introPriceUnit(u, view, plans, promosDeclared = false) {
|
|
|
18821
19045
|
{ quote: said, meta: { ...u.meta ?? {}, priceMismatch: true, introPriceMismatch: true, introFix: introFixLine(p.plan) } }
|
|
18822
19046
|
);
|
|
18823
19047
|
}
|
|
19048
|
+
function setOffAdvice(sizePx, contrast, weight, ref) {
|
|
19049
|
+
const refWeight = ref.fontWeight ?? 400;
|
|
19050
|
+
const larger = Math.ceil(ref.fontSizePx * SET_OFF_MIN_SIZE_RATIO);
|
|
19051
|
+
const ways = [`${larger}px or larger (the surrounding text is about ${ref.fontSizePx}px)`, ...refWeight < 600 ? ["semibold or bold (weight 600+; the surrounding text is weight " + refWeight + ")"] : [], "a contrasting colour", "a border or shaded box (confirm by eye: borders and symbols cannot be measured from a capture)"];
|
|
19052
|
+
return `the renewal terms are legible (${sizePx}px, weight ${weight}${contrast !== null ? `, ${contrast}:1 contrast` : ""}) but look like the surrounding text (about ${ref.fontSizePx}px, weight ${refWeight}${refWeight >= 600 ? ": already bold, so a bolder weight cannot set them apart" : ""}, similar colour) \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border. Any one of these would: ${ways.join("; ")}`;
|
|
19053
|
+
}
|
|
18824
19054
|
function trialObservedOutsideCheckout(input) {
|
|
18825
19055
|
const ctx = input.ctx;
|
|
18826
19056
|
return [...ctx.config?.plans ?? [], ...ctx.referencePlans ?? []].some((p) => (p.trialDays ?? 0) > 0) || (ctx.events ?? []).some((e) => e.type === "trial_will_end" || (e.trialDays ?? 0) > 0) || input.data.emails.some((e) => e.kind === "trial_ending");
|
|
@@ -19767,7 +19997,7 @@ ${view.pageText}`;
|
|
|
19767
19997
|
if (setOff && !setOff.differentiators.length) {
|
|
19768
19998
|
return unit(
|
|
19769
19999
|
"needs_review",
|
|
19770
|
-
setOff.referenceUnknown ? `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast), but there is no surrounding text to show they are set apart from it \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border` :
|
|
20000
|
+
setOff.referenceUnknown ? `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast), but there is no surrounding text to show they are set apart from it \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border` : setOffAdvice(best.a.fontSizePx, best.a.contrastRatio, best.el.style?.fontWeight ?? 400, setOff.reference),
|
|
19771
20001
|
0.55,
|
|
19772
20002
|
{ meta: { ...meta, ...setOffMeta }, quote: q2 }
|
|
19773
20003
|
);
|
|
@@ -20472,7 +20702,7 @@ var NOTICES_TEST_MODE_NOTE, NOTICE_KIND_CHECKS, NOT_SEEN_RE;
|
|
|
20472
20702
|
var init_notices_on = __esm({
|
|
20473
20703
|
"../rules/src/checks/notices-on.ts"() {
|
|
20474
20704
|
"use strict";
|
|
20475
|
-
NOTICES_TEST_MODE_NOTE = "Test mode:
|
|
20705
|
+
NOTICES_TEST_MODE_NOTE = "Test mode: emails for a Stripe test customer go to you as a marked \u201CTest mode copy\u201D (and to the test-run inbox during a test run), never to the test customer. Use 'Send me a test' or a test run to see it work.";
|
|
20476
20706
|
NOTICE_KIND_CHECKS = {
|
|
20477
20707
|
acknowledgment: ["confirmation.email.sent", "confirmation.email.terms", "confirmation.email.cancellation_instructions", "confirmation.email.trial_terms"],
|
|
20478
20708
|
trial_ending: ["reminder.trial_ending", "config.trial_reminder_enabled"],
|
|
@@ -20623,9 +20853,9 @@ function limiter(max) {
|
|
|
20623
20853
|
run2();
|
|
20624
20854
|
}
|
|
20625
20855
|
};
|
|
20626
|
-
return async (fn) => new Promise((
|
|
20856
|
+
return async (fn) => new Promise((resolve10, reject) => {
|
|
20627
20857
|
queue.push(() => {
|
|
20628
|
-
fn().then(
|
|
20858
|
+
fn().then(resolve10, reject).finally(() => {
|
|
20629
20859
|
active--;
|
|
20630
20860
|
next();
|
|
20631
20861
|
});
|
|
@@ -22016,6 +22246,9 @@ function planFrequencyFix(c) {
|
|
|
22016
22246
|
const every = /\bplans? (?:is|are) billed (every [a-z0-9 ]+?)(?= \/ | —|[.;,]|$)/i.exec(c.detail)?.[1];
|
|
22017
22247
|
return every ? `State how often it is charged ("${every}").` : void 0;
|
|
22018
22248
|
}
|
|
22249
|
+
function billingUnread(findings) {
|
|
22250
|
+
return findings.some((f) => String(f.checkId).startsWith("config.") && /^No billing platform (?:connected|settings to read)/i.test(f.detail ?? ""));
|
|
22251
|
+
}
|
|
22019
22252
|
function rollup(items, opts = {}) {
|
|
22020
22253
|
const counts = { needs_fix: 0, your_call: 0, pending_live_check: 0, verified_live: 0, compliant: 0, not_applicable: 0, not_set_up: 0, not_checked: 0 };
|
|
22021
22254
|
const pending = [];
|
|
@@ -22059,7 +22292,8 @@ function rollup(items, opts = {}) {
|
|
|
22059
22292
|
label = `${counts.not_checked} not checked yet${counts.pending_live_check ? ` \xB7 ${plural2(counts.pending_live_check, "live check", "live checks")} pending` : ""}`;
|
|
22060
22293
|
} else {
|
|
22061
22294
|
status = "compliant";
|
|
22062
|
-
|
|
22295
|
+
const pass = opts.billingUnread ? CHECKS_PASS_UNREAD_LABEL : "Checks pass";
|
|
22296
|
+
label = counts.pending_live_check ? `${pass} \xB7 ${plural2(counts.pending_live_check, "live check", "live checks")} pending` : counts.verified_live ? `${pass} \xB7 verified live` : pass;
|
|
22063
22297
|
}
|
|
22064
22298
|
return { status, label, counts, pending, suggestions, ...opts.groupOf ? { byTask: Object.fromEntries(groups) } : {} };
|
|
22065
22299
|
}
|
|
@@ -22092,7 +22326,7 @@ function classifyFindings(findings) {
|
|
|
22092
22326
|
info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
|
|
22093
22327
|
}));
|
|
22094
22328
|
}
|
|
22095
|
-
var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, FAIL_FIX, STATUS_RANK;
|
|
22329
|
+
var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
|
|
22096
22330
|
var init_status = __esm({
|
|
22097
22331
|
"../rules/src/status.ts"() {
|
|
22098
22332
|
"use strict";
|
|
@@ -22317,7 +22551,7 @@ var init_status = __esm({
|
|
|
22317
22551
|
)
|
|
22318
22552
|
})
|
|
22319
22553
|
},
|
|
22320
|
-
{ code: "consent_implied", category: "content", when: has(/consent is implied by clicking/), build: () => ({ status: "needs_fix", fix: '
|
|
22554
|
+
{ code: "consent_implied", category: "content", when: has(/consent is implied by clicking/), build: () => ({ status: "needs_fix", fix: 'Ask for a separate yes to the renewal: usually one unchecked box, "I agree to the automatic renewal terms above".' }) },
|
|
22321
22555
|
{ code: "consent_general_terms", category: "content", when: has(/covers general terms, not the/), build: () => ({ status: "needs_fix", fix: 'Make the checkbox about the renewal: "I agree to the automatic renewal terms above."' }) },
|
|
22322
22556
|
// Round 23 (pilot 22 nontech-founder done blocker): "does not appear to be required" means the capture could not see
|
|
22323
22557
|
// whether the box is enforced (a script may block the purchase, as Stripe's own page does). A person who can try it
|
|
@@ -22760,7 +22994,7 @@ var init_status = __esm({
|
|
|
22760
22994
|
"signup.disclosure.term_length": 'Say how often it renews ("renews every month until you cancel").',
|
|
22761
22995
|
"signup.disclosure.proximity": "Put the renewal line under the price, before the card and email fields and the pay button.",
|
|
22762
22996
|
"signup.disclosure.conspicuous": "Show the renewal line at body size, semibold, in your normal text colour.",
|
|
22763
|
-
"signup.consent.affirmative": '
|
|
22997
|
+
"signup.consent.affirmative": 'Ask for a separate yes to the renewal: usually one unchecked box, "I agree to the automatic renewal terms above".',
|
|
22764
22998
|
"signup.consent.not_prechecked": "Leave the consent checkbox unchecked by default.",
|
|
22765
22999
|
"confirmation.email.sent": "Send an acknowledgment email right after sign-up (Checkbox has a template).",
|
|
22766
23000
|
"confirmation.email.terms": "Restate the renewal terms (price, frequency, until cancelled) in the acknowledgment.",
|
|
@@ -22781,6 +23015,7 @@ var init_status = __esm({
|
|
|
22781
23015
|
"terms.refund_policy_consistent": "Make the refund policy match the money-back promise on your site.",
|
|
22782
23016
|
"terms.renewal_terms_present": "Add a short automatic-renewal section to your Terms."
|
|
22783
23017
|
};
|
|
23018
|
+
CHECKS_PASS_UNREAD_LABEL = "Checks pass on what we can see";
|
|
22784
23019
|
STATUS_RANK = { needs_fix: 0, not_set_up: 1, your_call: 2, not_checked: 3, pending_live_check: 4, verified_live: 5, compliant: 6, not_applicable: 7 };
|
|
22785
23020
|
}
|
|
22786
23021
|
});
|
|
@@ -23140,7 +23375,7 @@ async function runEvaluationInScope(input, opts) {
|
|
|
23140
23375
|
}
|
|
23141
23376
|
const classified = classifyFindings(findings);
|
|
23142
23377
|
for (const { finding, info } of classified) finding.compliance = info;
|
|
23143
|
-
const complianceRollup = rollup(classified.map(({ finding, info }) => ({ checkId: finding.checkId, info })), { groupOf: taskGroupOf });
|
|
23378
|
+
const complianceRollup = rollup(classified.map(({ finding, info }) => ({ checkId: finding.checkId, info })), { groupOf: taskGroupOf, billingUnread: billingUnread(findings) });
|
|
23144
23379
|
const { score, insurable } = scoreFindings(findings);
|
|
23145
23380
|
const summary = { pass: 0, fail: 0, needs_review: 0, not_applicable: 0 };
|
|
23146
23381
|
for (const f of findings) summary[f.status]++;
|
|
@@ -24488,16 +24723,24 @@ function readParams(params, merchant, kind, asOf) {
|
|
|
24488
24723
|
throw new PreflightInputError("The billing interval is unknown: pass line_items[].price_data.recurring.interval or merchant.interval", "merchant.interval");
|
|
24489
24724
|
}
|
|
24490
24725
|
const plan2 = resolvePlan(merged, "merchant.");
|
|
24491
|
-
if (coupon && merchantIntro &&
|
|
24726
|
+
if (coupon && merchantIntro && plan2.introAmount !== void 0) {
|
|
24492
24727
|
const pct = typeof coupon.percent_off === "number" ? coupon.percent_off : void 0;
|
|
24493
24728
|
const off = typeof coupon.amount_off === "number" ? coupon.amount_off : void 0;
|
|
24494
24729
|
const fromCoupon = pct !== void 0 ? Math.round(plan2.amount * (1 - pct / 100)) : off !== void 0 ? plan2.amount - off : void 0;
|
|
24495
|
-
|
|
24730
|
+
const months = plan2.interval === "year" ? 12 * plan2.intervalCount : plan2.interval === "month" ? plan2.intervalCount : void 0;
|
|
24731
|
+
const couponPeriods = coupon.duration === "forever" ? Infinity : coupon.duration === "once" ? 1 : coupon.duration === "repeating" && typeof coupon.duration_in_months === "number" && months ? Math.max(1, Math.ceil(coupon.duration_in_months / months)) : void 0;
|
|
24732
|
+
const declaredPeriods = plan2.introPeriods ?? 1;
|
|
24733
|
+
const amountDiffers = fromCoupon !== void 0 && Math.abs(fromCoupon - plan2.introAmount) >= 1;
|
|
24734
|
+
const durationDiffers = couponPeriods !== void 0 && couponPeriods !== declaredPeriods;
|
|
24735
|
+
if (amountDiffers || durationDiffers) {
|
|
24496
24736
|
const id = typeof coupon.id === "string" ? `coupon ${coupon.id}` : "the coupon";
|
|
24737
|
+
const per = (n) => n === Infinity ? "every billing period (forever)" : n === 1 ? "the first billing period" : `the first ${n} billing periods`;
|
|
24738
|
+
const couponSays = `${pct !== void 0 ? `${pct}% off` : off !== void 0 ? `${formatMoney(off, plan2.currency)} off` : "a discount"}${fromCoupon !== void 0 ? ` (${formatMoney(fromCoupon, plan2.currency)})` : ""} for ${couponPeriods !== void 0 ? per(couponPeriods) : `an unknown duration (${String(coupon.duration ?? "not given")})`}`;
|
|
24739
|
+
const declaredSays = `${formatMoney(plan2.introAmount, plan2.currency)} for ${per(declaredPeriods)}`;
|
|
24497
24740
|
caveats.push({
|
|
24498
24741
|
checks: ["signup.disclosure.price"],
|
|
24499
24742
|
status: "needs_review",
|
|
24500
|
-
reason: `discounts apply ${id}
|
|
24743
|
+
reason: `discounts apply ${id}, which gives ${couponSays}, but the plan's declared intro is ${declaredSays}: the coupon or the declared intro is wrong \u2014 make them the same, then state that promo`,
|
|
24501
24744
|
param: "params.discounts"
|
|
24502
24745
|
});
|
|
24503
24746
|
}
|
|
@@ -25851,7 +26094,7 @@ __export(check_exports, {
|
|
|
25851
26094
|
default: () => check_default
|
|
25852
26095
|
});
|
|
25853
26096
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
25854
|
-
import { resolve } from "node:path";
|
|
26097
|
+
import { resolve as resolve2 } from "node:path";
|
|
25855
26098
|
function operationFor(endpoint) {
|
|
25856
26099
|
const op = Object.values(OPERATIONS).find((o) => o.httpMethod === "POST" && o.path === endpoint);
|
|
25857
26100
|
if (!op) throw new Error(`No API operation for POST ${endpoint}`);
|
|
@@ -25860,7 +26103,7 @@ function operationFor(endpoint) {
|
|
|
25860
26103
|
async function readInput(ctx, file) {
|
|
25861
26104
|
if (file === "-") return ctx.readStdin();
|
|
25862
26105
|
try {
|
|
25863
|
-
return readFileSync3(
|
|
26106
|
+
return readFileSync3(resolve2(ctx.cwd, file), "utf8");
|
|
25864
26107
|
} catch (err) {
|
|
25865
26108
|
throw new UsageError(`Cannot read ${file}: ${err.code ?? err.message}`);
|
|
25866
26109
|
}
|
|
@@ -26148,7 +26391,7 @@ var init_check = __esm({
|
|
|
26148
26391
|
|
|
26149
26392
|
// src/ci/config.ts
|
|
26150
26393
|
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
|
|
26151
|
-
import { dirname as
|
|
26394
|
+
import { dirname as dirname3, isAbsolute, join as join3, resolve as resolve3 } from "node:path";
|
|
26152
26395
|
function planList(config, ref) {
|
|
26153
26396
|
if (Array.isArray(ref)) return ref;
|
|
26154
26397
|
if (ref === "all" && !(config.plans ?? []).some((p) => p.name === "all")) return (config.plans ?? []).map((p) => p.name);
|
|
@@ -26156,7 +26399,7 @@ function planList(config, ref) {
|
|
|
26156
26399
|
}
|
|
26157
26400
|
function findConfig(cwd, explicit) {
|
|
26158
26401
|
if (explicit) {
|
|
26159
|
-
const p = isAbsolute(explicit) ? explicit :
|
|
26402
|
+
const p = isAbsolute(explicit) ? explicit : resolve3(cwd, explicit);
|
|
26160
26403
|
return existsSync3(p) ? p : void 0;
|
|
26161
26404
|
}
|
|
26162
26405
|
for (const f of CONFIG_FILES) if (existsSync3(join3(cwd, f))) return join3(cwd, f);
|
|
@@ -26177,7 +26420,7 @@ function loadConfig(path) {
|
|
|
26177
26420
|
}
|
|
26178
26421
|
const issues = validateConfig(value);
|
|
26179
26422
|
if (issues.length) throw new ConfigError(path, issues);
|
|
26180
|
-
return { config: value, path, dir:
|
|
26423
|
+
return { config: value, path, dir: dirname3(path), raw };
|
|
26181
26424
|
}
|
|
26182
26425
|
function validateConfig(value) {
|
|
26183
26426
|
const is = new Issues();
|
|
@@ -26460,7 +26703,7 @@ function parseObjectLiteral(source, env2 = {}) {
|
|
|
26460
26703
|
const p = new Parser(source, env2);
|
|
26461
26704
|
if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
|
|
26462
26705
|
const v = p.object("");
|
|
26463
|
-
return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)] };
|
|
26706
|
+
return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
|
|
26464
26707
|
}
|
|
26465
26708
|
var UNKNOWN, Parser;
|
|
26466
26709
|
var init_literal = __esm({
|
|
@@ -26474,6 +26717,7 @@ var init_literal = __esm({
|
|
|
26474
26717
|
}
|
|
26475
26718
|
i = 0;
|
|
26476
26719
|
unresolved = [];
|
|
26720
|
+
fromEnv = {};
|
|
26477
26721
|
ws() {
|
|
26478
26722
|
for (; ; ) {
|
|
26479
26723
|
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
|
|
@@ -26560,7 +26804,10 @@ var init_literal = __esm({
|
|
|
26560
26804
|
if (name === "null") return null;
|
|
26561
26805
|
if (name === "undefined") return void 0;
|
|
26562
26806
|
const env2 = /^(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
|
|
26563
|
-
if (env2)
|
|
26807
|
+
if (env2) {
|
|
26808
|
+
if (this.env[env2[1]] !== void 0 && path) this.fromEnv[path] = name;
|
|
26809
|
+
return this.env[env2[1]] ?? UNKNOWN;
|
|
26810
|
+
}
|
|
26564
26811
|
return UNKNOWN;
|
|
26565
26812
|
}
|
|
26566
26813
|
this.skipExpr();
|
|
@@ -26875,11 +27122,17 @@ function findStripeKey(env2, processEnv) {
|
|
|
26875
27122
|
sources.push([`${k} (environment)`, processEnv[k]]);
|
|
26876
27123
|
sources.push([`${k} (.env)`, env2[k]]);
|
|
26877
27124
|
}
|
|
26878
|
-
const found = sources.filter((s2) => typeof s2[1] === "string" && /^(sk|rk)
|
|
26879
|
-
const
|
|
26880
|
-
|
|
26881
|
-
|
|
26882
|
-
|
|
27125
|
+
const found = sources.filter((s2) => typeof s2[1] === "string" && /^(sk|rk)_(test|live)_[A-Za-z0-9]{10,}$/.test(s2[1].trim())).map(([src, v]) => [src, v.trim()]);
|
|
27126
|
+
const hint = (key) => `${key.slice(0, 8)}\u2026${key.slice(-4)}`;
|
|
27127
|
+
const pick = found.find((s2) => s2[1].startsWith("rk_test_"));
|
|
27128
|
+
if (pick) return { key: { key: pick[1], hint: hint(pick[1]), source: pick[0], restricted: true } };
|
|
27129
|
+
const other = found.find((s2) => s2[1].startsWith("sk_test_")) ?? found[0];
|
|
27130
|
+
if (!other) return {};
|
|
27131
|
+
return { refused: { hint: hint(other[1]), source: other[0], kind: /_live_/.test(other[1]) ? "live" : "secret" } };
|
|
27132
|
+
}
|
|
27133
|
+
function stripeKeyRefusedMessage(r) {
|
|
27134
|
+
const what = r.kind === "live" ? `${r.hint} in ${r.source} is a LIVE Stripe key. Live keys are never stored by the CLI or an agent: a person adds them in the Checkbox dashboard.` : `${r.hint} in ${r.source} is the Stripe secret key, which can do everything in your Stripe test account, so Checkbox refuses it.`;
|
|
27135
|
+
return `${what} For test runs, make a restricted TEST key instead: in Stripe, switch to Test mode, open Developers \u2192 API keys \u2192 Create restricted key, tick the permissions on https://checkbox.site/docs/guides/stripe-key, and put the key (rk_test_\u2026) in STRIPE_RESTRICTED_KEY in .env.local (never in a chat). Then run \`checkbox init\` again.`;
|
|
26883
27136
|
}
|
|
26884
27137
|
function sessionLiteral(text, index) {
|
|
26885
27138
|
const open2 = text.indexOf("{", index);
|
|
@@ -26961,7 +27214,8 @@ function detectProject(root, processEnv = {}) {
|
|
|
26961
27214
|
checkoutCode,
|
|
26962
27215
|
payButtons,
|
|
26963
27216
|
checkoutSessions,
|
|
26964
|
-
...stripeKey ? { stripeTestKey: stripeKey } : {},
|
|
27217
|
+
...stripeKey.key ? { stripeTestKey: stripeKey.key } : {},
|
|
27218
|
+
...stripeKey.refused ? { stripeKeyRefused: stripeKey.refused } : {},
|
|
26965
27219
|
...brand ? { brand } : {},
|
|
26966
27220
|
plans: plansFromSessions(checkoutSessions),
|
|
26967
27221
|
env: env2
|
|
@@ -27001,8 +27255,8 @@ var init_detect = __esm({
|
|
|
27001
27255
|
});
|
|
27002
27256
|
|
|
27003
27257
|
// src/ci/scaffold.ts
|
|
27004
|
-
import { readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
|
|
27005
|
-
import { basename, join as join5, relative as relative2 } from "node:path";
|
|
27258
|
+
import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
|
|
27259
|
+
import { basename, dirname as dirname4, join as join5, relative as relative2 } from "node:path";
|
|
27006
27260
|
function walk3(root, visit) {
|
|
27007
27261
|
let n = 0;
|
|
27008
27262
|
const go = (dir, depth) => {
|
|
@@ -27035,20 +27289,70 @@ function emailKindOf(file) {
|
|
|
27035
27289
|
const name = basename(file).replace(/\.[^.]+$/, "");
|
|
27036
27290
|
return EMAIL_NAMES.find(([re]) => re.test(name))?.[1];
|
|
27037
27291
|
}
|
|
27038
|
-
function
|
|
27292
|
+
function plansFromLiterals(text, file) {
|
|
27293
|
+
const out = [];
|
|
27294
|
+
for (const m of text.matchAll(/(?:(?:['"]?)([A-Za-z_$][\w$ -]*)['"]?\s*:\s*)?\{([^{}]{0,600})\}/g)) {
|
|
27295
|
+
const body = m[2];
|
|
27296
|
+
const interval = /\binterval\s*:\s*['"](day|week|month|year)['"]/.exec(body)?.[1];
|
|
27297
|
+
if (!interval) continue;
|
|
27298
|
+
const price3 = /\bprice\s*:\s*(\d+(?:\.\d{1,2})?)\b/.exec(body)?.[1];
|
|
27299
|
+
const minor = /\b(?:amount|unit_amount|price_cents|cents)\s*:\s*(\d+)\b/.exec(body)?.[1];
|
|
27300
|
+
const major = price3 !== void 0 ? Number(price3) : minor !== void 0 ? Number(minor) / 100 : void 0;
|
|
27301
|
+
if (major === void 0 || !(major > 0)) continue;
|
|
27302
|
+
const name = /\b(?:name|title|label)\s*:\s*['"]([^'"]{1,80})['"]/.exec(body)?.[1] ?? m[1]?.trim();
|
|
27303
|
+
if (!name) continue;
|
|
27304
|
+
const currency = /\bcurrency\s*:\s*['"]([a-z]{3})['"]/i.exec(body)?.[1]?.toLowerCase() ?? "usd";
|
|
27305
|
+
const count = /\binterval_?count\s*:\s*(\d+)/i.exec(body)?.[1];
|
|
27306
|
+
const trial = /\btrial(?:_period)?_?days\s*:\s*(\d+)/i.exec(body)?.[1];
|
|
27307
|
+
if (out.some((p) => p.name === name)) continue;
|
|
27308
|
+
const key = m[1]?.trim() && /^[A-Za-z_$][\w$]*$/.test(m[1].trim()) ? m[1].trim() : void 0;
|
|
27309
|
+
out.push({ name, ...key ? { key } : {}, price: major, currency, interval, ...count && Number(count) > 1 ? { interval_count: Number(count) } : {}, ...trial && Number(trial) > 0 ? { trial_days: Number(trial) } : {}, source: file });
|
|
27310
|
+
}
|
|
27311
|
+
return out;
|
|
27312
|
+
}
|
|
27313
|
+
function paramsBuilder(root, file, line2) {
|
|
27314
|
+
let text;
|
|
27315
|
+
try {
|
|
27316
|
+
text = readFileSync6(join5(root, file), "utf8");
|
|
27317
|
+
} catch {
|
|
27318
|
+
return void 0;
|
|
27319
|
+
}
|
|
27320
|
+
const from = text.split("\n").slice(line2 - 1).join("\n");
|
|
27321
|
+
const call = /checkout\.sessions\.create\s*\(\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/.exec(from);
|
|
27322
|
+
if (!call) return void 0;
|
|
27323
|
+
const fn = call[1];
|
|
27324
|
+
const takesArgs = call[2].trim().length > 0;
|
|
27325
|
+
const imp = new RegExp(`import\\s*\\{[^}]*\\b${fn}\\b[^}]*\\}\\s*from\\s*['"](\\.{1,2}/[^'"]+)['"]`).exec(text);
|
|
27326
|
+
let target;
|
|
27327
|
+
if (imp) {
|
|
27328
|
+
const base = join5(dirname4(file), imp[1]).split("\\").join("/");
|
|
27329
|
+
target = [base, ...[".ts", ".tsx", ".js", ".mjs", "/index.ts", "/index.js"].map((e) => `${base.replace(/\.(?:[cm]?js)$/, "")}${e}`)].find((f) => /\.[cm]?[jt]sx?$/.test(f) && existsSync5(join5(root, f)));
|
|
27330
|
+
} else if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${fn}\\b|export\\s+const\\s+${fn}\\b`).test(text)) target = file;
|
|
27331
|
+
return target ? { module: `${target}#${fn}`, takesArgs } : void 0;
|
|
27332
|
+
}
|
|
27333
|
+
function scaffoldConfig(root, env2 = {}, facts = {}) {
|
|
27039
27334
|
const det = detectProject(root, env2);
|
|
27040
27335
|
const todos = [];
|
|
27041
27336
|
const found = [];
|
|
27042
27337
|
const names = /* @__PURE__ */ new Set();
|
|
27043
27338
|
const merchant = {};
|
|
27044
|
-
|
|
27045
|
-
|
|
27339
|
+
const fact = (k) => typeof facts[k] === "string" && facts[k].trim() ? facts[k].trim() : void 0;
|
|
27340
|
+
const brand = fact("merchant_name") ?? det.brand?.name;
|
|
27341
|
+
merchant.name = brand ?? "TODO: the brand subscribers see";
|
|
27342
|
+
if (!brand) todos.push("merchant.name: the brand subscribers see");
|
|
27343
|
+
if (fact("merchant_name")) found.push(`merchant name "${fact("merchant_name")}" (from checkbox init)`);
|
|
27046
27344
|
const cancelPath = det.policyPaths.cancellation;
|
|
27047
|
-
|
|
27345
|
+
const siteUrl = fact("site_url") ?? det.siteUrl;
|
|
27346
|
+
const cancelFact = fact("cancellation_url") ?? fact("account_url");
|
|
27347
|
+
if (cancelFact) {
|
|
27348
|
+
merchant.cancel_url = cancelFact;
|
|
27349
|
+
found.push(`cancel URL ${cancelFact} (from checkbox init)`);
|
|
27350
|
+
} else if (siteUrl && cancelPath) merchant.cancel_url = `${originOf(siteUrl)}${cancelPath}`;
|
|
27048
27351
|
else {
|
|
27049
27352
|
merchant.cancel_url = "TODO: https://\u2026 page where subscribers cancel online";
|
|
27050
27353
|
todos.push("merchant.cancel_url: the page where subscribers cancel online");
|
|
27051
27354
|
}
|
|
27355
|
+
if (fact("support_email")) merchant.support_contact = fact("support_email");
|
|
27052
27356
|
const plans = det.plans.map((p, i) => ({
|
|
27053
27357
|
name: p.name ?? `Plan ${i + 1}`,
|
|
27054
27358
|
amount: p.amount,
|
|
@@ -27058,6 +27362,32 @@ function scaffoldConfig(root, env2 = {}) {
|
|
|
27058
27362
|
...p.trial_days ? { trial_days: p.trial_days } : {}
|
|
27059
27363
|
}));
|
|
27060
27364
|
for (const p of det.plans) found.push(`plan ${p.name ?? ""} ${p.amount / 100} ${p.currency}/${p.interval} (${p.source})`.replace(/\s+/g, " "));
|
|
27365
|
+
const planKeys = /* @__PURE__ */ new Map();
|
|
27366
|
+
if (!plans.length && Array.isArray(facts.plans)) {
|
|
27367
|
+
for (const [i, p] of facts.plans.entries()) {
|
|
27368
|
+
if (typeof p.amount !== "number" || typeof p.interval !== "string") continue;
|
|
27369
|
+
plans.push({ name: typeof p.name === "string" ? p.name : `Plan ${i + 1}`, amount: p.amount, currency: typeof p.currency === "string" ? p.currency : "usd", interval: p.interval, ...typeof p.interval_count === "number" ? { interval_count: p.interval_count } : {}, ...typeof p.trial_days === "number" ? { trial_days: p.trial_days } : {} });
|
|
27370
|
+
found.push(`plan ${String(plans[plans.length - 1].name)} (from checkbox init --plan)`);
|
|
27371
|
+
}
|
|
27372
|
+
}
|
|
27373
|
+
if (!plans.length) {
|
|
27374
|
+
walk3(root, (rel2) => {
|
|
27375
|
+
if (!PLAN_FILE.test(rel2) || plans.length >= 8) return;
|
|
27376
|
+
let text = "";
|
|
27377
|
+
try {
|
|
27378
|
+
text = readFileSync6(join5(root, rel2), "utf8");
|
|
27379
|
+
} catch {
|
|
27380
|
+
return;
|
|
27381
|
+
}
|
|
27382
|
+
for (const p of plansFromLiterals(text, rel2)) {
|
|
27383
|
+
if (plans.some((x) => x.name === p.name)) continue;
|
|
27384
|
+
const { source, key, ...plan2 } = p;
|
|
27385
|
+
plans.push(plan2);
|
|
27386
|
+
if (key) planKeys.set(p.name, key);
|
|
27387
|
+
found.push(`plan ${p.name} ${p.price} ${p.currency}/${p.interval} (${source})`);
|
|
27388
|
+
}
|
|
27389
|
+
});
|
|
27390
|
+
}
|
|
27061
27391
|
if (!plans.length) {
|
|
27062
27392
|
plans.push({ name: "TODO: plan name", price: "TODO: e.g. 19.99", currency: "usd", interval: "month" });
|
|
27063
27393
|
todos.push("plans: the price, interval and trial of each plan (the checkout uses Price ids, which cannot be read from code)");
|
|
@@ -27071,6 +27401,22 @@ function scaffoldConfig(root, env2 = {}) {
|
|
|
27071
27401
|
const call = (perFile.get(s2.file) ?? 0) + 1;
|
|
27072
27402
|
perFile.set(s2.file, call);
|
|
27073
27403
|
const entry = { name: unique(names, `Stripe Checkout (${basename(s2.file)}${call > 1 ? ` #${call}` : ""})`), source: s2.file, ...call > 1 ? { call } : {} };
|
|
27404
|
+
const builder = !s2.params ? paramsBuilder(root, s2.file, s2.line) : void 0;
|
|
27405
|
+
if (builder) {
|
|
27406
|
+
found.push(`${s2.file}:${s2.line}: Checkout Session built by ${builder.module}`);
|
|
27407
|
+
const keyed = plans.length > 1 && plans.every((p) => planKeys.has(String(p.name)));
|
|
27408
|
+
if (builder.takesArgs && plans.length > 1) {
|
|
27409
|
+
for (const p of plans) {
|
|
27410
|
+
const n = String(p.name);
|
|
27411
|
+
stripe.push({ name: unique(names, `Checkout (${n})`), module: builder.module, args: [keyed ? planKeys.get(n) : `TODO: the argument for plan ${n}`], plan: n, strict: true });
|
|
27412
|
+
}
|
|
27413
|
+
if (!keyed) todos.push(`${builder.module}: the argument that builds each plan's params ("args")`);
|
|
27414
|
+
} else {
|
|
27415
|
+
stripe.push({ name: entry.name, module: builder.module, ...builder.takesArgs ? { args: [plans.length === 1 && planKeys.size ? [...planKeys.values()][0] : "TODO: the function argument"] } : {}, ...plans.length === 1 ? { plan: String(plans[0].name) } : {}, strict: true });
|
|
27416
|
+
if (builder.takesArgs && !(plans.length === 1 && planKeys.size)) todos.push(`${builder.module}: the argument that builds the params ("args")`);
|
|
27417
|
+
}
|
|
27418
|
+
continue;
|
|
27419
|
+
}
|
|
27074
27420
|
if (!s2.params) {
|
|
27075
27421
|
todos.push(`${s2.file}:${s2.line}: the params are not an object literal; use "module" (an exported function returning the params) instead of "source"`);
|
|
27076
27422
|
delete entry.source;
|
|
@@ -27134,7 +27480,7 @@ function scaffoldConfig(root, env2 = {}) {
|
|
|
27134
27480
|
found
|
|
27135
27481
|
};
|
|
27136
27482
|
}
|
|
27137
|
-
var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique;
|
|
27483
|
+
var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
|
|
27138
27484
|
var init_scaffold = __esm({
|
|
27139
27485
|
"src/ci/scaffold.ts"() {
|
|
27140
27486
|
"use strict";
|
|
@@ -27160,12 +27506,13 @@ var init_scaffold = __esm({
|
|
|
27160
27506
|
names.add(n);
|
|
27161
27507
|
return n;
|
|
27162
27508
|
};
|
|
27509
|
+
PLAN_FILE = /(?:^|\/)(?:plans?|pricing|prices|tiers|subscriptions?|billing|products?)(?:\.config)?\.(?:[cm]?[jt]sx?|json)$/i;
|
|
27163
27510
|
}
|
|
27164
27511
|
});
|
|
27165
27512
|
|
|
27166
27513
|
// src/ci/coverage.ts
|
|
27167
27514
|
import { readFileSync as readFileSync7 } from "node:fs";
|
|
27168
|
-
import { join as join6, relative as relative3, resolve as
|
|
27515
|
+
import { join as join6, relative as relative3, resolve as resolve4 } from "node:path";
|
|
27169
27516
|
function callsIn(text, file) {
|
|
27170
27517
|
const hits = [];
|
|
27171
27518
|
for (const [re, kind] of PATTERNS) {
|
|
@@ -27187,7 +27534,7 @@ function callsIn(text, file) {
|
|
|
27187
27534
|
}
|
|
27188
27535
|
function coverageOf(loaded, root, files) {
|
|
27189
27536
|
const cfg = loaded.config;
|
|
27190
|
-
const toRoot = (p) => relative3(root,
|
|
27537
|
+
const toRoot = (p) => relative3(root, resolve4(loaded.dir, p)).split("\\").join("/");
|
|
27191
27538
|
const calls = [];
|
|
27192
27539
|
const visit = (rel2) => {
|
|
27193
27540
|
if (!CODE.test(rel2) || TESTISH.test(rel2)) return;
|
|
@@ -27287,11 +27634,11 @@ var init_coverage = __esm({
|
|
|
27287
27634
|
|
|
27288
27635
|
// src/ci/inputs.ts
|
|
27289
27636
|
import { execFile } from "node:child_process";
|
|
27290
|
-
import { existsSync as
|
|
27291
|
-
import { dirname as
|
|
27637
|
+
import { existsSync as existsSync6, readFileSync as readFileSync8 } from "node:fs";
|
|
27638
|
+
import { dirname as dirname5, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve5 } from "node:path";
|
|
27292
27639
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
27293
27640
|
function readRel(dir, file) {
|
|
27294
|
-
const abs = isAbsolute2(file) ? file :
|
|
27641
|
+
const abs = isAbsolute2(file) ? file : resolve5(dir, file);
|
|
27295
27642
|
try {
|
|
27296
27643
|
return readFileSync8(abs, "utf8");
|
|
27297
27644
|
} catch (err) {
|
|
@@ -27301,28 +27648,44 @@ function readRel(dir, file) {
|
|
|
27301
27648
|
}
|
|
27302
27649
|
function hasTsx(dir) {
|
|
27303
27650
|
for (let d = dir, i = 0; i < 8; i++) {
|
|
27304
|
-
if (
|
|
27305
|
-
const up =
|
|
27651
|
+
if (existsSync6(join7(d, "node_modules", "tsx", "package.json"))) return true;
|
|
27652
|
+
const up = dirname5(d);
|
|
27653
|
+
if (up === d) break;
|
|
27654
|
+
d = up;
|
|
27655
|
+
}
|
|
27656
|
+
return false;
|
|
27657
|
+
}
|
|
27658
|
+
function declaresTsx(dir) {
|
|
27659
|
+
for (let d = dir, i = 0; i < 8; i++) {
|
|
27660
|
+
try {
|
|
27661
|
+
const pkg = JSON.parse(readFileSync8(join7(d, "package.json"), "utf8"));
|
|
27662
|
+
if (pkg.dependencies?.tsx || pkg.devDependencies?.tsx) return true;
|
|
27663
|
+
} catch {
|
|
27664
|
+
}
|
|
27665
|
+
const up = dirname5(d);
|
|
27306
27666
|
if (up === d) break;
|
|
27307
27667
|
d = up;
|
|
27308
27668
|
}
|
|
27309
27669
|
return false;
|
|
27310
27670
|
}
|
|
27671
|
+
function addTsx(dir, detail = "") {
|
|
27672
|
+
return declaresTsx(dir) ? NOT_INSTALLED : `Add tsx to the project (npm install -D tsx${detail})`;
|
|
27673
|
+
}
|
|
27311
27674
|
function loaderFlags(file, dir, nodeVersion = process.versions.node, features = process.features) {
|
|
27312
27675
|
if (/\.[cm]?js$/.test(file)) return [];
|
|
27313
27676
|
if (hasTsx(dir)) return ["--import", "tsx"];
|
|
27314
|
-
if (/\.[jt]sx$/.test(file)) return `${file}: JSX modules need tsx (npm install -D tsx), or export the rendered { subject, html } from a .js / .ts file`;
|
|
27677
|
+
if (/\.[jt]sx$/.test(file)) return declaresTsx(dir) ? `${file}: ${NOT_INSTALLED}` : `${file}: JSX modules need tsx (npm install -D tsx), or export the rendered { subject, html } from a .js / .ts file`;
|
|
27315
27678
|
if (features.typescript) return [];
|
|
27316
27679
|
const [major, minor] = nodeVersion.split(".").map(Number);
|
|
27317
27680
|
if (major > 22 || major === 22 && minor >= 6) return ["--experimental-strip-types"];
|
|
27318
|
-
return `${file}: TypeScript modules need Node 22.6+ (type stripping) or tsx in the project (npm install -D tsx); or point "file" at a JSON export`;
|
|
27681
|
+
return declaresTsx(dir) ? `${file}: ${NOT_INSTALLED}` : `${file}: TypeScript modules need Node 22.6+ (type stripping) or tsx in the project (npm install -D tsx); or point "file" at a JSON export`;
|
|
27319
27682
|
}
|
|
27320
27683
|
function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
|
|
27321
27684
|
const hash = ref.lastIndexOf("#");
|
|
27322
27685
|
const file = hash > 0 ? ref.slice(0, hash) : ref;
|
|
27323
27686
|
const name = hash > 0 ? ref.slice(hash + 1) : "default";
|
|
27324
|
-
const abs =
|
|
27325
|
-
if (!
|
|
27687
|
+
const abs = resolve5(dir, file);
|
|
27688
|
+
if (!existsSync6(abs)) return Promise.reject(new InputError(`${file} does not exist (paths are relative to checkbox.json)`));
|
|
27326
27689
|
const flags = loaderFlags(file, dir);
|
|
27327
27690
|
if (typeof flags === "string") return Promise.reject(new InputError(flags));
|
|
27328
27691
|
return new Promise((resolvePromise, reject) => {
|
|
@@ -27341,12 +27704,39 @@ function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
|
|
|
27341
27704
|
return;
|
|
27342
27705
|
}
|
|
27343
27706
|
const own = /__CHECKBOX_ERR__ (.+)/.exec(stderr)?.[1];
|
|
27344
|
-
|
|
27345
|
-
reject(new InputError(`${ref}: ${own ?? (err && err.killed ? `did not finish within ${timeoutMs / 1e3}s` : tail || err?.message || "no output")}`));
|
|
27707
|
+
reject(new InputError(`${ref}: ${own ?? (err && err.killed ? `did not finish within ${timeoutMs / 1e3}s` : moduleErrorMessage(stderr, dir, flags) || err?.message || "no output")}`));
|
|
27346
27708
|
}
|
|
27347
27709
|
);
|
|
27348
27710
|
});
|
|
27349
27711
|
}
|
|
27712
|
+
function moduleErrorMessage(stderr, dir, flags = []) {
|
|
27713
|
+
const text = stderr.trim();
|
|
27714
|
+
if (!text) return "";
|
|
27715
|
+
const rel2 = (p) => {
|
|
27716
|
+
const r = relative4(dir, p.replace(/^file:\/\//, ""));
|
|
27717
|
+
return r && !r.startsWith("..") ? r.split("\\").join("/") : p;
|
|
27718
|
+
};
|
|
27719
|
+
const tsx = flags.includes("tsx");
|
|
27720
|
+
const missing = /Cannot find (module|package) '([^']+)' imported from (\S+)/.exec(text);
|
|
27721
|
+
if (missing) {
|
|
27722
|
+
const [, what, target, from] = missing;
|
|
27723
|
+
if (what === "package" || !/^[./]|^[A-Za-z]:[\\/]/.test(target) && !target.startsWith(dir)) {
|
|
27724
|
+
return `${rel2(from)} imports the package "${target}", which is not installed here. Install the project's dependencies before checkbox ci (e.g. npm ci in the workflow).`;
|
|
27725
|
+
}
|
|
27726
|
+
const noExt = !/\.[cm]?[jt]sx?$|\.json$/.test(target);
|
|
27727
|
+
if (noExt && !tsx) {
|
|
27728
|
+
return `${rel2(from)} imports ${rel2(target)} without a file extension, which Node cannot resolve on its own. ${addTsx(dir, "; checkbox ci then imports modules the way your bundler does")}, or write the import with its extension (e.g. './${rel2(target).split("/").pop()}.ts').`;
|
|
27729
|
+
}
|
|
27730
|
+
return `${rel2(from)} imports ${rel2(target)}, which does not exist${noExt ? " (tsx found no .ts / .js file of that name either)" : ""}.`;
|
|
27731
|
+
}
|
|
27732
|
+
if (/ERR_UNKNOWN_FILE_EXTENSION/.test(text)) {
|
|
27733
|
+
const file = /Unknown file extension "([^"]+)" for (\S+)/.exec(text);
|
|
27734
|
+
return `Node cannot import ${file ? `${rel2(file[2])} (${file[1]})` : "a file the module imports"}. ${addTsx(dir)}, or point the entry at a .js / .ts file that imports no ${file?.[1] ?? "such"} files.`;
|
|
27735
|
+
}
|
|
27736
|
+
const lines = text.split("\n").map((l) => l.trim()).filter((l) => l && !/^at /.test(l) && !/^Node\.js v\d/.test(l));
|
|
27737
|
+
const first2 = lines.find((l) => /^(?:[A-Z]\w*)?Error\b.*:/.test(l)) ?? lines.find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "";
|
|
27738
|
+
return first2.replace(/file:\/\/\S+/g, (u) => rel2(u)).replace(/\s+/g, " ").slice(0, 400);
|
|
27739
|
+
}
|
|
27350
27740
|
function paramsFromSource(text, file, call = 1, env2 = {}) {
|
|
27351
27741
|
const calls = [...text.matchAll(/checkout\.sessions\.create\s*\(/g)];
|
|
27352
27742
|
if (!calls.length) throw new InputError(`${file} has no stripe.checkout.sessions.create( call`);
|
|
@@ -27357,7 +27747,32 @@ function paramsFromSource(text, file, call = 1, env2 = {}) {
|
|
|
27357
27747
|
if (!lit) throw new InputError(`${file}:${line2}: the params are not an object literal; use "module" (a function returning the params) or "file" instead`);
|
|
27358
27748
|
const parsed = parseObjectLiteral(lit, env2);
|
|
27359
27749
|
if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
|
|
27360
|
-
return { params: parsed.value, line: line2, unresolved: parsed.unresolved };
|
|
27750
|
+
return { params: parsed.value, line: line2, unresolved: parsed.unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
|
|
27751
|
+
}
|
|
27752
|
+
function lineOfAny(text, needles) {
|
|
27753
|
+
const lower2 = text.toLowerCase();
|
|
27754
|
+
for (const n of needles) {
|
|
27755
|
+
const k = n.trim().toLowerCase();
|
|
27756
|
+
if (k.length < 2) continue;
|
|
27757
|
+
const i = lower2.indexOf(k);
|
|
27758
|
+
if (i >= 0) return text.slice(0, i).split("\n").length;
|
|
27759
|
+
}
|
|
27760
|
+
return void 0;
|
|
27761
|
+
}
|
|
27762
|
+
function exportLine(dir, ref) {
|
|
27763
|
+
const hash = ref.lastIndexOf("#");
|
|
27764
|
+
const file = hash > 0 ? ref.slice(0, hash) : ref;
|
|
27765
|
+
const name = hash > 0 ? ref.slice(hash + 1) : "default";
|
|
27766
|
+
let text;
|
|
27767
|
+
try {
|
|
27768
|
+
text = readFileSync8(resolve5(dir, file), "utf8");
|
|
27769
|
+
} catch {
|
|
27770
|
+
return 1;
|
|
27771
|
+
}
|
|
27772
|
+
const esc3 = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27773
|
+
const re = name === "default" ? /^\s*export\s+default\b/m : new RegExp(`^\\s*export\\s+(?:async\\s+)?(?:function\\*?|const|let|var|class)\\s+${esc3}\\b|^\\s*export\\s*\\{[^}]*\\b${esc3}\\b`, "m");
|
|
27774
|
+
const m = re.exec(text);
|
|
27775
|
+
return m ? text.slice(0, m.index).split("\n").length + (m[0].startsWith("\n") ? 1 : 0) : 1;
|
|
27361
27776
|
}
|
|
27362
27777
|
function projectEnv(dir) {
|
|
27363
27778
|
const out = {};
|
|
@@ -27389,7 +27804,7 @@ function planTerms(config, plan2) {
|
|
|
27389
27804
|
return t;
|
|
27390
27805
|
}
|
|
27391
27806
|
function rel(loaded, root, file) {
|
|
27392
|
-
return relative4(root,
|
|
27807
|
+
return relative4(root, resolve5(loaded.dir, file)).split("\\").join("/") || file;
|
|
27393
27808
|
}
|
|
27394
27809
|
function emailFromFile(content, file, subject) {
|
|
27395
27810
|
let body = content;
|
|
@@ -27423,15 +27838,22 @@ async function stripeJob(loaded, e, o, root) {
|
|
|
27423
27838
|
}
|
|
27424
27839
|
} else if (e.module) {
|
|
27425
27840
|
const file = e.module.replace(/#.*$/, "");
|
|
27426
|
-
job.target = { file: rel(loaded, root, file), line:
|
|
27841
|
+
job.target = { file: rel(loaded, root, file), line: exportLine(dir, e.module) };
|
|
27427
27842
|
job.inputs.push(file);
|
|
27428
27843
|
params = await runModule(dir, e.module, e.args ?? [], o.env);
|
|
27844
|
+
const unset = (Array.isArray(params?.line_items) ? params.line_items : []).flatMap((li, i) => li && typeof li === "object" && !Array.isArray(li) && li.price === void 0 && li.price_data === void 0 ? [`line_items[${i}].price`] : []);
|
|
27845
|
+
if (unset.length) {
|
|
27846
|
+
job.placeholders = Object.fromEntries(unset.map((p) => [p, "{{your Stripe Price id: unset when checkbox ci ran the module}}"]));
|
|
27847
|
+
job.warnings.push(`${unset.join(", ")} ${unset.length === 1 ? "was" : "were"} not set when Checkbox ran ${e.module} (an environment variable this job does not set?). The checks use "plan" in checkbox.json for the price, and corrected params keep a placeholder there for your own value.`);
|
|
27848
|
+
}
|
|
27429
27849
|
} else {
|
|
27430
27850
|
const src = e.source;
|
|
27431
27851
|
job.inputs.push(src);
|
|
27432
27852
|
const r = paramsFromSource(readRel(dir, src), src, e.call ?? 1, projectEnv(dir));
|
|
27433
27853
|
job.target = { file: rel(loaded, root, src), line: r.line };
|
|
27854
|
+
job.fromSource = true;
|
|
27434
27855
|
params = r.params;
|
|
27856
|
+
if (r.fromEnv) job.placeholders = Object.fromEntries(Object.entries(r.fromEnv).map(([p, expr]) => [p, `{{${expr}}}`]));
|
|
27435
27857
|
if (r.unresolved.length) {
|
|
27436
27858
|
job.partial = true;
|
|
27437
27859
|
job.unresolved = r.unresolved;
|
|
@@ -27507,16 +27929,32 @@ function pageJob(loaded, e, o, root) {
|
|
|
27507
27929
|
if (e.source) {
|
|
27508
27930
|
job.target = { file: rel(loaded, root, e.source), line: 1 };
|
|
27509
27931
|
job.inputs.push(e.source);
|
|
27932
|
+
const src = e.source;
|
|
27933
|
+
job.locate = (needles) => {
|
|
27934
|
+
try {
|
|
27935
|
+
return lineOfAny(readRel(dir, src), needles);
|
|
27936
|
+
} catch {
|
|
27937
|
+
return void 0;
|
|
27938
|
+
}
|
|
27939
|
+
};
|
|
27510
27940
|
}
|
|
27511
27941
|
if (e.url) {
|
|
27512
|
-
if (!o.online) job.skip =
|
|
27942
|
+
if (!o.online) job.skip = 'URL pages are fetched by the API: run with --online and a live-mode key (ck_live_\u2026 on an activated account), or list the built page under "file" to check it offline';
|
|
27513
27943
|
else job.body = { url: e.url, ...common3 };
|
|
27514
27944
|
return job;
|
|
27515
27945
|
}
|
|
27516
27946
|
try {
|
|
27517
27947
|
if (!e.source) job.target = { file: rel(loaded, root, e.file), line: 1 };
|
|
27518
27948
|
job.inputs.push(e.file);
|
|
27519
|
-
|
|
27949
|
+
let html;
|
|
27950
|
+
try {
|
|
27951
|
+
html = readRel(dir, e.file);
|
|
27952
|
+
} catch (err) {
|
|
27953
|
+
if (!existsSync6(resolve5(dir, e.file))) throw new InputError(`${e.file} does not exist (paths are relative to checkbox.json). If it is build output, build it before checkbox ci: in GitHub Actions, add a build step before the Checkbox step (\`checkbox ci init\` writes one when package.json has a build script).`);
|
|
27954
|
+
throw err;
|
|
27955
|
+
}
|
|
27956
|
+
if (!e.source) job.locate = (needles) => lineOfAny(html, needles);
|
|
27957
|
+
job.body = { html, ...common3 };
|
|
27520
27958
|
} catch (err) {
|
|
27521
27959
|
job.error = err.message;
|
|
27522
27960
|
}
|
|
@@ -27533,7 +27971,7 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
|
|
|
27533
27971
|
msg2 = emailFromFile(readRel(dir, e.file), e.file, e.subject);
|
|
27534
27972
|
} else if (e.module) {
|
|
27535
27973
|
const file = e.module.replace(/#.*$/, "");
|
|
27536
|
-
job.target = { file: rel(loaded, root, file), line:
|
|
27974
|
+
job.target = { file: rel(loaded, root, file), line: exportLine(dir, e.module) };
|
|
27537
27975
|
job.inputs.push(file);
|
|
27538
27976
|
const v = await runModule(dir, e.module, e.args ?? [], o.env);
|
|
27539
27977
|
if (typeof v === "string") msg2 = emailFromFile(v, /^\s*</.test(v) ? "x.html" : "x.txt", e.subject);
|
|
@@ -27581,7 +28019,7 @@ async function resolveJobs(loaded, o) {
|
|
|
27581
28019
|
}
|
|
27582
28020
|
return jobs;
|
|
27583
28021
|
}
|
|
27584
|
-
var InputError, CHILD, jurisdictionsOf;
|
|
28022
|
+
var InputError, NOT_INSTALLED, CHILD, jurisdictionsOf;
|
|
27585
28023
|
var init_inputs = __esm({
|
|
27586
28024
|
"src/ci/inputs.ts"() {
|
|
27587
28025
|
"use strict";
|
|
@@ -27594,6 +28032,7 @@ var init_inputs = __esm({
|
|
|
27594
28032
|
this.name = "InputError";
|
|
27595
28033
|
}
|
|
27596
28034
|
};
|
|
28035
|
+
NOT_INSTALLED = "tsx is in package.json but not installed: install the project's dependencies before checkbox ci (npm ci; in GitHub Actions a `- run: npm ci` step before the Checkbox step, which `checkbox ci init` writes)";
|
|
27597
28036
|
CHILD = `
|
|
27598
28037
|
const [url, name, argsJson] = process.argv.slice(1);
|
|
27599
28038
|
const mod = await import(url);
|
|
@@ -27612,102 +28051,6 @@ process.exit(0);
|
|
|
27612
28051
|
}
|
|
27613
28052
|
});
|
|
27614
28053
|
|
|
27615
|
-
// src/ci/report.ts
|
|
27616
|
-
function icon(s2, c) {
|
|
27617
|
-
if (s2 === "pass") return c.green("\u2713");
|
|
27618
|
-
if (s2 === "fail" || s2 === "error") return c.red("\u2717");
|
|
27619
|
-
if (s2 === "needs_review") return c.yellow("?");
|
|
27620
|
-
return c.dim("\u2013");
|
|
27621
|
-
}
|
|
27622
|
-
function overallLine(run2) {
|
|
27623
|
-
const s2 = run2.summary;
|
|
27624
|
-
const parts = [];
|
|
27625
|
-
if (s2.error) parts.push(`${s2.error} could not run`);
|
|
27626
|
-
if (s2.fail) parts.push(`${s2.fail} need${s2.fail === 1 ? "s" : ""} a fix`);
|
|
27627
|
-
if (s2.needs_review) parts.push(`${s2.needs_review} need${s2.needs_review === 1 ? "s" : ""} review${run2.strict ? " (strict)" : ""}`);
|
|
27628
|
-
if (s2.pass) parts.push(`${s2.pass} pass`);
|
|
27629
|
-
if (s2.not_applicable) parts.push(`${s2.not_applicable} not required`);
|
|
27630
|
-
if (s2.skipped) parts.push(`${s2.skipped} skipped`);
|
|
27631
|
-
return parts.join(" \xB7 ") || "nothing checked";
|
|
27632
|
-
}
|
|
27633
|
-
function renderHuman(run2, c, opts = {}) {
|
|
27634
|
-
const lines = [];
|
|
27635
|
-
const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
|
|
27636
|
-
lines.push(`${c.bold("Checkbox CI")} ${run2.config} \xB7 ${run2.mode}${versions} \xB7 ${run2.checks.length} check${run2.checks.length === 1 ? "" : "s"}`);
|
|
27637
|
-
const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
|
|
27638
|
-
for (const x of run2.checks) {
|
|
27639
|
-
const label = x.status === "fail" || x.status === "error" ? c.red(x.label) : x.status === "needs_review" ? c.yellow(x.label) : x.status === "pass" ? c.green(x.label) : c.dim(x.label);
|
|
27640
|
-
lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
|
|
27641
|
-
if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
|
|
27642
|
-
if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
|
|
27643
|
-
if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(` fix: ${wrap(f, 110, " ")}`);
|
|
27644
|
-
for (const w of x.warnings.slice(0, opts.verbose ? 10 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
|
|
27645
|
-
if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
|
|
27646
|
-
}
|
|
27647
|
-
const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
|
|
27648
|
-
lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
|
|
27649
|
-
if (run2.checks.some((x) => x.corrected) && !opts.verbose) lines.push(c.dim("Corrected params / text: --verbose, --json, or the job summary in GitHub Actions."));
|
|
27650
|
-
lines.push(c.dim(run2.disclaimer));
|
|
27651
|
-
return lines;
|
|
27652
|
-
}
|
|
27653
|
-
function annotations(run2) {
|
|
27654
|
-
const out = [];
|
|
27655
|
-
for (const x of run2.checks) {
|
|
27656
|
-
const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : void 0;
|
|
27657
|
-
if (!level) continue;
|
|
27658
|
-
const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
|
|
27659
|
-
const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
|
|
27660
|
-
${x.fix}` : `Could not run: ${x.message ?? ""}` : [x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
|
|
27661
|
-
out.push(`::${level} ${props.join(",")}::${escData(body)}`);
|
|
27662
|
-
}
|
|
27663
|
-
return out;
|
|
27664
|
-
}
|
|
27665
|
-
function stepSummary(run2) {
|
|
27666
|
-
const lines = [];
|
|
27667
|
-
lines.push("## Checkbox compliance checks", "");
|
|
27668
|
-
const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
|
|
27669
|
-
lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
|
|
27670
|
-
lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
|
|
27671
|
-
for (const x of run2.checks) {
|
|
27672
|
-
const fix = x.status === "error" || x.status === "skipped" ? [x.message, x.fix].filter(Boolean).join(" ") : x.fix ? `${x.fix}${x.fixes.length > 1 ? ` (+${x.fixes.length - 1} more)` : ""}` : "";
|
|
27673
|
-
lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
|
|
27674
|
-
}
|
|
27675
|
-
const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
|
|
27676
|
-
for (const x of detailed) {
|
|
27677
|
-
lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
|
|
27678
|
-
if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
|
|
27679
|
-
if (x.corrected) {
|
|
27680
|
-
const fence = x.corrected.body.includes("```") ? "````" : "```";
|
|
27681
|
-
lines.push(`${fence}${x.corrected.format === "json" ? "json" : "text"}`, x.corrected.body, fence, "");
|
|
27682
|
-
}
|
|
27683
|
-
lines.push("</details>");
|
|
27684
|
-
}
|
|
27685
|
-
lines.push("", `<sub>${run2.disclaimer} Docs: https://checkbox.site/docs/guides/ci</sub>`, "");
|
|
27686
|
-
return lines.join("\n");
|
|
27687
|
-
}
|
|
27688
|
-
function escapeHtml(s2) {
|
|
27689
|
-
return s2.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
27690
|
-
}
|
|
27691
|
-
var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
|
|
27692
|
-
var init_report = __esm({
|
|
27693
|
-
"src/ci/report.ts"() {
|
|
27694
|
-
"use strict";
|
|
27695
|
-
init_output();
|
|
27696
|
-
TYPE_LABEL = {
|
|
27697
|
-
stripe_checkout: "Stripe Checkout",
|
|
27698
|
-
disclosure: "Disclosure",
|
|
27699
|
-
page: "Page",
|
|
27700
|
-
email: "Email",
|
|
27701
|
-
notice_schedule: "Notice schedule"
|
|
27702
|
-
};
|
|
27703
|
-
where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
|
|
27704
|
-
escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
27705
|
-
escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
27706
|
-
cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
|
27707
|
-
STATUS_MD = { pass: "\u2705", fail: "\u274C", needs_review: "\u26A0\uFE0F", error: "\u274C", skipped: "\u23ED\uFE0F", not_applicable: "\u2796" };
|
|
27708
|
-
}
|
|
27709
|
-
});
|
|
27710
|
-
|
|
27711
28054
|
// ../rules/src/fixes/conflicts.ts
|
|
27712
28055
|
var init_conflicts = __esm({
|
|
27713
28056
|
"../rules/src/fixes/conflicts.ts"() {
|
|
@@ -27874,6 +28217,10 @@ function pickDaysBefore(kind, range) {
|
|
|
27874
28217
|
if (open2) return lo + OPEN_WINDOW_MARGIN_DAYS;
|
|
27875
28218
|
return lo + Math.floor((range.maxDays - lo) / 2);
|
|
27876
28219
|
}
|
|
28220
|
+
function sendLeadDays(range, days) {
|
|
28221
|
+
if (range.maxDays >= OPEN_ENDED_DAYS2 || !Number.isInteger(days) || days <= 0) return days;
|
|
28222
|
+
return days <= range.maxDays ? days + BOUNDED_SEND_MARGIN_DAYS : days;
|
|
28223
|
+
}
|
|
27877
28224
|
function stabWindows(windows) {
|
|
27878
28225
|
const sorted = [...windows].sort((a, b) => a.window.maxDays - b.window.maxDays || a.window.minDays - b.window.minDays);
|
|
27879
28226
|
const groups = [];
|
|
@@ -27891,11 +28238,15 @@ function stabWindows(windows) {
|
|
|
27891
28238
|
function slotOf(kind, anchorAt, range, members, daysBefore) {
|
|
27892
28239
|
const days = daysBefore ?? pickDaysBefore(kind, range);
|
|
27893
28240
|
const maxDays = Math.min(range.maxDays, OPEN_ENDED_DAYS2);
|
|
28241
|
+
const lead = sendLeadDays(range, days);
|
|
28242
|
+
const sendAt = new Date(anchorAt.getTime() - lead * DAY_MS5);
|
|
28243
|
+
const opensAt = new Date(anchorAt.getTime() - maxDays * DAY_MS5);
|
|
27894
28244
|
return {
|
|
27895
28245
|
slot: `d${days}`,
|
|
27896
28246
|
daysBefore: days,
|
|
27897
|
-
sendAt
|
|
27898
|
-
opensAt
|
|
28247
|
+
sendAt,
|
|
28248
|
+
// The send time is inside [opensAt, closesAt] (a bounded window's send sits inside its maximum's rounding).
|
|
28249
|
+
opensAt: sendAt.getTime() < opensAt.getTime() ? sendAt : opensAt,
|
|
27899
28250
|
closesAt: new Date(anchorAt.getTime() - range.minDays * DAY_MS5),
|
|
27900
28251
|
range: { minDays: range.minDays, maxDays },
|
|
27901
28252
|
jurisdictions: [...new Set(members.map((m) => m.jurisdiction))].sort(),
|
|
@@ -27976,7 +28327,7 @@ function renewalReminderApplies(termMonths2, jurisdictions, asOf, requirements)
|
|
|
27976
28327
|
(r) => r.appliesWhen?.minTermMonths !== void 0 && r.appliesWhen.minTermMonths <= termMonths2
|
|
27977
28328
|
);
|
|
27978
28329
|
}
|
|
27979
|
-
var DAY_MS5, HOUR_MS2, OPEN_ENDED_DAYS2, OPEN_WINDOW_MARGIN_DAYS, TRIAL_MIN_MARGIN_DAYS, TRIAL_SIGNUP_GAP_DAYS, PRICE_CHANGE_MAX_LEAD_DAYS, ANNUAL_TERMS_DEFAULT_WINDOW, NOTICE_CHECKS2, isImmediateKind, KIND_ANCHORS, FALLBACK_WINDOWS, BUSINESS_DAY_MINIMUMS;
|
|
28330
|
+
var DAY_MS5, HOUR_MS2, OPEN_ENDED_DAYS2, OPEN_WINDOW_MARGIN_DAYS, TRIAL_MIN_MARGIN_DAYS, TRIAL_SIGNUP_GAP_DAYS, BOUNDED_SEND_MARGIN_DAYS, PRICE_CHANGE_MAX_LEAD_DAYS, ANNUAL_TERMS_DEFAULT_WINDOW, NOTICE_CHECKS2, isImmediateKind, KIND_ANCHORS, FALLBACK_WINDOWS, BUSINESS_DAY_MINIMUMS;
|
|
27980
28331
|
var init_timing = __esm({
|
|
27981
28332
|
"../../apps/api/src/notices/timing.ts"() {
|
|
27982
28333
|
"use strict";
|
|
@@ -27987,6 +28338,7 @@ var init_timing = __esm({
|
|
|
27987
28338
|
OPEN_WINDOW_MARGIN_DAYS = 2;
|
|
27988
28339
|
TRIAL_MIN_MARGIN_DAYS = 0.5;
|
|
27989
28340
|
TRIAL_SIGNUP_GAP_DAYS = 1 / 24;
|
|
28341
|
+
BOUNDED_SEND_MARGIN_DAYS = 0.25;
|
|
27990
28342
|
PRICE_CHANGE_MAX_LEAD_DAYS = 60;
|
|
27991
28343
|
ANNUAL_TERMS_DEFAULT_WINDOW = { minDays: 15, maxDays: 45 };
|
|
27992
28344
|
NOTICE_CHECKS2 = {
|
|
@@ -28039,7 +28391,7 @@ function singleDayNote(requirementIds, days) {
|
|
|
28039
28391
|
const lower2 = [...new Set(windows.filter((w) => w.minDays === days).map((w) => jur(w.id)))];
|
|
28040
28392
|
const upper = [...new Set(windows.filter((w) => w.maxDays === days).map((w) => jur(w.id)))];
|
|
28041
28393
|
const why = lower2.length && upper.length ? `at least ${days} days (${lower2.slice(0, 4).join(", ")}) and at most ${days} days (${upper.slice(0, 4).join(", ")}) leave a single day` : `the states' windows meet on a single day`;
|
|
28042
|
-
return `Exactly ${days} days before: ${why}. Send at send_at
|
|
28394
|
+
return `Exactly ${days} days before: ${why}. Send at send_at, ${days} days and 6 hours before the anchor. The one rule: a minimum counts only full days, less a 2-hour allowance for delivery; a maximum is rounded to the nearest day. So a send counts as ${days} days when it goes out less than ${days + 0.5} days and at least ${days} days less 2 hours before the anchor; anything earlier or later does not.`;
|
|
28043
28395
|
}
|
|
28044
28396
|
function addInterval(d, interval, n) {
|
|
28045
28397
|
if (interval === "day") return new Date(d.getTime() + n * DAY_MS5);
|
|
@@ -28202,6 +28554,32 @@ function operationFor2(kind) {
|
|
|
28202
28554
|
function onlineEngine(client) {
|
|
28203
28555
|
return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
|
|
28204
28556
|
}
|
|
28557
|
+
function ciVocabulary(text) {
|
|
28558
|
+
return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored").replace(/In test mode, pass the page source as html\./g, 'With a test key, list the built page under "file" in checkbox.json instead of "url" (or run --online with a live key, ck_live_\u2026).');
|
|
28559
|
+
}
|
|
28560
|
+
function pricingPageFixes(fixes) {
|
|
28561
|
+
const reworded = fixes.map(
|
|
28562
|
+
(f) => f.replace(/,? before the card and email fields and the (?:pay|purchase) button/g, ", next to the plan's button").replace(/,? before the card and email fields/g, ", next to the plan's button")
|
|
28563
|
+
);
|
|
28564
|
+
const out = [];
|
|
28565
|
+
const renewalLineFor = /* @__PURE__ */ new Set();
|
|
28566
|
+
for (const f of reworded) {
|
|
28567
|
+
const card = /^(Plan card [^:]+): /.exec(f)?.[1] ?? "";
|
|
28568
|
+
const isRenewalLine = /\b(?:Add one line under the price|Put the renewal line under the price|Move the renewal line under the price)/.test(f);
|
|
28569
|
+
if (isRenewalLine) {
|
|
28570
|
+
if (renewalLineFor.has(card)) continue;
|
|
28571
|
+
renewalLineFor.add(card);
|
|
28572
|
+
}
|
|
28573
|
+
if (!out.includes(f)) out.push(f);
|
|
28574
|
+
}
|
|
28575
|
+
return out;
|
|
28576
|
+
}
|
|
28577
|
+
function stripeConsentFix(text, params) {
|
|
28578
|
+
if (!/^(Ask for a separate yes to the renewal|Add one unchecked checkbox)/.test(text)) return text;
|
|
28579
|
+
const cc = params?.consent_collection;
|
|
28580
|
+
if (cc?.terms_of_service === "required") return text;
|
|
28581
|
+
return 'Set consent_collection: { terms_of_service: "required" } in the params: Stripe then shows an unchecked consent checkbox, labelled with custom_text.terms_of_service_acceptance.message.';
|
|
28582
|
+
}
|
|
28205
28583
|
function ciStatusOf(r) {
|
|
28206
28584
|
const cs = r.compliance_status;
|
|
28207
28585
|
if (r.verdict === "not_applicable") return "not_applicable";
|
|
@@ -28210,9 +28588,9 @@ function ciStatusOf(r) {
|
|
|
28210
28588
|
if (cs && OK_STATUSES.has(cs)) return "pass";
|
|
28211
28589
|
return "needs_review";
|
|
28212
28590
|
}
|
|
28213
|
-
function fixesOf(r) {
|
|
28591
|
+
function fixesOf(r, onlyNeedsFix = false) {
|
|
28214
28592
|
const out = [];
|
|
28215
|
-
const failing = (r.checks ?? []).filter((c) => (c.status === "fail" || c.status === "needs_review") && !(c.compliance_status && OK_STATUSES.has(c.compliance_status)));
|
|
28593
|
+
const failing = (r.checks ?? []).filter((c) => (c.status === "fail" || c.status === "needs_review") && !(c.compliance_status && OK_STATUSES.has(c.compliance_status)) && (!onlyNeedsFix || c.compliance_status === "needs_fix"));
|
|
28216
28594
|
failing.sort((a, b) => (a.compliance_status === "needs_fix" ? 0 : 1) - (b.compliance_status === "needs_fix" ? 0 : 1) || (RANK2[a.status] ?? 2) - (RANK2[b.status] ?? 2));
|
|
28217
28595
|
for (const c of failing) {
|
|
28218
28596
|
const base = c.compliance?.fix || c.fix || c.compliance?.question?.text || c.explanation || c.title || c.check_id;
|
|
@@ -28225,14 +28603,25 @@ function fixesOf(r) {
|
|
|
28225
28603
|
function failingCards(explanation) {
|
|
28226
28604
|
if (!explanation) return [];
|
|
28227
28605
|
if (/\(\d+ checkout page views? checked\) — e\.g\./.test(explanation)) return [];
|
|
28228
|
-
return [...new Set([...explanation.matchAll(/plan button “([^”]{1,80})
|
|
28606
|
+
return [...new Set([...explanation.matchAll(/plan button “([^”]{1,80})”(?: in the card “([^”]{1,80})”)?/g)].map((m) => m[2] ?? m[1]))];
|
|
28607
|
+
}
|
|
28608
|
+
function withPlaceholders(params, placeholders) {
|
|
28609
|
+
if (!placeholders || !params || typeof params !== "object") return params;
|
|
28610
|
+
const copy = JSON.parse(JSON.stringify(params));
|
|
28611
|
+
for (const [path, value] of Object.entries(placeholders)) {
|
|
28612
|
+
const keys = path.split(/\.|\[(\d+)\]/).filter((k) => k !== void 0 && k !== "");
|
|
28613
|
+
let node = copy;
|
|
28614
|
+
for (const k of keys.slice(0, -1)) node = node && typeof node === "object" ? node[k] : void 0;
|
|
28615
|
+
if (node && typeof node === "object") node[keys[keys.length - 1]] = value;
|
|
28616
|
+
}
|
|
28617
|
+
return copy;
|
|
28229
28618
|
}
|
|
28230
|
-
function correctedOf(type, r, partial = false) {
|
|
28619
|
+
function correctedOf(type, r, partial = false, placeholders) {
|
|
28231
28620
|
if (type === "stripe_checkout" && r.correction?.patch?.length && partial) {
|
|
28232
28621
|
return { format: "json", title: `Changes to make to the params (JSON Patch; keep your other values)${r.correction.verification?.passes ? ", verified by the rules engine" : ""}`, body: JSON.stringify(r.correction.patch, null, 2) };
|
|
28233
28622
|
}
|
|
28234
28623
|
if (type === "stripe_checkout" && r.correction?.patch?.length) {
|
|
28235
|
-
return { format: "json", title: `Corrected params${r.correction.verification?.passes ? " (verified by the rules engine)" : ""}`, body: JSON.stringify(r.correction.corrected_params, null, 2) };
|
|
28624
|
+
return { format: "json", title: `Corrected params${r.correction.verification?.passes ? " (verified by the rules engine)" : ""}${placeholders ? "; keep your own values where it says {{\u2026}}" : ""}`, body: JSON.stringify(withPlaceholders(r.correction.corrected_params, placeholders), null, 2) };
|
|
28236
28625
|
}
|
|
28237
28626
|
const perPlan = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
|
|
28238
28627
|
if (type === "page" && perPlan.length >= 2 && ciStatusOf(r) !== "pass") {
|
|
@@ -28264,11 +28653,38 @@ ${r.template.text}` };
|
|
|
28264
28653
|
}
|
|
28265
28654
|
return void 0;
|
|
28266
28655
|
}
|
|
28267
|
-
function
|
|
28656
|
+
function declaredByCheckbox(config) {
|
|
28657
|
+
const sb = config.notices?.sent_by_checkbox;
|
|
28658
|
+
return sb === true ? Object.values(NOTICE_TO_EMAIL) : Array.isArray(sb) ? sb : [];
|
|
28659
|
+
}
|
|
28660
|
+
function sentByNotices(seen) {
|
|
28661
|
+
if ("error" in seen || seen.mode !== "checkbox" || seen.real_sends === "off") return /* @__PURE__ */ new Set();
|
|
28662
|
+
return new Set(seen.enabled);
|
|
28663
|
+
}
|
|
28664
|
+
function noticesCrossCheck(config, seen, target) {
|
|
28665
|
+
const declared = declaredByCheckbox(config);
|
|
28666
|
+
if (!declared.length) return void 0;
|
|
28667
|
+
const base = { name: "Checkbox Notices (site settings)", type: "notice_schedule", target, warnings: [] };
|
|
28668
|
+
if ("error" in seen) {
|
|
28669
|
+
return { ...base, status: "needs_review", label: "Not checked: could not read the site's Notice settings", message: seen.error, fixes: [] };
|
|
28670
|
+
}
|
|
28671
|
+
const real = sentByNotices(seen);
|
|
28672
|
+
const all = config.notices?.sent_by_checkbox === true;
|
|
28673
|
+
const missing = all ? real.size ? [] : declared : declared.filter((k) => !real.has(k));
|
|
28674
|
+
if (!missing.length) {
|
|
28675
|
+
const note = seen.real_sends === "needs_confirmation" ? " (a person still has to confirm sending to real subscribers once in the dashboard: Emails tab)" : seen.real_sends === "test_mode" ? " (test mode: they go to test-run inboxes only)" : "";
|
|
28676
|
+
return { ...base, status: "pass", label: `Checks pass \xB7 Checkbox Notices is on for ${seen.site}${all ? ` (${real.size} kind${real.size === 1 ? "" : "s"} turned on)` : ` and sends the ${declared.length} email${declared.length === 1 ? "" : "s"} checkbox.json declares`}${note}`, fixes: [] };
|
|
28677
|
+
}
|
|
28678
|
+
const why = seen.mode !== "checkbox" ? `${seen.site} is in "You send" mode (Checkbox Notices sends nothing)` : seen.real_sends === "off" || !real.size ? `Checkbox Notices is off for ${seen.site}` : `${seen.site} does not have ${missing.map((k) => `"${k}"`).join(", ")} turned on in Checkbox Notices`;
|
|
28679
|
+
const fix = `checkbox.json says Checkbox Notices sends ${all ? "the required emails" : missing.map((k) => `"${k}"`).join(", ")}, but ${why}. Turn ${missing.length === 1 ? "it" : "them"} on in the dashboard (Emails tab) or with updateNoticeSettings, or remove ${missing.length === 1 ? "it" : "them"} from "notices.sent_by_checkbox" and add the templates to checks.emails.`;
|
|
28680
|
+
return { ...base, status: "fail", label: all ? "Checkbox Notices sends nothing for this site" : `${missing.length} declared email${missing.length === 1 ? " is" : "s are"} not sent by Checkbox Notices`, fix, fixes: [fix] };
|
|
28681
|
+
}
|
|
28682
|
+
function noticeCoverage(config, planName, schedule, opts = {}) {
|
|
28268
28683
|
const plan2 = (config.plans ?? []).find((p) => p.name === planName);
|
|
28269
28684
|
const have = new Set((config.checks.emails ?? []).filter((e) => !e.plan || e.plan === planName || planList(config, e.plan)?.includes(planName)).map((e) => e.kind));
|
|
28270
|
-
const
|
|
28271
|
-
const
|
|
28685
|
+
const declared = declaredByCheckbox(config);
|
|
28686
|
+
const real = opts.notices && !("error" in opts.notices) ? sentByNotices(opts.notices) : void 0;
|
|
28687
|
+
const byCheckbox = new Set(real ? declared.filter((k) => real.has(k)) : declared);
|
|
28272
28688
|
for (const k of byCheckbox) have.add(k);
|
|
28273
28689
|
const yearly = plan2 ? (plan2.interval === "year" ? 12 * (plan2.interval_count ?? 1) : plan2.interval === "month" ? plan2.interval_count ?? 1 : 0) >= 12 : false;
|
|
28274
28690
|
if (yearly && have.has("renewal_reminder")) have.add("annual_terms");
|
|
@@ -28280,8 +28696,8 @@ function noticeCoverage(config, planName, schedule) {
|
|
|
28280
28696
|
const missing = [...required].filter(([k]) => !have.has(k));
|
|
28281
28697
|
if (!required.size) return { status: "not_applicable", label: "No reminder emails required", fixes: [] };
|
|
28282
28698
|
const sent2 = [...required.keys()].filter((k) => byCheckbox.has(k)).length;
|
|
28283
|
-
if (!missing.length) return { status: "pass", label: `Checks pass \xB7 ${required.size} required email${required.size === 1 ? "" : "s"} covered${sent2 ? ` (${sent2} sent by Checkbox Notices, as checkbox.json declares)` : ""}`, fixes: [] };
|
|
28284
|
-
const fixes = missing.map(([k, w]) => `Add a ${EMAIL_LABEL[k]} to checks.emails (kind "${k}"; ${w.days > 0 ? `${w.days} days before: ${w.occasion}` : w.occasion}), or have Checkbox Notices send it.`);
|
|
28699
|
+
if (!missing.length) return { status: "pass", label: `Checks pass \xB7 ${required.size} required email${required.size === 1 ? "" : "s"} covered${sent2 ? ` (${sent2} sent by Checkbox Notices, ${real ? "as the site's Notice settings confirm" : "as checkbox.json declares; --online confirms it against the site"})` : ""}`, fixes: [] };
|
|
28700
|
+
const fixes = missing.map(([k, w]) => `Add ${/^[aeiou]/i.test(EMAIL_LABEL[k]) ? "an" : "a"} ${EMAIL_LABEL[k]} to checks.emails (kind "${k}"; ${w.days > 0 ? `${w.days} days before: ${w.occasion}` : w.occasion}), or have Checkbox Notices send it${declared.includes(k) ? " (checkbox.json says it does, but the site's Notice settings do not send it)" : ""}.`);
|
|
28285
28701
|
return { status: "needs_review", label: `${missing.length} required email${missing.length === 1 ? "" : "s"} not in checkbox.json`, fix: fixes[0], fixes };
|
|
28286
28702
|
}
|
|
28287
28703
|
function unverifiedOffer(r) {
|
|
@@ -28295,8 +28711,8 @@ function unverifiedOffer(r) {
|
|
|
28295
28711
|
if (coupon && open2.length) return coupon.message;
|
|
28296
28712
|
return void 0;
|
|
28297
28713
|
}
|
|
28298
|
-
async function runJob(job, engine, config) {
|
|
28299
|
-
const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: job.target, warnings: [...job.warnings] };
|
|
28714
|
+
async function runJob(job, engine, config, opts = {}) {
|
|
28715
|
+
const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: { ...job.target }, ...job.fromSource ? { fromSource: true } : {}, warnings: [...job.warnings] };
|
|
28300
28716
|
if (job.error) return { ...base, status: "error", label: "Could not run", message: job.error, fixes: [] };
|
|
28301
28717
|
if (job.skip || !job.body) return { ...base, status: "skipped", label: "Skipped", message: job.skip ?? "nothing to check", fixes: [] };
|
|
28302
28718
|
let report;
|
|
@@ -28305,49 +28721,91 @@ async function runJob(job, engine, config) {
|
|
|
28305
28721
|
} catch (err) {
|
|
28306
28722
|
const e = err;
|
|
28307
28723
|
const failure = isAuthError(err) ? "auth" : err instanceof CheckboxError && (!e.statusCode || e.statusCode >= 500 || e.statusCode === 429) ? "api" : void 0;
|
|
28308
|
-
return { ...base, status: "error", label: "Could not run", message: `${e.message}${e.param ? ` (${e.param})` : ""}`, fixes: [], ...failure ? { failure } : {} };
|
|
28724
|
+
return { ...base, status: "error", label: "Could not run", message: `${ciVocabulary(e.message)}${e.param && !failure && /^merchant\./.test(e.param) ? "" : e.param ? ` (${e.param})` : ""}`, fixes: [], ...failure ? { failure } : {} };
|
|
28309
28725
|
}
|
|
28310
|
-
if (job.type === "notice_schedule") return { ...base, ...noticeCoverage(config, job.plan, report), report };
|
|
28311
|
-
|
|
28726
|
+
if (job.type === "notice_schedule") return { ...base, ...noticeCoverage(config, job.plan, report, opts), report };
|
|
28727
|
+
const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
|
|
28728
|
+
for (const w of report.warnings ?? []) {
|
|
28729
|
+
const text = ciVocabulary(w.message);
|
|
28730
|
+
base.warnings.push(text);
|
|
28731
|
+
if (ALERT_CODES.has(w.code)) alerts.push(text);
|
|
28732
|
+
}
|
|
28733
|
+
if (alerts.length) Object.assign(base, { alerts });
|
|
28312
28734
|
const status = ciStatusOf(report);
|
|
28313
|
-
const
|
|
28735
|
+
const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
|
|
28736
|
+
let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
|
|
28737
|
+
const pricingPage = job.type === "page" && job.body?.page_kind === "pricing";
|
|
28738
|
+
if (pricingPage) fixes = pricingPageFixes(fixes);
|
|
28314
28739
|
const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
|
|
28315
28740
|
const unread = job.type === "stripe_checkout" && unreadText.length > 0 && (status === "fail" || status === "needs_review" || status === "pass" && unreadText.some((p) => /^(?:custom_text|consent_collection|discounts|subscription_data\.trial)/.test(p)));
|
|
28316
28741
|
if (unread) {
|
|
28317
28742
|
fixes.splice(0, fixes.length, `${unreadText.slice(0, 3).join(", ")} ${unreadText.length === 1 ? "is an expression" : "are expressions"} Checkbox cannot read statically (e.g. a ternary or PLANS.monthly.*), so the checks that depend on ${unreadText.length === 1 ? "it were" : "them were"} not checked (no fix is given for a value Checkbox did not read). Use "module" (a function returning the exact params for each plan) or "file" instead of "source".`);
|
|
28318
28743
|
}
|
|
28744
|
+
const literal = [];
|
|
28745
|
+
if (unread) {
|
|
28746
|
+
const consentRead = !(job.unresolved ?? []).some((p) => /^(?:consent_collection|\.\.\.)/.test(p));
|
|
28747
|
+
const consentFails = (report.checks ?? []).some((c) => c.check_id === "signup.consent.affirmative" && (c.status === "fail" || c.compliance_status === "needs_fix"));
|
|
28748
|
+
if (consentRead && consentFails && params?.consent_collection?.terms_of_service !== "required") literal.push(stripeConsentFix("Add one unchecked checkbox", params));
|
|
28749
|
+
fixes.unshift(...literal);
|
|
28750
|
+
}
|
|
28319
28751
|
const offer = !unread && status === "needs_review" && job.type === "stripe_checkout" ? unverifiedOffer(report) : void 0;
|
|
28320
28752
|
if (offer) {
|
|
28321
28753
|
fixes.unshift(
|
|
28322
28754
|
`What the shopper is charged first is not verified (${offer.replace(/\.$/, "")}). Declare the discount on the plan in checkbox.json (intro_percent_off, intro_amount_off or intro_price, with intro_periods) and state it in custom_text.submit, or remove the discount. This fails the run (exit 2) until it is resolved.`
|
|
28323
28755
|
);
|
|
28324
28756
|
}
|
|
28325
|
-
|
|
28326
|
-
|
|
28757
|
+
if (job.locate && (status === "fail" || status === "needs_review")) {
|
|
28758
|
+
const named = (report.checks ?? []).filter((c) => c.status === "fail" || c.status === "needs_review").flatMap((c) => failingCards(c.explanation));
|
|
28759
|
+
const needles = named.flatMap((n) => [n.split(", ")[0], ...n.split(", ").slice(1)]);
|
|
28760
|
+
const line2 = needles.length ? job.locate(needles) : void 0;
|
|
28761
|
+
if (line2) base.target = { ...base.target, line: line2 };
|
|
28762
|
+
}
|
|
28763
|
+
const declaredCoupon = !unread && status === "pass" && job.type === "stripe_checkout" ? (report.warnings ?? []).find((w) => w.code === "coupon_unverified" && /merchant\.intro_\* and are not verified/.test(w.message)) : void 0;
|
|
28764
|
+
if (declaredCoupon) {
|
|
28765
|
+
const id = /discounts use (coupon|promotion code) (\S+?),/.exec(declaredCoupon.message);
|
|
28766
|
+
const what = id ? `${id[1]} ${id[2]}` : "the coupon";
|
|
28767
|
+
fixes.unshift(
|
|
28768
|
+
engine.mode === "offline" ? `The intro price is declared in checkbox.json, not verified: offline, Checkbox cannot read ${what} from Stripe. Run with --online (CHECKBOX_SECRET_KEY, and the site's Stripe key connected) so Checkbox reads it and compares its percent / amount off and duration with the declared intro.` : `The intro price is declared in checkbox.json, not verified: Checkbox could not read ${what} through the site's connected Stripe key (is the site known, via --site or CHECKBOX_SITE, and does the key have Coupons: Read?). Until it is read, its percent / amount off and duration are not compared with the declared intro.`
|
|
28769
|
+
);
|
|
28770
|
+
}
|
|
28771
|
+
const finalStatus = unread ? literal.length ? "fail" : "needs_review" : declaredCoupon ? "needs_review" : status;
|
|
28772
|
+
const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders);
|
|
28773
|
+
if (corrected?.format === "json" && job.placeholders) {
|
|
28774
|
+
const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
|
|
28775
|
+
if (unset.length) Object.assign(base, { alerts: [...unset, ...base.alerts ?? []] });
|
|
28776
|
+
}
|
|
28327
28777
|
const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked") || status === "needs_review" && job.type === "stripe_checkout" && (!!job.strict || !!offer);
|
|
28328
28778
|
return {
|
|
28329
28779
|
...base,
|
|
28330
28780
|
status: finalStatus,
|
|
28331
28781
|
...blocking ? { blocking: true } : {},
|
|
28332
|
-
label: unread ? `Not checked: ${unreadText[0]} is an expression` : report.compliance?.label || DEFAULT_LABEL[status],
|
|
28782
|
+
label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
|
|
28333
28783
|
...fixes[0] ? { fix: fixes[0] } : {},
|
|
28334
28784
|
fixes,
|
|
28335
28785
|
...corrected ? { corrected } : {},
|
|
28336
28786
|
report
|
|
28337
28787
|
};
|
|
28338
28788
|
}
|
|
28789
|
+
function countedLabel(label, r, adjust) {
|
|
28790
|
+
const m = /^(\d+) fix(?:es)? needed/.exec(label);
|
|
28791
|
+
if (!m) return label;
|
|
28792
|
+
const n = (adjust ? adjust(fixesOf(r, true)) : fixesOf(r, true)).length;
|
|
28793
|
+
if (!n || n === Number(m[1])) return label;
|
|
28794
|
+
return label.replace(m[0], `${n} fix${n === 1 ? "" : "es"} needed`);
|
|
28795
|
+
}
|
|
28339
28796
|
function exitCodeOf(checks, strict) {
|
|
28340
28797
|
if (checks.some((c) => c.failure === "auth")) return { status: "error", exit: EXIT.auth };
|
|
28341
28798
|
if (checks.some((c) => c.failure === "api")) return { status: "error", exit: EXIT.error };
|
|
28342
28799
|
if (checks.some((c) => c.status === "error")) return { status: "error", exit: EXIT.usage };
|
|
28343
28800
|
if (checks.some((c) => c.status === "fail")) return { status: "fail", exit: EXIT.fail };
|
|
28344
28801
|
if (checks.some((c) => c.status === "needs_review")) return { status: "needs_review", exit: strict || checks.some((c) => c.blocking) ? EXIT.needs_review : EXIT.ok };
|
|
28802
|
+
if (!checks.some((c) => c.status === "pass")) return { status: "skipped", exit: EXIT.needs_review };
|
|
28345
28803
|
return { status: "pass", exit: EXIT.ok };
|
|
28346
28804
|
}
|
|
28347
28805
|
function mergeSourcePatches(checks) {
|
|
28348
28806
|
const groups = /* @__PURE__ */ new Map();
|
|
28349
28807
|
for (const c of checks) {
|
|
28350
|
-
if (c.type !== "stripe_checkout" || c.corrected?.format !== "json" || !c.target.line) continue;
|
|
28808
|
+
if (c.type !== "stripe_checkout" || c.corrected?.format !== "json" || !c.target.line || !c.fromSource) continue;
|
|
28351
28809
|
const key = `${c.target.file}:${c.target.line}`;
|
|
28352
28810
|
groups.set(key, [...groups.get(key) ?? [], c]);
|
|
28353
28811
|
}
|
|
@@ -28363,9 +28821,16 @@ function mergeSourcePatches(checks) {
|
|
|
28363
28821
|
}
|
|
28364
28822
|
}
|
|
28365
28823
|
}
|
|
28824
|
+
function nothingCheckedMessage(run2) {
|
|
28825
|
+
const urlSkipped = run2.checks.some((c) => c.status === "skipped" && /^URL pages/.test(c.message ?? ""));
|
|
28826
|
+
const how = urlSkipped ? run2.mode === "offline" ? ' Page entries with a "url" are fetched only with --online and an API key (CHECKBOX_SECRET_KEY); offline, point them at the built HTML with "file" instead.' : ' Page entries with a "url" need a key that can read that site (a live key for a live site); or point them at the built HTML with "file".' : ' Add a check checkbox.json can run (a "file" page, a disclosure, Stripe Checkout params or an email).';
|
|
28827
|
+
return `Nothing was checked: every check was skipped or does not apply, so this run is not a pass (exit 2).${how}`;
|
|
28828
|
+
}
|
|
28366
28829
|
async function runCi(jobs, engine, opts) {
|
|
28367
28830
|
const checks = [...opts.extra ?? []];
|
|
28368
|
-
for (const j of jobs) checks.push(await runJob(j, engine, opts.config));
|
|
28831
|
+
for (const j of jobs) checks.push(await runJob(j, engine, opts.config, { ...opts.notices ? { notices: opts.notices } : {} }));
|
|
28832
|
+
const cross = opts.notices ? noticesCrossCheck(opts.config, opts.notices, opts.noticesTarget ?? { file: opts.configPath }) : void 0;
|
|
28833
|
+
if (cross) checks.push(cross);
|
|
28369
28834
|
mergeSourcePatches(checks);
|
|
28370
28835
|
const summary = { pass: 0, fail: 0, needs_review: 0, not_applicable: 0, skipped: 0, error: 0 };
|
|
28371
28836
|
for (const c of checks) summary[c.status]++;
|
|
@@ -28385,7 +28850,7 @@ async function runCi(jobs, engine, opts) {
|
|
|
28385
28850
|
disclaimer: CI_DISCLAIMER
|
|
28386
28851
|
};
|
|
28387
28852
|
}
|
|
28388
|
-
var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL;
|
|
28853
|
+
var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
|
|
28389
28854
|
var init_runner = __esm({
|
|
28390
28855
|
"src/ci/runner.ts"() {
|
|
28391
28856
|
"use strict";
|
|
@@ -28422,16 +28887,205 @@ var init_runner = __esm({
|
|
|
28422
28887
|
cancellation_confirmation: "cancellation confirmation"
|
|
28423
28888
|
};
|
|
28424
28889
|
DEFAULT_LABEL = { pass: "Checks pass", fail: "Fixes needed", needs_review: "Needs review", not_applicable: "Not required for this plan" };
|
|
28890
|
+
ALERT_CODES = /* @__PURE__ */ new Set(["coupon_unverified", "coupon_read", "merchant_trial_ignored", "merchant_price_ignored", "price_not_in_site_plans", "price_from_setup_plan", "plan_terms_conflict", "trial_mismatch", "intro_mismatch"]);
|
|
28425
28891
|
}
|
|
28426
28892
|
});
|
|
28427
28893
|
|
|
28428
|
-
// src/ci/
|
|
28429
|
-
|
|
28430
|
-
|
|
28431
|
-
"
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
28976
|
+
}
|
|
28977
|
+
var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
|
|
28978
|
+
var init_report = __esm({
|
|
28979
|
+
"src/ci/report.ts"() {
|
|
28432
28980
|
"use strict";
|
|
28433
|
-
|
|
28434
|
-
|
|
28981
|
+
init_output();
|
|
28982
|
+
init_runner();
|
|
28983
|
+
TYPE_LABEL = {
|
|
28984
|
+
stripe_checkout: "Stripe Checkout",
|
|
28985
|
+
disclosure: "Disclosure",
|
|
28986
|
+
page: "Page",
|
|
28987
|
+
email: "Email",
|
|
28988
|
+
notice_schedule: "Notice schedule"
|
|
28989
|
+
};
|
|
28990
|
+
where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
|
|
28991
|
+
escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
28992
|
+
escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
28993
|
+
cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
|
28994
|
+
STATUS_MD = { pass: "\u2705", fail: "\u274C", needs_review: "\u26A0\uFE0F", error: "\u274C", skipped: "\u23ED\uFE0F", not_applicable: "\u2796" };
|
|
28995
|
+
}
|
|
28996
|
+
});
|
|
28997
|
+
|
|
28998
|
+
// src/commands/plan/lib.ts
|
|
28999
|
+
import { dirname as dirname6, resolve as resolve6 } from "node:path";
|
|
29000
|
+
async function resolveSite(ctx, flag, client) {
|
|
29001
|
+
const explicit = flag ?? ctx.env.CHECKBOX_SITE;
|
|
29002
|
+
if (explicit) {
|
|
29003
|
+
if (!SITE_RE.test(explicit) && !/^[0-9a-f-]{36}$/i.test(explicit)) throw new UsageError(`--site must be a site id (site_\u2026), got "${explicit}"`);
|
|
29004
|
+
return explicit;
|
|
29005
|
+
}
|
|
29006
|
+
const projects = ctx.config.load().init?.projects ?? {};
|
|
29007
|
+
for (let dir = resolve6(ctx.cwd); ; dir = dirname6(dir)) {
|
|
29008
|
+
const site = projects[dir]?.site;
|
|
29009
|
+
if (site) return site;
|
|
29010
|
+
if (dirname6(dir) === dir) break;
|
|
29011
|
+
}
|
|
29012
|
+
const list2 = await client.sdk.sites.list({ limit: 3 });
|
|
29013
|
+
const sites = list2?.data ?? [];
|
|
29014
|
+
if (sites.length === 1) return sites[0].id;
|
|
29015
|
+
throw new UsageError(
|
|
29016
|
+
sites.length ? `This account has several sites; pass --site <id> (e.g. ${sites.map((s2) => s2.id).join(", ")}) or set CHECKBOX_SITE.` : "No site yet: run `checkbox init` in your project first (it creates the site and remembers it here)."
|
|
29017
|
+
);
|
|
29018
|
+
}
|
|
29019
|
+
function nextLine(n) {
|
|
29020
|
+
const text = n.description ?? n.message ?? n.type;
|
|
29021
|
+
return `${text}${n.command ? `
|
|
29022
|
+
$ ${n.command}` : n.endpoint ? `
|
|
29023
|
+
${n.endpoint}` : ""}`;
|
|
29024
|
+
}
|
|
29025
|
+
function renderPlan(p, c, opts = {}) {
|
|
29026
|
+
const head = `${c.bold(`Compliance plan ${p.site}`)} \u2014 ${p.compliance?.label ? p.status === "compliant" ? c.green(p.compliance.label) : p.status === "action_required" ? c.red(p.compliance.label) : c.yellow(p.compliance.label) : statusColor(p.status, c)}`;
|
|
29027
|
+
const tasks = opts.all ? p.tasks : p.tasks.filter((t) => t.status !== "done" && t.status !== "wont_fix");
|
|
29028
|
+
const lines = [head, p.summary, ""];
|
|
29029
|
+
for (const t of tasks) {
|
|
29030
|
+
lines.push(`${STATUS_MARK[t.status] ?? "[ ]"} ${String(t.order).padStart(2)}. ${t.title} ${c.dim(`(${t.priority}, ${t.id})`)}`);
|
|
29031
|
+
if (t.status === "manual" && t.next_action?.type === "ask_human_to_confirm") lines.push(` ${c.yellow("waiting for the human to confirm:")} ${t.next_action.description}`);
|
|
29032
|
+
else if (t.status === "manual" && t.question) lines.push(` ${c.yellow("ask the human:")} ${t.question.text} ${c.dim(`\u2192 checkbox answer ${t.id} <${t.question.options?.join("|") ?? "yes|no"}>`)}`);
|
|
29033
|
+
else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
|
|
29034
|
+
else if (t.blocked_by.length) lines.push(c.dim(` after ${t.blocked_by.join(", ")}`));
|
|
29035
|
+
else if (t.status === "open" || t.status === "in_progress") lines.push(c.dim(` ${t.where.file_hint ?? t.where.url ?? t.where.stripe_object ?? t.where.kind} \xB7 ${t.change.type} \xB7 checkbox fix ${t.id}`));
|
|
29036
|
+
}
|
|
29037
|
+
const hidden = p.tasks.length - tasks.length;
|
|
29038
|
+
if (hidden) lines.push(c.dim(` \u2026 ${hidden} done / declined (--all to show)`));
|
|
29039
|
+
lines.push("", `${c.cyan("next")} ${nextLine(p.next_action)}`);
|
|
29040
|
+
return lines;
|
|
29041
|
+
}
|
|
29042
|
+
function statusColor(s2, c) {
|
|
29043
|
+
return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
|
|
29044
|
+
}
|
|
29045
|
+
function renderVerification(v, c) {
|
|
29046
|
+
const mark = v.status === "passed" ? c.green("passed") : v.status === "failed" ? c.red("failed") : v.status === "passed_with_gaps" ? c.yellow("passed with gaps (not verified)") : v.status === "passed_with_notes" ? c.yellow("passed with notes (a check outside your control needs review)") : v.status === "not_checked" ? c.yellow("not checked yet (missing evidence, nothing to change)") : v.status === "pending_live_check" ? c.yellow("live check pending (content passes; waiting for a real send)") : c.yellow("pending");
|
|
29047
|
+
const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
|
|
29048
|
+
const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
|
|
29049
|
+
if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
|
|
29050
|
+
if (v.scan) lines.push(c.dim(` confirming scan ${v.scan}`));
|
|
29051
|
+
for (const d of v.diff.slice(0, 12)) lines.push(` ${d.observed === "fail" ? c.red("\u2717") : c.yellow("?")} ${d.check_id}: expected ${d.expected}, observed ${d.observed} \u2014 ${d.detail}`);
|
|
29052
|
+
if (v.diff.length > 12) lines.push(c.dim(` \u2026 ${v.diff.length - 12} more`));
|
|
29053
|
+
lines.push(`${c.cyan("next")} ${nextLine(v.next_action)}`);
|
|
29054
|
+
return lines;
|
|
29055
|
+
}
|
|
29056
|
+
var SITE_RE, STATUS_MARK;
|
|
29057
|
+
var init_lib = __esm({
|
|
29058
|
+
"src/commands/plan/lib.ts"() {
|
|
29059
|
+
"use strict";
|
|
29060
|
+
init_args();
|
|
29061
|
+
SITE_RE = /^site_[0-9a-f]{32}$/;
|
|
29062
|
+
STATUS_MARK = { open: "[ ]", in_progress: "[~]", verifying: "[?]", done: "[x]", wont_fix: "[-]", manual: "[!]" };
|
|
29063
|
+
}
|
|
29064
|
+
});
|
|
29065
|
+
|
|
29066
|
+
// src/ci/workflow.ts
|
|
29067
|
+
import { execFileSync } from "node:child_process";
|
|
29068
|
+
import { existsSync as existsSync7, readFileSync as readFileSync9 } from "node:fs";
|
|
29069
|
+
import { join as join8 } from "node:path";
|
|
29070
|
+
function workflowYaml(needs = {}) {
|
|
29071
|
+
const node = !!(needs.install || needs.build);
|
|
29072
|
+
const steps = [" - uses: actions/checkout@v4"];
|
|
29073
|
+
if (node) {
|
|
29074
|
+
steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 20");
|
|
29075
|
+
if (needs.install) steps.push(` # ${needs.why?.install ?? 'checkbox.json "module" entries import your code and its dependencies.'}`, ` - run: ${needs.install}`);
|
|
29076
|
+
if (needs.build) steps.push(` # ${needs.why?.build ?? 'checkbox.json "pages" read built HTML, which a fresh clone does not have.'}`, ` - run: ${needs.build}`);
|
|
29077
|
+
} else {
|
|
29078
|
+
steps.push(' # Only needed when checkbox.json uses "module" entries that import your dependencies:', " # - run: npm ci");
|
|
29079
|
+
}
|
|
29080
|
+
steps.push(" - uses: checkbox-site/checkbox@v1", " # with:", " # strict: true # also fail when a check needs review");
|
|
29081
|
+
if (needs.urlPages) {
|
|
29082
|
+
steps.push(
|
|
29083
|
+
' # args: --online # "url" pages are fetched by the API: needs a LIVE key (ck_live_\u2026) on an',
|
|
29084
|
+
' # # activated account. Without one, list the built page as "file" instead.'
|
|
29085
|
+
);
|
|
29086
|
+
} else steps.push(" # args: --online # URL pages via the API; needs the env below");
|
|
29087
|
+
steps.push(" # env:", " # CHECKBOX_SECRET_KEY: ${{ secrets.CHECKBOX_SECRET_KEY }}");
|
|
29088
|
+
return `# Checkbox: automatic-renewal law checks on every pull request (https://checkbox.site/docs/guides/ci).
|
|
28435
29089
|
# Runs the checks listed in checkbox.json with the Checkbox rules engine, offline (no key, no network), annotates the
|
|
28436
29090
|
# lines that need a fix and writes a summary with the corrected params / text to the job page.
|
|
28437
29091
|
name: Checkbox
|
|
@@ -28449,35 +29103,85 @@ jobs:
|
|
|
28449
29103
|
runs-on: ubuntu-latest
|
|
28450
29104
|
timeout-minutes: 10
|
|
28451
29105
|
steps:
|
|
28452
|
-
|
|
28453
|
-
# Only needed when checkbox.json uses "module" entries that import your dependencies:
|
|
28454
|
-
# - run: npm ci
|
|
28455
|
-
- uses: checkbox-site/checkbox@v1
|
|
28456
|
-
# with:
|
|
28457
|
-
# strict: true # also fail when a check needs review
|
|
28458
|
-
# args: --online # URL pages via the API; needs the env below
|
|
28459
|
-
# env:
|
|
28460
|
-
# CHECKBOX_SECRET_KEY: \${{ secrets.CHECKBOX_SECRET_KEY }}
|
|
29106
|
+
${steps.join("\n")}
|
|
28461
29107
|
`;
|
|
29108
|
+
}
|
|
29109
|
+
function installCommand(root) {
|
|
29110
|
+
if (existsSync7(join8(root, "pnpm-lock.yaml"))) return "corepack enable && pnpm install --frozen-lockfile";
|
|
29111
|
+
if (existsSync7(join8(root, "yarn.lock"))) return "corepack enable && yarn install --immutable";
|
|
29112
|
+
if (existsSync7(join8(root, "bun.lockb")) || existsSync7(join8(root, "bun.lock"))) return "npx --yes bun install --frozen-lockfile";
|
|
29113
|
+
if (existsSync7(join8(root, "package-lock.json")) || existsSync7(join8(root, "npm-shrinkwrap.json"))) return "npm ci";
|
|
29114
|
+
return "npm install";
|
|
29115
|
+
}
|
|
29116
|
+
function runScript(root, script) {
|
|
29117
|
+
if (existsSync7(join8(root, "pnpm-lock.yaml"))) return `pnpm run ${script}`;
|
|
29118
|
+
if (existsSync7(join8(root, "yarn.lock"))) return `yarn run ${script}`;
|
|
29119
|
+
return `npm run ${script}`;
|
|
29120
|
+
}
|
|
29121
|
+
function notInClone(root, files) {
|
|
29122
|
+
if (!files.length) return [];
|
|
29123
|
+
let tracked = null;
|
|
29124
|
+
try {
|
|
29125
|
+
const out = execFileSync("git", ["ls-files", "-z", "--", ...files], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
|
|
29126
|
+
tracked = new Set(out.split("\0").filter(Boolean));
|
|
29127
|
+
} catch {
|
|
29128
|
+
tracked = null;
|
|
29129
|
+
}
|
|
29130
|
+
return files.filter((f) => tracked ? !tracked.has(f.replace(/^\.\//, "")) : !existsSync7(join8(root, f)));
|
|
29131
|
+
}
|
|
29132
|
+
function workflowNeeds(root, config) {
|
|
29133
|
+
const checks = config.checks ?? {};
|
|
29134
|
+
const list2 = (k) => Array.isArray(checks[k]) ? checks[k] : [];
|
|
29135
|
+
const modules = [...list2("stripe_checkout"), ...list2("emails"), ...list2("disclosures")].some((e) => typeof e.module === "string" && !!e.module);
|
|
29136
|
+
const pageFiles = list2("pages").flatMap((e) => typeof e.file === "string" && !e.file.startsWith("TODO") ? [e.file] : []);
|
|
29137
|
+
const urlPages = list2("pages").some((e) => typeof e.url === "string");
|
|
29138
|
+
let pkg = null;
|
|
29139
|
+
try {
|
|
29140
|
+
pkg = JSON.parse(readFileSync9(join8(root, "package.json"), "utf8"));
|
|
29141
|
+
} catch {
|
|
29142
|
+
pkg = null;
|
|
29143
|
+
}
|
|
29144
|
+
const built = notInClone(root, pageFiles);
|
|
29145
|
+
const buildScript = pkg?.scripts?.build ? "build" : void 0;
|
|
29146
|
+
const needs = { why: {} };
|
|
29147
|
+
if (built.length && buildScript) {
|
|
29148
|
+
needs.build = runScript(root, buildScript);
|
|
29149
|
+
needs.why.build = `checkbox.json "pages" read ${built.slice(0, 2).join(", ")}${built.length > 2 ? ", \u2026" : ""}, built here (not in git).`;
|
|
29150
|
+
}
|
|
29151
|
+
if (pkg && (modules || needs.build)) {
|
|
29152
|
+
needs.install = installCommand(root);
|
|
29153
|
+
needs.why.install = modules ? 'checkbox.json "module" entries import your code and its dependencies.' : "The build needs your dependencies.";
|
|
29154
|
+
}
|
|
29155
|
+
if (urlPages) needs.urlPages = true;
|
|
29156
|
+
return needs;
|
|
29157
|
+
}
|
|
29158
|
+
var WORKFLOW_PATH, WORKFLOW_YAML;
|
|
29159
|
+
var init_workflow = __esm({
|
|
29160
|
+
"src/ci/workflow.ts"() {
|
|
29161
|
+
"use strict";
|
|
29162
|
+
WORKFLOW_PATH = ".github/workflows/checkbox.yml";
|
|
29163
|
+
WORKFLOW_YAML = workflowYaml();
|
|
28462
29164
|
}
|
|
28463
29165
|
});
|
|
28464
29166
|
|
|
28465
29167
|
// src/commands/ci/index.ts
|
|
28466
29168
|
var ci_exports = {};
|
|
28467
29169
|
__export(ci_exports, {
|
|
29170
|
+
attachSiteForCoupons: () => attachSiteForCoupons,
|
|
29171
|
+
couponIdsIn: () => couponIdsIn,
|
|
28468
29172
|
default: () => ci_default,
|
|
28469
29173
|
touchesConfig: () => touchesConfig,
|
|
28470
29174
|
undiffableInputs: () => undiffableInputs
|
|
28471
29175
|
});
|
|
28472
|
-
import { execFileSync } from "node:child_process";
|
|
28473
|
-
import { appendFileSync, existsSync as
|
|
29176
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
29177
|
+
import { appendFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
28474
29178
|
import { createInterface } from "node:readline";
|
|
28475
|
-
import { dirname as
|
|
29179
|
+
import { dirname as dirname7, join as join9, relative as relative5, resolve as resolve7 } from "node:path";
|
|
28476
29180
|
function gitChanged(cwd, base) {
|
|
28477
29181
|
try {
|
|
28478
|
-
const root =
|
|
28479
|
-
const out =
|
|
28480
|
-
return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) =>
|
|
29182
|
+
const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
29183
|
+
const out = execFileSync2("git", ["diff", "--name-only", `${base}...HEAD`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
29184
|
+
return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve7(root, f)) };
|
|
28481
29185
|
} catch (err) {
|
|
28482
29186
|
const msg2 = String(err.stderr || err.message).trim().split("\n")[0];
|
|
28483
29187
|
return `git diff ${base}...HEAD failed (${msg2}); fetch the base branch (actions/checkout with fetch-depth: 0)`;
|
|
@@ -28487,13 +29191,13 @@ function undiffableInputs(loaded, jobs, gitRoot) {
|
|
|
28487
29191
|
const need = [];
|
|
28488
29192
|
const out = [];
|
|
28489
29193
|
for (const e of loaded.config.checks.pages ?? []) {
|
|
28490
|
-
if (e.source) need.push({ path:
|
|
29194
|
+
if (e.source) need.push({ path: resolve7(loaded.dir, e.source), label: `pages "${e.name}" source ${e.source}` });
|
|
28491
29195
|
else if (e.url) out.push(`pages "${e.name}" is a URL with no "source" file to diff`);
|
|
28492
|
-
else if (e.file) need.push({ path:
|
|
29196
|
+
else if (e.file) need.push({ path: resolve7(loaded.dir, e.file), label: `pages "${e.name}" file ${e.file}` });
|
|
28493
29197
|
}
|
|
28494
29198
|
for (const j of jobs) {
|
|
28495
29199
|
if (j.type === "page") continue;
|
|
28496
|
-
for (const f of j.inputs) need.push({ path:
|
|
29200
|
+
for (const f of j.inputs) need.push({ path: resolve7(loaded.dir, f), label: `"${j.name}" reads ${f}` });
|
|
28497
29201
|
}
|
|
28498
29202
|
if (!need.length) return out;
|
|
28499
29203
|
const real = (p) => {
|
|
@@ -28508,8 +29212,8 @@ function undiffableInputs(loaded, jobs, gitRoot) {
|
|
|
28508
29212
|
let tracked;
|
|
28509
29213
|
try {
|
|
28510
29214
|
const rels = [...new Set(need.map((n) => relative5(gitRoot, n.path)))];
|
|
28511
|
-
const listed =
|
|
28512
|
-
tracked = new Set(listed.split("\0").filter(Boolean).map((f) =>
|
|
29215
|
+
const listed = execFileSync2("git", ["ls-files", "-z", "--", ...rels], { cwd: gitRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
29216
|
+
tracked = new Set(listed.split("\0").filter(Boolean).map((f) => resolve7(gitRoot, f)));
|
|
28513
29217
|
} catch (err) {
|
|
28514
29218
|
return `git ls-files failed (${String(err.stderr || err.message).trim().split("\n")[0]})`;
|
|
28515
29219
|
}
|
|
@@ -28521,7 +29225,7 @@ function touchesConfig(loaded, jobs, changed) {
|
|
|
28521
29225
|
if (set.has(loaded.path)) return true;
|
|
28522
29226
|
const codeChanged = changed.some((f) => /\.(?:[cm]?[jt]sx?)$/.test(f));
|
|
28523
29227
|
for (const j of jobs) {
|
|
28524
|
-
for (const f of j.inputs) if (set.has(
|
|
29228
|
+
for (const f of j.inputs) if (set.has(resolve7(loaded.dir, f))) return true;
|
|
28525
29229
|
}
|
|
28526
29230
|
const hasModules = [...loaded.config.checks.stripe_checkout ?? [], ...loaded.config.checks.emails ?? []].some((e) => "module" in e && e.module);
|
|
28527
29231
|
return hasModules && codeChanged;
|
|
@@ -28546,15 +29250,26 @@ async function runInit(ctx, argv) {
|
|
|
28546
29250
|
const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
|
|
28547
29251
|
if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
|
|
28548
29252
|
const explicit = str(p.flags, "config");
|
|
28549
|
-
const configPath = explicit ?
|
|
28550
|
-
const workflowPath =
|
|
29253
|
+
const configPath = explicit ? resolve7(ctx.cwd, explicit) : CONFIG_FILES.map((f) => join9(ctx.cwd, f)).find((f) => existsSync8(f)) ?? join9(ctx.cwd, CONFIG_FILES[0]);
|
|
29254
|
+
const workflowPath = join9(ctx.cwd, WORKFLOW_PATH);
|
|
28551
29255
|
const force = bool(p.flags, "force") === true;
|
|
28552
29256
|
const withWorkflow = bool(p.flags, "workflow") !== false;
|
|
28553
|
-
const
|
|
28554
|
-
|
|
29257
|
+
const projects = ctx.config.load().init?.projects ?? {};
|
|
29258
|
+
let facts = {};
|
|
29259
|
+
for (let dir = resolve7(ctx.cwd); ; dir = dirname7(dir)) {
|
|
29260
|
+
if (projects[dir]?.facts) {
|
|
29261
|
+
facts = projects[dir].facts;
|
|
29262
|
+
break;
|
|
29263
|
+
}
|
|
29264
|
+
if (dirname7(dir) === dir) break;
|
|
29265
|
+
}
|
|
29266
|
+
const s2 = scaffoldConfig(ctx.cwd, ctx.env, facts);
|
|
29267
|
+
const actionFor = (f) => existsSync8(f) ? force ? "overwrite" : "keep" : "create";
|
|
28555
29268
|
const files = [{ path: configPath, content: `${JSON.stringify(s2.config, null, 2)}
|
|
28556
29269
|
`, action: actionFor(configPath) }];
|
|
28557
|
-
|
|
29270
|
+
const needs = workflowNeeds(ctx.cwd, s2.config);
|
|
29271
|
+
const workflowSteps = [...needs.install ? [needs.install] : [], ...needs.build ? [needs.build] : []];
|
|
29272
|
+
if (withWorkflow) files.push({ path: workflowPath, content: workflowYaml(needs), action: actionFor(workflowPath) });
|
|
28558
29273
|
const toWrite = files.filter((f) => f.action !== "keep");
|
|
28559
29274
|
const dry = bool(p.flags, "dry-run") === true;
|
|
28560
29275
|
let write = !dry && toWrite.length > 0;
|
|
@@ -28566,7 +29281,7 @@ async function runInit(ctx, argv) {
|
|
|
28566
29281
|
}
|
|
28567
29282
|
if (write) {
|
|
28568
29283
|
for (const f of toWrite) {
|
|
28569
|
-
mkdirSync2(
|
|
29284
|
+
mkdirSync2(dirname7(f.path), { recursive: true });
|
|
28570
29285
|
writeFileSync2(f.path, f.content);
|
|
28571
29286
|
}
|
|
28572
29287
|
}
|
|
@@ -28577,6 +29292,7 @@ async function runInit(ctx, argv) {
|
|
|
28577
29292
|
kept: files.filter((f) => f.action === "keep").map((f) => relName(f.path)),
|
|
28578
29293
|
...write ? {} : { would_write: toWrite.map((f) => relName(f.path)) },
|
|
28579
29294
|
found: s2.found,
|
|
29295
|
+
...withWorkflow ? { workflow_steps: workflowSteps } : {},
|
|
28580
29296
|
todos: s2.todos,
|
|
28581
29297
|
config: s2.config,
|
|
28582
29298
|
next_action: s2.todos.length ? { type: "edit_config", message: `Fill in the ${s2.todos.length} TODO item(s) in ${relName(configPath)} (checkbox ci refuses TODO values), then run \`checkbox ci\` and fix what it reports.` } : { type: "run_ci", message: "Run `checkbox ci` locally, fix what it reports, then commit checkbox.json and the workflow." }
|
|
@@ -28586,6 +29302,8 @@ async function runInit(ctx, argv) {
|
|
|
28586
29302
|
for (const f of files) lines.push(`${f.action === "keep" ? ctx.out.c.dim("kept ") : write ? ctx.out.c.green(f.action === "create" ? "created" : "wrote ") : ctx.out.c.yellow("would write")} ${relName(f.path)}${f.action === "keep" ? ctx.out.c.dim(" (exists; --force overwrites)") : ""}`);
|
|
28587
29303
|
if (!write && toWrite.length && !dry) lines.push(ctx.out.c.dim("Nothing written: add --yes to write without asking."));
|
|
28588
29304
|
if (s2.found.length) lines.push("", "Found:", ...s2.found.map((f) => ` - ${f}`));
|
|
29305
|
+
if (withWorkflow && workflowSteps.length) lines.push("", `The workflow runs ${workflowSteps.map((c) => `\`${c}\``).join(" and ")} before the checks, so they work on a fresh clone.`);
|
|
29306
|
+
if (needs.urlPages) lines.push("", 'Note: "url" pages are fetched by the API (checkbox ci --online), which needs a LIVE key (ck_live_\u2026) on an activated account. Without one, list the built page as "file".');
|
|
28589
29307
|
if (s2.todos.length) lines.push("", "To do:", ...s2.todos.map((t) => ` - ${t}`));
|
|
28590
29308
|
lines.push("", `${ctx.out.c.cyan("Next:")} ${result.next_action.message}`);
|
|
28591
29309
|
if (!write) lines.push("", ctx.out.c.dim(`${relName(configPath)}:`), ...JSON.stringify(s2.config, null, 2).split("\n").map((l) => ctx.out.c.dim(` ${l}`)));
|
|
@@ -28593,7 +29311,37 @@ async function runInit(ctx, argv) {
|
|
|
28593
29311
|
});
|
|
28594
29312
|
return EXIT.ok;
|
|
28595
29313
|
}
|
|
28596
|
-
|
|
29314
|
+
async function readNotices(ctx, client, siteFlag) {
|
|
29315
|
+
let site;
|
|
29316
|
+
try {
|
|
29317
|
+
site = await resolveSite(ctx, siteFlag, client);
|
|
29318
|
+
} catch (err) {
|
|
29319
|
+
return { error: `${err.message} checkbox.json says Checkbox Notices sends emails; --online checks that against the site's Notice settings once it knows the site (--site or CHECKBOX_SITE).` };
|
|
29320
|
+
}
|
|
29321
|
+
try {
|
|
29322
|
+
const s2 = await client.sdk.noticeSettings.retrieve(site);
|
|
29323
|
+
return { site, mode: s2.mode, enabled: (s2.enabled_kinds ?? []).map((k) => NOTICE_KIND_TO_EMAIL[k] ?? k), real_sends: s2.real_sends };
|
|
29324
|
+
} catch (err) {
|
|
29325
|
+
return { error: `Could not read the Notice settings of ${site} (${err.message}).` };
|
|
29326
|
+
}
|
|
29327
|
+
}
|
|
29328
|
+
async function attachSiteForCoupons(ctx, client, jobs, siteFlag) {
|
|
29329
|
+
const withCoupon = jobs.filter((j) => j.type === "stripe_checkout" && j.body && couponIdsIn(j.body.params).length);
|
|
29330
|
+
if (!withCoupon.length) return;
|
|
29331
|
+
let site;
|
|
29332
|
+
try {
|
|
29333
|
+
site = await resolveSite(ctx, siteFlag, client);
|
|
29334
|
+
} catch (err) {
|
|
29335
|
+
for (const j of withCoupon) j.warnings.push(`The coupon was not read from Stripe: ${err.message} --online reads it through the site's connected Stripe key once it knows the site (--site or CHECKBOX_SITE).`);
|
|
29336
|
+
return;
|
|
29337
|
+
}
|
|
29338
|
+
for (const j of withCoupon) j.body = { ...j.body, site };
|
|
29339
|
+
}
|
|
29340
|
+
function couponIdsIn(params) {
|
|
29341
|
+
const discounts = params?.discounts;
|
|
29342
|
+
return (Array.isArray(discounts) ? discounts : []).flatMap((d) => d && typeof d === "object" && typeof d.coupon === "string" ? [d.coupon] : []);
|
|
29343
|
+
}
|
|
29344
|
+
var HELP2, NOTICE_KIND_TO_EMAIL, ci_default;
|
|
28597
29345
|
var init_ci = __esm({
|
|
28598
29346
|
"src/commands/ci/index.ts"() {
|
|
28599
29347
|
"use strict";
|
|
@@ -28603,6 +29351,8 @@ var init_ci = __esm({
|
|
|
28603
29351
|
init_inputs();
|
|
28604
29352
|
init_report();
|
|
28605
29353
|
init_runner();
|
|
29354
|
+
init_run();
|
|
29355
|
+
init_lib();
|
|
28606
29356
|
init_scaffold();
|
|
28607
29357
|
init_workflow();
|
|
28608
29358
|
init_types2();
|
|
@@ -28611,7 +29361,10 @@ var init_ci = __esm({
|
|
|
28611
29361
|
"",
|
|
28612
29362
|
"Flags:",
|
|
28613
29363
|
" --config <path> Config file (default: checkbox.json, then .checkbox.json)",
|
|
28614
|
-
" --online Use the API (CHECKBOX_SECRET_KEY / CHECKBOX_API_KEY): URL pages
|
|
29364
|
+
" --online Use the API (CHECKBOX_SECRET_KEY / CHECKBOX_API_KEY): URL pages (these need a live-mode",
|
|
29365
|
+
' key on an activated account; with a test key list the built page as "file") and site-aware checks;',
|
|
29366
|
+
` with "notices" in checkbox.json, also checks that the site's Checkbox Notices really send them`,
|
|
29367
|
+
" --site <site_\u2026> The site for --online (default: $CHECKBOX_SITE, the site `checkbox init` saved, or the only one)",
|
|
28615
29368
|
' --strict "Needs review" fails too (exit 2)',
|
|
28616
29369
|
" --changed-only Skip when no file the config reads changed (git diff against --base, or",
|
|
28617
29370
|
" origin/$GITHUB_BASE_REF in a pull request). Never skips while a page or file it reads",
|
|
@@ -28625,15 +29378,18 @@ var init_ci = __esm({
|
|
|
28625
29378
|
"",
|
|
28626
29379
|
"Exit: 0 pass (or needs review without --strict) \xB7 1 a check needs a fix \xB7 2 needs review with --strict, or a",
|
|
28627
29380
|
" page / disclosure with no renewal terms to judge, a checkout whose coupon / trial is not verified, or a",
|
|
28628
|
-
' checkout entry with "strict": true that needs review
|
|
29381
|
+
' checkout entry with "strict": true that needs review, or nothing was checked (every check skipped or',
|
|
29382
|
+
" not applicable, e.g. url pages without --online) \xB7",
|
|
28629
29383
|
" 3 config / usage error \xB7 4 auth (--online) \xB7 5 API or network error",
|
|
28630
29384
|
"",
|
|
28631
29385
|
"checkbox ci init [--yes] [--force] [--dry-run] [--no-workflow]",
|
|
28632
29386
|
' Writes checkbox.json from the project (Stripe Checkout calls, plans, email templates, pages; "TODO" where it',
|
|
28633
|
-
|
|
29387
|
+
' cannot tell) and .github/workflows/checkbox.yml (with the install and build steps a fresh clone needs for "module"',
|
|
29388
|
+
" entries and built pages). Asks first on a terminal; --yes writes without asking (agents).",
|
|
28634
29389
|
"",
|
|
28635
29390
|
"Docs: https://checkbox.site/docs/guides/ci"
|
|
28636
29391
|
].join("\n");
|
|
29392
|
+
NOTICE_KIND_TO_EMAIL = { trial_ending: "trial_reminder" };
|
|
28637
29393
|
ci_default = defineCommand({
|
|
28638
29394
|
name: "ci",
|
|
28639
29395
|
summary: "Run the checks in checkbox.json (CI: annotations + job summary); `ci init` scaffolds the config and workflow",
|
|
@@ -28641,7 +29397,7 @@ var init_ci = __esm({
|
|
|
28641
29397
|
help: HELP2,
|
|
28642
29398
|
async run(ctx) {
|
|
28643
29399
|
if (ctx.argv[0] === "init") return runInit(ctx, ctx.argv.slice(1));
|
|
28644
|
-
const p = parseArgs(ctx.argv, { boolean: ["online", "offline", "strict", "verbose", "changed-only"], string: ["config", "base"] });
|
|
29400
|
+
const p = parseArgs(ctx.argv, { boolean: ["online", "offline", "strict", "verbose", "changed-only"], string: ["config", "base", "site"] });
|
|
28645
29401
|
if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}" (did you mean \`checkbox ci init\`?)`);
|
|
28646
29402
|
const gha = ctx.env.GITHUB_ACTIONS === "true";
|
|
28647
29403
|
const strict = bool(p.flags, "strict") === true;
|
|
@@ -28672,7 +29428,7 @@ var init_ci = __esm({
|
|
|
28672
29428
|
const workspace = ctx.env.GITHUB_WORKSPACE ?? ctx.cwd;
|
|
28673
29429
|
const extra = (loaded.config.coverage === false ? [] : coverageResults(coverageOf(loaded, loaded.dir))).map((r) => ({
|
|
28674
29430
|
...r,
|
|
28675
|
-
target: { ...r.target, file: relative5(workspace,
|
|
29431
|
+
target: { ...r.target, file: relative5(workspace, join9(loaded.dir, r.target.file)).split("\\").join("/") || r.target.file }
|
|
28676
29432
|
}));
|
|
28677
29433
|
if (bool(p.flags, "changed-only")) {
|
|
28678
29434
|
const base = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
|
|
@@ -28698,8 +29454,22 @@ ${message}
|
|
|
28698
29454
|
}
|
|
28699
29455
|
}
|
|
28700
29456
|
}
|
|
28701
|
-
|
|
28702
|
-
|
|
29457
|
+
let client;
|
|
29458
|
+
if (online) {
|
|
29459
|
+
try {
|
|
29460
|
+
client = ctx.client();
|
|
29461
|
+
} catch (err) {
|
|
29462
|
+
if (!(err instanceof NotLoggedInError)) throw err;
|
|
29463
|
+
if (gha) emit("::error title=Checkbox::--online needs CHECKBOX_SECRET_KEY. Add it as a repository secret, or drop --online (checkbox ci runs offline without a key).");
|
|
29464
|
+
ctx.out.error({ message: `${err.message}`, code: "not_logged_in", type: "authentication_error", next_action: { type: "set_key", message: "--online needs an API key: set CHECKBOX_SECRET_KEY (a repository secret in GitHub Actions), or drop --online: checkbox ci runs offline with no key." } });
|
|
29465
|
+
return EXIT.auth;
|
|
29466
|
+
}
|
|
29467
|
+
}
|
|
29468
|
+
if (client) await attachSiteForCoupons(ctx, client, jobs, str(p.flags, "site"));
|
|
29469
|
+
const engine = client ? onlineEngine(client) : offlineEngine();
|
|
29470
|
+
const notices = client && declaredByCheckbox(loaded.config).length ? await readNotices(ctx, client, str(p.flags, "site")) : void 0;
|
|
29471
|
+
const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
|
|
29472
|
+
const run2 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra, ...notices ? { notices, noticesTarget: { file: relative5(workspace, path).split("\\").join("/") || configName, line: noticesLine } } : {} });
|
|
28703
29473
|
ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
|
|
28704
29474
|
if (gha) {
|
|
28705
29475
|
for (const a of annotations(run2)) emit(a);
|
|
@@ -28931,8 +29701,8 @@ var init_patch = __esm({
|
|
|
28931
29701
|
});
|
|
28932
29702
|
|
|
28933
29703
|
// src/commands/plan/fix.ts
|
|
28934
|
-
import { existsSync as
|
|
28935
|
-
import { relative as relative6, resolve as
|
|
29704
|
+
import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync3 } from "node:fs";
|
|
29705
|
+
import { relative as relative6, resolve as resolve8 } from "node:path";
|
|
28936
29706
|
function fixForTask(task, cwd, file) {
|
|
28937
29707
|
const base = {
|
|
28938
29708
|
object: "task_fix",
|
|
@@ -28944,12 +29714,12 @@ function fixForTask(task, cwd, file) {
|
|
|
28944
29714
|
verify: task.verify
|
|
28945
29715
|
};
|
|
28946
29716
|
const verifyCmd = `checkbox verify ${task.id} --wait`;
|
|
28947
|
-
const candidate = file ?? (task.where.file_hint &&
|
|
29717
|
+
const candidate = file ?? (task.where.file_hint && existsSync9(resolve8(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
|
|
28948
29718
|
if (task.change.type === "patch" && task.change.edits.length && candidate) {
|
|
28949
|
-
const path =
|
|
29719
|
+
const path = resolve8(cwd, candidate);
|
|
28950
29720
|
let src;
|
|
28951
29721
|
try {
|
|
28952
|
-
src =
|
|
29722
|
+
src = readFileSync10(path, "utf8");
|
|
28953
29723
|
} catch (err) {
|
|
28954
29724
|
throw new UsageError(`Cannot read ${candidate}: ${err.code ?? err.message}`);
|
|
28955
29725
|
}
|
|
@@ -29009,7 +29779,7 @@ var init_fix = __esm({
|
|
|
29009
29779
|
const task = await ctx.client().sdk.tasks.retrieve(id);
|
|
29010
29780
|
const result = fixForTask(task, ctx.cwd, str(flags, "file"));
|
|
29011
29781
|
const out = str(flags, "out");
|
|
29012
|
-
if (out && result.diff) writeFileSync3(
|
|
29782
|
+
if (out && result.diff) writeFileSync3(resolve8(ctx.cwd, out), result.diff);
|
|
29013
29783
|
ctx.out.result({ ...result, task_object: task }, () => {
|
|
29014
29784
|
const c = ctx.out.c;
|
|
29015
29785
|
const lines = [`${c.bold(task.title)} ${c.dim(`(${task.priority}, ${task.id})`)}`, c.dim(`${task.requirement.citation}`), task.why, ""];
|
|
@@ -29111,12 +29881,12 @@ var init_device = __esm({
|
|
|
29111
29881
|
});
|
|
29112
29882
|
|
|
29113
29883
|
// src/commands/init/patch.ts
|
|
29114
|
-
import { execFileSync as
|
|
29115
|
-
import { existsSync as
|
|
29116
|
-
import { dirname as
|
|
29884
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
29885
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync4 } from "node:fs";
|
|
29886
|
+
import { dirname as dirname8, join as join10 } from "node:path";
|
|
29117
29887
|
function read(root, rel2) {
|
|
29118
29888
|
try {
|
|
29119
|
-
return
|
|
29889
|
+
return readFileSync11(join10(root, rel2), "utf8");
|
|
29120
29890
|
} catch {
|
|
29121
29891
|
return null;
|
|
29122
29892
|
}
|
|
@@ -29294,7 +30064,7 @@ function mask(v) {
|
|
|
29294
30064
|
}
|
|
29295
30065
|
function isGitTracked(root, file) {
|
|
29296
30066
|
try {
|
|
29297
|
-
|
|
30067
|
+
execFileSync3("git", ["ls-files", "--error-unmatch", "--", file], { cwd: root, stdio: "ignore", timeout: 5e3 });
|
|
29298
30068
|
return true;
|
|
29299
30069
|
} catch {
|
|
29300
30070
|
return false;
|
|
@@ -29311,7 +30081,7 @@ function planGitignore(d) {
|
|
|
29311
30081
|
const covered = lines.some((l) => l === env2 || l === `/${env2}` || l === ".env*" || l === "*.env" || env2 === ".env.local" && (l === ".env*.local" || l === "*.local" || l === ".env.local*") || env2 === ".env" && l === ".env*");
|
|
29312
30082
|
if (covered) return null;
|
|
29313
30083
|
}
|
|
29314
|
-
if (existing === null && !
|
|
30084
|
+
if (existing === null && !existsSync10(join10(d.root, ".git"))) return null;
|
|
29315
30085
|
const text = `${existing ?? ""}${existing && !existing.endsWith("\n") ? "\n" : ""}# Checkbox keys (checkbox init)
|
|
29316
30086
|
${env2}
|
|
29317
30087
|
`;
|
|
@@ -29327,8 +30097,8 @@ function planWellKnown(d, token) {
|
|
|
29327
30097
|
}
|
|
29328
30098
|
function applyChange(root, c) {
|
|
29329
30099
|
if (c.action === "unchanged" || c.contents === void 0) return;
|
|
29330
|
-
const abs =
|
|
29331
|
-
mkdirSync3(
|
|
30100
|
+
const abs = join10(root, c.file);
|
|
30101
|
+
mkdirSync3(dirname8(abs), { recursive: true });
|
|
29332
30102
|
writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
|
|
29333
30103
|
}
|
|
29334
30104
|
var MARK_START, MARK_END, NEXT_DOCUMENT, NUXT_PLUGIN;
|
|
@@ -29375,8 +30145,8 @@ __export(init_exports, {
|
|
|
29375
30145
|
parsePlanFlag: () => parsePlanFlag,
|
|
29376
30146
|
syncCheckoutConfigs: () => syncCheckoutConfigs
|
|
29377
30147
|
});
|
|
29378
|
-
import { existsSync as
|
|
29379
|
-
import { join as
|
|
30148
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
30149
|
+
import { join as join11 } from "node:path";
|
|
29380
30150
|
import { createInterface as createInterface2 } from "node:readline";
|
|
29381
30151
|
function loadState(ctx, root) {
|
|
29382
30152
|
const cfg = ctx.config.load().init ?? {};
|
|
@@ -29396,7 +30166,7 @@ function saveState(ctx, root, patch) {
|
|
|
29396
30166
|
async function ask2(question) {
|
|
29397
30167
|
const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
|
|
29398
30168
|
try {
|
|
29399
|
-
return await new Promise((
|
|
30169
|
+
return await new Promise((resolve10) => rl.question(question, resolve10));
|
|
29400
30170
|
} finally {
|
|
29401
30171
|
rl.close();
|
|
29402
30172
|
}
|
|
@@ -29474,7 +30244,7 @@ async function syncCheckoutConfigs(client, site, sessions, root) {
|
|
|
29474
30244
|
const from = configFileOf(c.name);
|
|
29475
30245
|
if (!from) continue;
|
|
29476
30246
|
const sameFile = perFile.has(from.file);
|
|
29477
|
-
const gone = !sameFile && !
|
|
30247
|
+
const gone = !sameFile && !existsSync11(join11(root, from.file));
|
|
29478
30248
|
const current = sameFile && from.call !== void 0 && from.call <= perFile.get(from.file) && c.name === configNameOf(from.file, from.call);
|
|
29479
30249
|
if (!sameFile && !gone || current) continue;
|
|
29480
30250
|
if (await client.sdk.stripeCheckoutConfigs.del(c.id).then(() => true, () => false)) {
|
|
@@ -29514,6 +30284,7 @@ function detectionSummary(d, siteUrl) {
|
|
|
29514
30284
|
pay_buttons: d.payButtons.map((c) => ({ file: c.file, line: c.line })),
|
|
29515
30285
|
checkout_sessions: d.checkoutSessions.map((s2) => ({ file: s2.file, line: s2.line, readable: !!s2.params, unresolved: s2.unresolved })),
|
|
29516
30286
|
stripe_test_key: d.stripeTestKey ? { hint: d.stripeTestKey.hint, source: d.stripeTestKey.source, restricted: d.stripeTestKey.restricted } : null,
|
|
30287
|
+
...d.stripeKeyRefused ? { stripe_key_refused: { ...d.stripeKeyRefused, message: stripeKeyRefusedMessage(d.stripeKeyRefused) } } : {},
|
|
29517
30288
|
merchant_name: d.brand ?? null,
|
|
29518
30289
|
plans: d.plans
|
|
29519
30290
|
};
|
|
@@ -29608,7 +30379,7 @@ var init_init = __esm({
|
|
|
29608
30379
|
" 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it)",
|
|
29609
30380
|
" 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments)",
|
|
29610
30381
|
" 5. registers the site, Terms / refund / cancellation policy URLs (fetched and checked by Checkbox)",
|
|
29611
|
-
" 6. with your permission, uses the project's Stripe TEST key for a synthetic test subscription",
|
|
30382
|
+
" 6. with your permission, uses the project's Stripe restricted TEST key (rk_test_\u2026; sk_test_ and live keys are refused) for a synthetic test subscription",
|
|
29612
30383
|
" 7. runs the first checks and prints the next step (`checkbox plan --json`); the human gets the summary email",
|
|
29613
30384
|
" when the setup is finished (every check passes, or `checkbox init --complete`)",
|
|
29614
30385
|
"",
|
|
@@ -29804,7 +30575,20 @@ var init_init = __esm({
|
|
|
29804
30575
|
}
|
|
29805
30576
|
const policyFromPaths = (kind) => siteUrl && d.policyPaths[kind] ? `${siteUrl}${d.policyPaths[kind]}` : void 0;
|
|
29806
30577
|
const agent = detectAgent(ctx.env, agentName);
|
|
29807
|
-
|
|
30578
|
+
let plans = flagPlans ?? (d.plans.length ? d.plans.map(({ source: _source, ...p }) => p) : void 0);
|
|
30579
|
+
if (plans?.length && !planFlag) {
|
|
30580
|
+
let current = null;
|
|
30581
|
+
try {
|
|
30582
|
+
current = await client.sdk.setupSessions.retrieve(sessionId);
|
|
30583
|
+
} catch {
|
|
30584
|
+
current = null;
|
|
30585
|
+
}
|
|
30586
|
+
const saved = Array.isArray(current?.business?.plans) ? current.business.plans : [];
|
|
30587
|
+
if (saved.length) {
|
|
30588
|
+
plans = void 0;
|
|
30589
|
+
out.info(`Kept the ${saved.length} plan${saved.length === 1 ? "" : "s"} already saved for this site (pass --plan to replace ${saved.length === 1 ? "it" : "them"}).`);
|
|
30590
|
+
}
|
|
30591
|
+
}
|
|
29808
30592
|
const brand = merchantName ?? d.brand?.name;
|
|
29809
30593
|
const update = {
|
|
29810
30594
|
...siteUrl ? { site_url: siteUrl } : {},
|
|
@@ -29870,13 +30654,16 @@ var init_init = __esm({
|
|
|
29870
30654
|
if (consent && d.stripeTestKey && !session.stripe.test_credential_stored) {
|
|
29871
30655
|
try {
|
|
29872
30656
|
await client.sdk.testCredentials.create(site, { type: "stripe_test", secret_key: d.stripeTestKey.key });
|
|
29873
|
-
stripe.test_credential = { stored: true, key: d.stripeTestKey.hint, restricted:
|
|
29874
|
-
if (!d.stripeTestKey.restricted) warnings.push(`Stored your full Stripe TEST key (${d.stripeTestKey.hint}) because no restricted key was found. Recommended: create a restricted test key (${session.stripe.restricted_key.create_url}) with ${session.stripe.restricted_key.permissions.map((p) => `${p.resource} ${p.access}`).join(", ")}, put it in STRIPE_RESTRICTED_KEY and run \`checkbox init\` again.`);
|
|
30657
|
+
stripe.test_credential = { stored: true, key: d.stripeTestKey.hint, restricted: true };
|
|
29875
30658
|
} catch (err) {
|
|
29876
30659
|
stripe.test_credential = { stored: false, error: err instanceof ApiError ? `${err.code ?? ""} ${err.message}`.trim() : String(err) };
|
|
29877
30660
|
warnings.push(`Stripe test key not stored: ${stripe.test_credential.error}`);
|
|
29878
30661
|
}
|
|
29879
30662
|
}
|
|
30663
|
+
if (!d.stripeTestKey && d.stripeKeyRefused) {
|
|
30664
|
+
stripe.test_credential = { stored: false, refused: d.stripeKeyRefused.kind };
|
|
30665
|
+
warnings.push(stripeKeyRefusedMessage(d.stripeKeyRefused));
|
|
30666
|
+
}
|
|
29880
30667
|
const configs = await syncCheckoutConfigs(client, site, d.checkoutSessions, root);
|
|
29881
30668
|
if (configs.length) stripe.checkout_configs = configs;
|
|
29882
30669
|
const registered = configs.find((c) => c.id);
|
|
@@ -29979,10 +30766,11 @@ var init_whoami = __esm({
|
|
|
29979
30766
|
object: "whoami",
|
|
29980
30767
|
key: creds ? maskKey(creds.apiKey) : null,
|
|
29981
30768
|
key_source: creds?.source ?? null,
|
|
30769
|
+
...creds?.file ? { key_file: creds.file } : {},
|
|
29982
30770
|
api_base: client.apiBase,
|
|
29983
30771
|
account: account.raw
|
|
29984
30772
|
},
|
|
29985
|
-
() => [describeAccount(account), ctx.out.c.dim(`key ${creds ? maskKey(creds.apiKey) : "?"} from ${creds
|
|
30773
|
+
() => [describeAccount(account), ctx.out.c.dim(`key ${creds ? maskKey(creds.apiKey) : "?"} from ${creds ? describeKeySource(creds, ctx.env) : "?"} \xB7 ${client.apiBase}`)]
|
|
29986
30774
|
);
|
|
29987
30775
|
return EXIT.ok;
|
|
29988
30776
|
}
|
|
@@ -29999,7 +30787,7 @@ import { createInterface as createInterface3 } from "node:readline";
|
|
|
29999
30787
|
async function prompt(question) {
|
|
30000
30788
|
const rl = createInterface3({ input: process.stdin, output: process.stderr, terminal: true });
|
|
30001
30789
|
try {
|
|
30002
|
-
return await new Promise((
|
|
30790
|
+
return await new Promise((resolve10) => rl.question(question, resolve10));
|
|
30003
30791
|
} finally {
|
|
30004
30792
|
rl.close();
|
|
30005
30793
|
}
|
|
@@ -30093,74 +30881,6 @@ var init_logout = __esm({
|
|
|
30093
30881
|
}
|
|
30094
30882
|
});
|
|
30095
30883
|
|
|
30096
|
-
// src/commands/plan/lib.ts
|
|
30097
|
-
import { dirname as dirname6, resolve as resolve7 } from "node:path";
|
|
30098
|
-
async function resolveSite(ctx, flag, client) {
|
|
30099
|
-
const explicit = flag ?? ctx.env.CHECKBOX_SITE;
|
|
30100
|
-
if (explicit) {
|
|
30101
|
-
if (!SITE_RE.test(explicit) && !/^[0-9a-f-]{36}$/i.test(explicit)) throw new UsageError(`--site must be a site id (site_\u2026), got "${explicit}"`);
|
|
30102
|
-
return explicit;
|
|
30103
|
-
}
|
|
30104
|
-
const projects = ctx.config.load().init?.projects ?? {};
|
|
30105
|
-
for (let dir = resolve7(ctx.cwd); ; dir = dirname6(dir)) {
|
|
30106
|
-
const site = projects[dir]?.site;
|
|
30107
|
-
if (site) return site;
|
|
30108
|
-
if (dirname6(dir) === dir) break;
|
|
30109
|
-
}
|
|
30110
|
-
const list2 = await client.sdk.sites.list({ limit: 3 });
|
|
30111
|
-
const sites = list2?.data ?? [];
|
|
30112
|
-
if (sites.length === 1) return sites[0].id;
|
|
30113
|
-
throw new UsageError(
|
|
30114
|
-
sites.length ? `This account has several sites; pass --site <id> (e.g. ${sites.map((s2) => s2.id).join(", ")}) or set CHECKBOX_SITE.` : "No site yet: run `checkbox init` in your project first (it creates the site and remembers it here)."
|
|
30115
|
-
);
|
|
30116
|
-
}
|
|
30117
|
-
function nextLine(n) {
|
|
30118
|
-
const text = n.description ?? n.message ?? n.type;
|
|
30119
|
-
return `${text}${n.command ? `
|
|
30120
|
-
$ ${n.command}` : n.endpoint ? `
|
|
30121
|
-
${n.endpoint}` : ""}`;
|
|
30122
|
-
}
|
|
30123
|
-
function renderPlan(p, c, opts = {}) {
|
|
30124
|
-
const head = `${c.bold(`Compliance plan ${p.site}`)} \u2014 ${p.compliance?.label ? p.status === "compliant" ? c.green(p.compliance.label) : p.status === "action_required" ? c.red(p.compliance.label) : c.yellow(p.compliance.label) : statusColor(p.status, c)}`;
|
|
30125
|
-
const tasks = opts.all ? p.tasks : p.tasks.filter((t) => t.status !== "done" && t.status !== "wont_fix");
|
|
30126
|
-
const lines = [head, p.summary, ""];
|
|
30127
|
-
for (const t of tasks) {
|
|
30128
|
-
lines.push(`${STATUS_MARK[t.status] ?? "[ ]"} ${String(t.order).padStart(2)}. ${t.title} ${c.dim(`(${t.priority}, ${t.id})`)}`);
|
|
30129
|
-
if (t.status === "manual" && t.next_action?.type === "ask_human_to_confirm") lines.push(` ${c.yellow("waiting for the human to confirm:")} ${t.next_action.description}`);
|
|
30130
|
-
else if (t.status === "manual" && t.question) lines.push(` ${c.yellow("ask the human:")} ${t.question.text} ${c.dim(`\u2192 checkbox answer ${t.id} <${t.question.options?.join("|") ?? "yes|no"}>`)}`);
|
|
30131
|
-
else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
|
|
30132
|
-
else if (t.blocked_by.length) lines.push(c.dim(` after ${t.blocked_by.join(", ")}`));
|
|
30133
|
-
else if (t.status === "open" || t.status === "in_progress") lines.push(c.dim(` ${t.where.file_hint ?? t.where.url ?? t.where.stripe_object ?? t.where.kind} \xB7 ${t.change.type} \xB7 checkbox fix ${t.id}`));
|
|
30134
|
-
}
|
|
30135
|
-
const hidden = p.tasks.length - tasks.length;
|
|
30136
|
-
if (hidden) lines.push(c.dim(` \u2026 ${hidden} done / declined (--all to show)`));
|
|
30137
|
-
lines.push("", `${c.cyan("next")} ${nextLine(p.next_action)}`);
|
|
30138
|
-
return lines;
|
|
30139
|
-
}
|
|
30140
|
-
function statusColor(s2, c) {
|
|
30141
|
-
return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
|
|
30142
|
-
}
|
|
30143
|
-
function renderVerification(v, c) {
|
|
30144
|
-
const mark = v.status === "passed" ? c.green("passed") : v.status === "failed" ? c.red("failed") : v.status === "passed_with_gaps" ? c.yellow("passed with gaps (not verified)") : v.status === "passed_with_notes" ? c.yellow("passed with notes (a check outside your control needs review)") : v.status === "not_checked" ? c.yellow("not checked yet (missing evidence, nothing to change)") : v.status === "pending_live_check" ? c.yellow("live check pending (content passes; waiting for a real send)") : c.yellow("pending");
|
|
30145
|
-
const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
|
|
30146
|
-
const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
|
|
30147
|
-
if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
|
|
30148
|
-
if (v.scan) lines.push(c.dim(` confirming scan ${v.scan}`));
|
|
30149
|
-
for (const d of v.diff.slice(0, 12)) lines.push(` ${d.observed === "fail" ? c.red("\u2717") : c.yellow("?")} ${d.check_id}: expected ${d.expected}, observed ${d.observed} \u2014 ${d.detail}`);
|
|
30150
|
-
if (v.diff.length > 12) lines.push(c.dim(` \u2026 ${v.diff.length - 12} more`));
|
|
30151
|
-
lines.push(`${c.cyan("next")} ${nextLine(v.next_action)}`);
|
|
30152
|
-
return lines;
|
|
30153
|
-
}
|
|
30154
|
-
var SITE_RE, STATUS_MARK;
|
|
30155
|
-
var init_lib = __esm({
|
|
30156
|
-
"src/commands/plan/lib.ts"() {
|
|
30157
|
-
"use strict";
|
|
30158
|
-
init_args();
|
|
30159
|
-
SITE_RE = /^site_[0-9a-f]{32}$/;
|
|
30160
|
-
STATUS_MARK = { open: "[ ]", in_progress: "[~]", verifying: "[?]", done: "[x]", wont_fix: "[-]", manual: "[!]" };
|
|
30161
|
-
}
|
|
30162
|
-
});
|
|
30163
|
-
|
|
30164
30884
|
// src/commands/plan/index.ts
|
|
30165
30885
|
var plan_exports = {};
|
|
30166
30886
|
__export(plan_exports, {
|
|
@@ -30201,11 +30921,11 @@ var init_plan = __esm({
|
|
|
30201
30921
|
});
|
|
30202
30922
|
|
|
30203
30923
|
// src/commands/plan/verify.ts
|
|
30204
|
-
import { readFileSync as
|
|
30205
|
-
import { resolve as
|
|
30924
|
+
import { readFileSync as readFileSync12 } from "node:fs";
|
|
30925
|
+
import { resolve as resolve9 } from "node:path";
|
|
30206
30926
|
function readJson2(ctx, file, what) {
|
|
30207
30927
|
try {
|
|
30208
|
-
const v = JSON.parse(
|
|
30928
|
+
const v = JSON.parse(readFileSync12(resolve9(ctx.cwd, file), "utf8"));
|
|
30209
30929
|
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
|
|
30210
30930
|
return v;
|
|
30211
30931
|
} catch (err) {
|
|
@@ -30251,7 +30971,7 @@ var init_verify2 = __esm({
|
|
|
30251
30971
|
const htmlFile = str(flags, "html-file");
|
|
30252
30972
|
if (htmlFile) {
|
|
30253
30973
|
try {
|
|
30254
|
-
body.html =
|
|
30974
|
+
body.html = readFileSync12(resolve9(ctx.cwd, htmlFile), "utf8");
|
|
30255
30975
|
} catch (err) {
|
|
30256
30976
|
throw new UsageError(`Cannot read ${htmlFile}: ${err.code ?? err.message}`);
|
|
30257
30977
|
}
|