@readystack/autorenew-signup-lint 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.txt ADDED
@@ -0,0 +1,16 @@
1
+ Auto-Renewal Signup Lint (California ARL) — Licence
2
+
3
+ Free scope
4
+ Check the signup or pricing page open in the editor is free for any use, personal or commercial, for as long as you keep the
5
+ extension installed, and the online check at the product page is free without registration.
6
+
7
+ Paid scope
8
+ Sweep every page in the workspace and write one dated report you keep is the paid part. It asks for a licence key issued at purchase (one seat, one key;
9
+ 7-day full refund, no questions). The key is checked against the payment provider and
10
+ cached locally for 30 days so it keeps working offline.
11
+
12
+ Redistribution
13
+ You may not resell, re-host or bundle this extension or its rule set. The rule set and the
14
+ engine are provided as-is; check results are advisory and do not constitute legal advice.
15
+
16
+ (c) ReadyStack — https://getreadystack.com
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # Auto-Renewal Signup Lint (California ARL)
2
+
3
+ ![Auto-Renewal Signup Lint (California ARL)](https://getreadystack.com/img/promo/sku48727_result_card.jpg)
4
+
5
+ Sixteen statutory checks on the signup page AB 2863 rewrote, in your editor and in the browser
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npx @readystack/autorenew-signup-lint file
11
+ ```
12
+
13
+ Node 18+. The same 16 rules as the VS Code extension, from a terminal or CI.
14
+
15
+ ## Free
16
+
17
+ - Check the signup or pricing page open in the editor against all 16 rules, free and unlimited, plus the same engine free in the browser.
18
+ - `--rules` lists every rule
19
+
20
+ ## With a licence ($29 once)
21
+
22
+ - Sweep every matching page in the workspace and write one dated report file into the folder, yours to keep and attach to a review.
23
+
24
+ ```
25
+ @readystack/autorenew-signup-lint --dir ./templates --report html --out report.html
26
+ ```
27
+
28
+ An hour of US outside counsel reviewing one signup flow is commonly quoted at $300-$500.
29
+
30
+ ## Use from an AI agent (MCP)
31
+
32
+ Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
33
+
34
+ ```json
35
+ { "mcpServers": { "autorenew-signup-lint": { "command": "npx", "args": ["-y", "@readystack/autorenew-signup-lint", "--mcp"] } } }
36
+ ```
37
+
38
+ Tools: `check_text` and `check_file` (free) · `check_dir` (licence; the full sweep is free for 7 days). The agent gets every finding with the line number.
39
+
40
+ ## Use in CI
41
+
42
+ ```yaml
43
+ - name: Auto-Renewal Signup Lint (California ARL)
44
+ run: npx -y @readystack/autorenew-signup-lint --dir . --ci
45
+ ```
46
+
47
+ (container: `docker run --rm -v "$PWD:/work" getreadystack/autorenew-signup-lint --dir /work --ci`)
48
+
49
+ Try the full run free for 7 days — no key needed. Then one licence, 7-day refund, no questions. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
50
+
51
+ [Get a licence](https://buy.polar.sh/polar_cl_ysb3yWUhknvn2fWoPwchsZqFfbA3X5hijEY2x1jBBhw)
52
+
53
+
54
+ <!-- california auto renewal signup lint -->
package/cli.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // cli.js — the same rules as the VS Code extension, run from a terminal or a container. Generated by adapters.py.
3
+ 'use strict';
4
+ const fs = require('fs'), path = require('path');
5
+ const RULES = require('./rules.json');
6
+ const S = require('./strings.json');
7
+ const lic = require('./license.js');
8
+ const ENGINE = require('./engine.js'); // s144 — scaffold products: the same engine the VS Code extension runs
9
+ const RULE_LIST = Array.isArray(ENGINE.RULES) ? ENGINE.RULES : (Array.isArray(RULES) ? RULES : []);
10
+ const RULE_N = ENGINE.RULE_COUNT || RULE_LIST.length;
11
+ function scan(text, file) {
12
+ const r = ENGINE.engine.check(String(text), { today: new Date().toISOString().slice(0, 10), path: file || '' });
13
+ return (r && r.findings || []).map((f) => ({ line: parseInt(f.line, 10) || 1, msg: String(f.msg || f.message || f.check || ''), fix: f.fix || null, sev: (String(f.sev || 'error').toLowerCase().startsWith('err') ? 'error' : 'warn') }));
14
+ }
15
+ function isText(p) { try { const b = fs.readFileSync(p); if (b.length > 2 * 1024 * 1024) return false; const s = b.subarray(0, 4096); for (const x of s) if (x === 0) return false; return true; } catch (e) { return false; } }
16
+ function walk(dir, exts, out) {
17
+ let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
18
+ for (const e of ents) {
19
+ if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue;
20
+ const p = path.join(dir, e.name);
21
+ if (e.isDirectory()) walk(p, exts, out);
22
+ else if ((!exts.length || exts.includes(path.extname(e.name).toLowerCase())) && isText(p)) out.push(p);
23
+ }
24
+ return out;
25
+ }
26
+ function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
27
+ function csvq(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; }
28
+ function render(rows, fmt) {
29
+ if (fmt === 'json') return JSON.stringify({ tool: S.name, rules: RULE_N, files: rows }, null, 1);
30
+ if (fmt === 'csv') { const o = ['file,line,severity,message,fix']; for (const r of rows) for (const h of r.hits) o.push([csvq(r.file), h.line, h.sev, csvq(h.msg), csvq(h.fix)].join(',')); return o.join('\n') + '\n'; }
31
+ if (fmt === 'html') {
32
+ const n = rows.reduce((a, r) => a + r.hits.length, 0);
33
+ let o = '<!doctype html><meta charset="utf-8"><title>' + esc(S.name) + ' report</title><style>body{font:14px system-ui;margin:24px}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:4px 8px;font-size:13px}code{font-family:ui-monospace,monospace}</style>';
34
+ o += '<h1>' + esc(S.name) + '</h1><p>' + rows.length + ' files · ' + n + ' findings · ' + RULE_N + ' rules</p><table><tr><th>file</th><th>line</th><th>sev</th><th>message</th><th>fix</th></tr>';
35
+ for (const r of rows) for (const h of r.hits) o += '<tr><td><code>' + esc(r.file) + '</code></td><td>' + h.line + '</td><td>' + esc(h.sev) + '</td><td>' + esc(h.msg) + '</td><td>' + esc(h.fix || '') + '</td></tr>';
36
+ return o + '</table>';
37
+ }
38
+ let o = ''; for (const r of rows) { o += r.file + '\n'; for (const h of r.hits) o += ' ' + String(h.line).padStart(5) + ' ' + h.sev.padEnd(5) + ' ' + h.msg + (h.fix ? '\n -> ' + h.fix : '') + '\n'; if (!r.hits.length) o += ' (no findings)\n'; }
39
+ return o;
40
+ }
41
+ function trialState() {
42
+ if (process.env.READYSTACK_NO_TRIAL) return { active: false };
43
+ try {
44
+ const p = path.join(path.dirname(lic.storePath()), S.bin + '.trial.json');
45
+ let t = null; try { t = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) {}
46
+ if (!t || !t.until) { t = { until: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString().slice(0, 10) }; fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(t)); }
47
+ return { active: new Date(t.until + 'T23:59:59Z').getTime() > Date.now(), until: t.until };
48
+ } catch (e) { return { active: false }; }
49
+ }
50
+ function mcpServe() {
51
+ // s144 — MCP server over stdio (newline-delimited JSON-RPC · no dependencies). Free: check_text · check_file. Licence (7-day trial): check_dir.
52
+ const rl = require('readline').createInterface({ input: process.stdin });
53
+ const send = (o) => process.stdout.write(JSON.stringify(o) + '\n');
54
+ const tools = [
55
+ { name: 'check_text', description: S.name + ' - run all ' + RULE_N + ' checks on a text (free)', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'file contents' }, path: { type: 'string', description: 'optional file name for context' } }, required: ['text'] } },
56
+ { name: 'check_file', description: S.name + ' - run all checks on one file by path (free)', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
57
+ { name: 'check_dir', description: S.name + ' - sweep a folder and return every finding (licence; the full run is free for 7 days)', inputSchema: { type: 'object', properties: { dir: { type: 'string' }, ext: { type: 'string', description: 'optional extension filter, e.g. .html' } }, required: ['dir'] } }
58
+ ];
59
+ const result = (id, rows) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: render(rows, 'text') }], structuredContent: { tool: S.name, rules: RULE_N, files: rows } } });
60
+ const fail = (id, text) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } });
61
+ rl.on('line', async (line) => {
62
+ let m; try { m = JSON.parse(line); } catch (e) { return; }
63
+ const id = m.id, method = m.method;
64
+ if (method === 'initialize') return send({ jsonrpc: '2.0', id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: '@readystack/' + S.bin, version: '1.0.0' } } });
65
+ if (method === 'notifications/initialized' || method === 'ping') { if (id !== undefined) send({ jsonrpc: '2.0', id, result: {} }); return; }
66
+ if (method === 'tools/list') return send({ jsonrpc: '2.0', id, result: { tools } });
67
+ if (method === 'tools/call') {
68
+ const name = (m.params || {}).name, args = (m.params || {}).arguments || {};
69
+ try {
70
+ if (name === 'check_text') return result(id, [{ file: args.path || '(text)', hits: scan(String(args.text || ''), args.path || '') }]);
71
+ if (name === 'check_file') return result(id, [{ file: args.path, hits: scan(fs.readFileSync(args.path, 'utf8'), args.path) }]);
72
+ if (name === 'check_dir') {
73
+ const r = await lic.ensure();
74
+ if (!r.ok && !trialState().active) return fail(id, S.need_key + ' Get a licence ($' + S.price + ', once, 7-day refund): ' + lic.BUY_URL);
75
+ const files = walk(args.dir, args.ext ? [args.ext] : (S.exts || []), []);
76
+ return result(id, files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t, f) }; }));
77
+ }
78
+ return send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown tool ' + name } });
79
+ } catch (e) { return fail(id, String(e && e.message || e)); }
80
+ }
81
+ if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
82
+ });
83
+ }
84
+ function help() {
85
+ return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
86
+ ' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
87
+ ' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
88
+ ' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
89
+ ' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
90
+ ' ' + S.bin + ' --rules list the ' + RULE_N + ' rules',
91
+ ' ' + S.bin + ' --mcp run as an MCP server (stdio) for Claude Code / Cursor / Windsurf - free checks, folder sweep needs a licence', '',
92
+ 'Free: ' + S.free, 'Licence ($' + S.price + ', once, 7-day refund): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
93
+ }
94
+ (async function main() {
95
+ try { const _feed = await lic.pullFeed(); if (_feed && Array.isArray(_feed.rules)) { if (Array.isArray(ENGINE.RULES)) for (const r of _feed.rules) ENGINE.RULES.push(r); } } catch (e) {} // ★s134 구독 피드 병합 (키 있는 손님만)
96
+ const a = process.argv.slice(2);
97
+ const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
98
+ if (a.includes('--mcp')) { mcpServe(); return; }
99
+ if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
100
+ if (a.includes('--rules')) { process.stdout.write((RULE_LIST.length ? RULE_LIST.map((r, i) => String(i + 1).padStart(3) + ' [' + (r.sev || 'warn') + '] ' + (r.message || r.msg || r.id || '')) : Array.from({ length: RULE_N }, (_, i) => String(i + 1).padStart(3) + ' [engine] check ' + (i + 1))).join('\n') + '\n'); return; }
101
+ if (a.includes('--license')) { const r = await lic.ensure(get('--license')); process.stdout.write(r.ok ? 'Licence stored: ' + lic.storePath() + '\n' : 'Licence not accepted (' + r.why + '). Get one: ' + lic.BUY_URL + '\n'); process.exit(r.ok ? 0 : 2); }
102
+ const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
103
+ const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
104
+ const paid = !!(dir || fmt || ci);
105
+ if (paid) {
106
+ const r = await lic.ensure();
107
+ if (!r.ok) {
108
+ const t = trialState(); // s144 reverse trial: the full run is free for 7 days from the first paid use, then the key
109
+ if (t.active) process.stderr.write('Trial: the full run is free until ' + t.until + ' — after that $' + S.price + ' once (7-day refund). Get a licence: ' + lic.BUY_URL + '\n');
110
+ else { process.stderr.write(S.need_key + '\n set READYSTACK_LICENSE=<key> or ' + S.bin + ' --license <key>\n Get a licence ($' + S.price + ', once): ' + lic.BUY_URL + '\n'); process.exit(2); }
111
+ }
112
+ }
113
+ const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
114
+ if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
115
+ const rows = files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t) }; });
116
+ const text = render(rows, fmt || 'text');
117
+ if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
118
+ const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
119
+ if (ci && errors) process.exit(1);
120
+ })().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
package/engine.js ADDED
@@ -0,0 +1,67 @@
1
+ // Auto-Renewal Checkout Lint — the brain. Same file runs in Node (VS Code) and in the browser (free web page).
2
+ // Rules live in rules.json; this file decides how each rule reads the page.
3
+ 'use strict';
4
+ var RULES = (typeof module !== 'undefined' && module.exports) ? require('./rules.json') : window.ARL_RULES;
5
+
6
+ function rx(p) { return new RegExp(p, 'i'); }
7
+ function anyMatch(pats, s) { for (var i = 0; i < (pats || []).length; i++) { if (rx(pats[i]).test(s)) return true; } return false; }
8
+ function allMatch(pats, s) { for (var i = 0; i < (pats || []).length; i++) { if (!rx(pats[i]).test(s)) return false; } return true; }
9
+ function firstLine(pats, lines) { for (var i = 0; i < lines.length; i++) { if (anyMatch(pats, lines[i])) return i + 1; } return 0; }
10
+ function days(a, b) { return Math.round((Date.parse(a) - Date.parse(b)) / 86400000); }
11
+
12
+ function check(text, opts) {
13
+ text = String(text == null ? '' : text);
14
+ opts = opts || {};
15
+ var today = /^\d{4}-\d{2}-\d{2}$/.test(String(opts.today || '')) ? String(opts.today) : new Date().toISOString().slice(0, 10);
16
+ var lines = text.split(/\r?\n/);
17
+ var findings = [];
18
+ var add = function (r, line, msg) { findings.push({ check: r.check, sev: r.sev || 'error', msg: msg || r.msg, line: line || 1 }); };
19
+
20
+ for (var i = 0; i < RULES.length; i++) {
21
+ var r = RULES[i];
22
+ var mode = r.mode || 'doc';
23
+
24
+ if (mode === 'doc') {
25
+ var t = firstLine(r.trigger, lines);
26
+ if (!t) continue;
27
+ if (anyMatch(r.required, text)) continue;
28
+ add(r, t);
29
+
30
+ } else if (mode === 'line') {
31
+ var hits = 0;
32
+ for (var j = 0; j < lines.length && hits < 5; j++) {
33
+ var ln = lines[j];
34
+ if (r.any && !anyMatch(r.any, ln)) continue;
35
+ if (r.all && !allMatch(r.all, ln)) continue;
36
+ if (r.unless && anyMatch(r.unless, ln)) continue;
37
+ add(r, j + 1); hits++;
38
+ }
39
+
40
+ } else if (mode === 'near') {
41
+ var win = r.window || 12, shown = 0;
42
+ for (var k = 0; k < lines.length && shown < 3; k++) {
43
+ if (!anyMatch(r.trigger, lines[k])) continue;
44
+ var near = false;
45
+ for (var m = Math.max(0, k - win); m <= Math.min(lines.length - 1, k + win); m++) {
46
+ if (anyMatch(r.near, lines[m])) { near = true; break; }
47
+ }
48
+ if (!near) { add(r, k + 1); shown++; }
49
+ }
50
+
51
+ } else if (mode === 'deadline') {
52
+ var d = firstLine(r.trigger, lines);
53
+ if (!d) continue;
54
+ var gap = days(today, r.date);
55
+ var when = gap >= 0
56
+ ? ('has been in force since ' + r.date + ' — ' + gap + ' day' + (gap === 1 ? '' : 's') + ', so every point above is already enforceable')
57
+ : ('takes effect ' + r.date + ' — ' + (-gap) + ' day' + (gap === -1 ? '' : 's') + ' left to ship the fixes');
58
+ add(r, d, r.msg.replace('{when}', when));
59
+ }
60
+ }
61
+
62
+ findings.sort(function (a, b) { return (a.line - b.line) || a.check.localeCompare(b.check); });
63
+ return { findings: findings, checked: RULES.length, today: today };
64
+ }
65
+
66
+ var API = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length };
67
+ if (typeof module !== 'undefined' && module.exports) module.exports = API; else window.ARLENGINE = API;
package/license.js ADDED
@@ -0,0 +1,64 @@
1
+ // license.js — Polar licence check for the CLI / container. Generated by adapters.py; do not edit by hand.
2
+ 'use strict';
3
+ const https = require('https'), fs = require('fs'), path = require('path'), os = require('os');
4
+ const ORG_ID = 'a5cdf664-d8e7-4f87-8895-056717aaba17';
5
+ const BENEFIT_ID = '947cdd9a-6fd6-4f5e-b30e-4dd8e633f60c'; // s142 — this product's Polar benefit (s140 law: ask with benefit_id, or one key opens every product)
6
+ const BUY_URL = 'https://buy.polar.sh/polar_cl_ysb3yWUhknvn2fWoPwchsZqFfbA3X5hijEY2x1jBBhw';
7
+ const SLUG = 'autorenew-signup-lint';
8
+ const GRACE_MS = 30 * 24 * 3600 * 1000; // after a successful check, 30 days work offline
9
+ const RECHECK_MS = 7 * 24 * 3600 * 1000; // re-ask Polar every 7 days (refunds / cancellations)
10
+ function storePath() { return path.join(process.env.READYSTACK_HOME || path.join(os.homedir(), '.config', 'readystack'), SLUG + '.json'); }
11
+ function load() { try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch (e) { return {}; } }
12
+ function save(o) { try { fs.mkdirSync(path.dirname(storePath()), { recursive: true }); fs.writeFileSync(storePath(), JSON.stringify(o)); } catch (e) { /* read-only home: still works for this run */ } }
13
+ function validate(key) {
14
+ return new Promise(function (resolve) {
15
+ if (!ORG_ID) return resolve({ ok: false, offline: false });
16
+ const body = JSON.stringify(/^[0-9a-f-]{36}$/.test(BENEFIT_ID) ? { key: key, organization_id: ORG_ID, benefit_id: BENEFIT_ID } : { key: key, organization_id: ORG_ID });
17
+ const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
18
+ headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
19
+ let buf = ''; res.on('data', function (d) { buf += d; });
20
+ res.on('end', function () {
21
+ if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
22
+ try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
23
+ catch (e) { resolve({ ok: false, offline: false }); }
24
+ });
25
+ });
26
+ req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
27
+ req.on('error', function () { resolve({ ok: false, offline: true }); });
28
+ req.write(body); req.end();
29
+ });
30
+ }
31
+ async function ensure(explicitKey) {
32
+ const st = load();
33
+ const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
34
+ if (!key) return { ok: false, why: 'no_key' };
35
+ const age = Date.now() - (st.okAt || 0);
36
+ if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
37
+ const r = await validate(String(key).trim());
38
+ if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
39
+ if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
40
+ return { ok: false, why: r.offline ? 'offline' : 'invalid' };
41
+ }
42
+ // ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
43
+ const FEED_URL = 'https://getreadystack.com/api/rules/';
44
+ function pullFeed() {
45
+ const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
46
+ if (!key) return Promise.resolve(cached);
47
+ if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
48
+ return new Promise(function (resolve) {
49
+ let req;
50
+ try {
51
+ req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
52
+ let buf = ''; res.on('data', function (d) { buf += d; });
53
+ res.on('end', function () {
54
+ if (res.statusCode !== 200) return resolve(cached);
55
+ try { const j = JSON.parse(buf); if (!j || !Array.isArray(j.rules)) return resolve(cached); save(Object.assign(load(), { feed: j, feedAt: Date.now() })); resolve(j); }
56
+ catch (e) { resolve(cached); }
57
+ });
58
+ });
59
+ } catch (e) { return resolve(cached); }
60
+ req.on('timeout', function () { req.destroy(); resolve(cached); });
61
+ req.on('error', function () { resolve(cached); });
62
+ });
63
+ }
64
+ module.exports = { ensure, BUY_URL, storePath, pullFeed };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@readystack/autorenew-signup-lint",
3
+ "version": "1.0.0",
4
+ "description": "Sixteen statutory checks on the signup page AB 2863 rewrote, in your editor and in the browser",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "auto-renewal",
11
+ "subscription",
12
+ "checkout",
13
+ "california-arl",
14
+ "ab-2863",
15
+ "click-to-cancel",
16
+ "negative-option",
17
+ "compliance",
18
+ "dark-patterns"
19
+ ],
20
+ "homepage": "https://getreadystack.com",
21
+ "funding": "https://buy.polar.sh/polar_cl_ysb3yWUhknvn2fWoPwchsZqFfbA3X5hijEY2x1jBBhw",
22
+ "bin": {
23
+ "autorenew-signup-lint": "cli.js"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "mcpName": "io.github.jmshinhwa/autorenew-signup-lint",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/jmshinhwa/readystack-themes.git",
32
+ "directory": "autorenew-signup-lint"
33
+ },
34
+ "files": [
35
+ "cli.js",
36
+ "license.js",
37
+ "rules.json",
38
+ "strings.json",
39
+ "README.md",
40
+ "LICENSE.txt",
41
+ "engine.js"
42
+ ]
43
+ }
package/rules.json ADDED
@@ -0,0 +1,273 @@
1
+ [
2
+ {
3
+ "check": "arl_renewal_term_missing",
4
+ "sev": "error",
5
+ "mode": "doc",
6
+ "trigger": [
7
+ "auto[- ]?renew",
8
+ "renews automatically",
9
+ "billed (monthly|annually|yearly|every)",
10
+ "recurring (charge|payment|billing)",
11
+ "\\$\\s?\\d[\\d.,]*\\s*(/|per\\s+)?\\s*(mo\\b|month|yr\\b|year)",
12
+ "free trial",
13
+ "start (my |your )?(free )?trial",
14
+ "subscribe",
15
+ "subscription"
16
+ ],
17
+ "required": [
18
+ "until (you )?cancel",
19
+ "continues? until cancell?ed",
20
+ "renews? (each|every) (month|year)",
21
+ "until cancell?ed"
22
+ ],
23
+ "msg": "Auto-renewal is offered but the page never says the plan keeps renewing until the customer cancels. That term is part of the automatic renewal offer under Cal. Bus. & Prof. Code 17601(b) and must be shown before consent."
24
+ },
25
+ {
26
+ "check": "arl_recurring_price_missing",
27
+ "sev": "error",
28
+ "mode": "doc",
29
+ "trigger": [
30
+ "auto[- ]?renew",
31
+ "renews automatically",
32
+ "billed (monthly|annually|yearly|every)",
33
+ "recurring (charge|payment|billing)",
34
+ "\\$\\s?\\d[\\d.,]*\\s*(/|per\\s+)?\\s*(mo\\b|month|yr\\b|year)",
35
+ "free trial",
36
+ "start (my |your )?(free )?trial",
37
+ "subscribe",
38
+ "subscription"
39
+ ],
40
+ "required": [
41
+ "\\$\\s?\\d[\\d.,]*\\s*(/|per\\s+)?\\s*(mo\\b|month|yr\\b|year)",
42
+ "\\$\\s?\\d[\\d.,]*\\s+(a|per|every)\\s+(month|year)",
43
+ "\\d+\\s*(USD|dollars)\\s*(a|per)\\s*(month|year)"
44
+ ],
45
+ "msg": "No recurring amount with its frequency anywhere on the page. The charge and how often it repeats must be clear and conspicuous in the offer, not only on the receipt."
46
+ },
47
+ {
48
+ "check": "arl_prechecked_consent",
49
+ "sev": "error",
50
+ "mode": "line",
51
+ "any": [
52
+ "<input[^>]*checkbox[^>]*\\bchecked(?![\\w=-])",
53
+ "<input[^>]*\\bchecked(?![\\w=-])[^>]*checkbox",
54
+ "checkbox[^>]*\\bdefaultChecked(?!\\s*=\\s*\\{?false)",
55
+ "\\bdefaultChecked[^>]*checkbox",
56
+ "\\bchecked\\s*[:=]\\s*\\{?true"
57
+ ],
58
+ "msg": "This consent box arrives pre-ticked. A pre-checked box is not the affirmative consent AB 2863 requires; the customer has to act to accept the renewal terms."
59
+ },
60
+ {
61
+ "check": "arl_consent_bundled",
62
+ "sev": "error",
63
+ "mode": "line",
64
+ "all": [
65
+ "type=[\\\"']?checkbox|<label|role=[\\\"']checkbox|defaultChecked",
66
+ "terms of (service|use)|terms and conditions|privacy policy",
67
+ "auto[- ]?renew|recurring|renews|subscription"
68
+ ],
69
+ "msg": "One control accepts the terms of service and the auto-renewal offer together. AB 2863 requires consent to the automatic renewal terms to be collected separately from every other term."
70
+ },
71
+ {
72
+ "check": "arl_no_consent_control",
73
+ "sev": "warn",
74
+ "mode": "doc",
75
+ "trigger": [
76
+ "auto[- ]?renew",
77
+ "renews automatically",
78
+ "billed (monthly|annually|yearly|every)",
79
+ "recurring (charge|payment|billing)",
80
+ "\\$\\s?\\d[\\d.,]*\\s*(/|per\\s+)?\\s*(mo\\b|month|yr\\b|year)",
81
+ "free trial",
82
+ "start (my |your )?(free )?trial",
83
+ "subscribe",
84
+ "subscription"
85
+ ],
86
+ "required": [
87
+ "type=[\\\"']?checkbox",
88
+ "role=[\\\"']checkbox",
89
+ "<input[^>]*checkbox"
90
+ ],
91
+ "msg": "No separate consent control appears on the page. The renewal terms have to be accepted, not merely displayed next to a Pay button."
92
+ },
93
+ {
94
+ "check": "arl_cancel_path_missing",
95
+ "sev": "error",
96
+ "mode": "doc",
97
+ "trigger": [
98
+ "auto[- ]?renew",
99
+ "renews automatically",
100
+ "billed (monthly|annually|yearly|every)",
101
+ "recurring (charge|payment|billing)",
102
+ "\\$\\s?\\d[\\d.,]*\\s*(/|per\\s+)?\\s*(mo\\b|month|yr\\b|year)",
103
+ "free trial",
104
+ "start (my |your )?(free )?trial",
105
+ "subscribe",
106
+ "subscription"
107
+ ],
108
+ "required": [
109
+ "cancel (any ?time|online|whenever|at will)",
110
+ "cancel (your |the )?(subscription|plan|membership)",
111
+ "href=[\\\"'][^\\\"']*cancel",
112
+ "/cancel",
113
+ "cancellation policy"
114
+ ],
115
+ "msg": "Nothing tells the customer how to cancel. An offer accepted online must be cancellable online, and the cancellation policy is one of the terms that has to be disclosed up front."
116
+ },
117
+ {
118
+ "check": "arl_cancel_requires_human",
119
+ "sev": "error",
120
+ "mode": "line",
121
+ "any": [
122
+ "(call|phone|speak (to|with)|contact (us|support|customer service)|live (agent|chat)|email us|chat with)[^.<\\n]{0,60}cancel",
123
+ "cancel[^.<\\n]{0,60}(call us|call our|by phone|contact (us|support)|speak (to|with)|live agent|send us an email)"
124
+ ],
125
+ "msg": "Cancellation is routed through a person. Someone who subscribed online must be able to cancel online, at will, without being made to talk to anyone first."
126
+ },
127
+ {
128
+ "check": "arl_trial_price_after_missing",
129
+ "sev": "error",
130
+ "mode": "doc",
131
+ "trigger": [
132
+ "free trial",
133
+ "trial period",
134
+ "\\d+[- ]day trial",
135
+ "try (it )?free"
136
+ ],
137
+ "required": [
138
+ "(then|after (the|your) trial|when (the|your) trial ends|at the end of (the|your) trial|once the trial ends)[^.<\\n]{0,80}\\$\\s?\\d",
139
+ "\\$\\s?\\d[^.<\\n]{0,80}(after (the|your) trial|when (the|your) trial ends|once the trial ends)"
140
+ ],
141
+ "msg": "A free trial converts here with no price named for what comes after it. The post-trial charge and the day it starts belong to the offer terms the customer consents to."
142
+ },
143
+ {
144
+ "check": "arl_trial_reminder_missing",
145
+ "sev": "warn",
146
+ "mode": "doc",
147
+ "trigger": [
148
+ "free trial",
149
+ "trial period",
150
+ "\\d+[- ]day trial",
151
+ "try (it )?free"
152
+ ],
153
+ "required": [
154
+ "remind(er|s)?\\b",
155
+ "we(’|')?ll (email|notify|let you know)",
156
+ "we will (email|notify)",
157
+ "notice before",
158
+ "3[-–]21 days"
159
+ ],
160
+ "msg": "For a free or discounted trial longer than 31 days, AB 2863 wants a notice 3 to 21 days before it converts to a paid term. Nothing on this page promises one."
161
+ },
162
+ {
163
+ "check": "arl_intro_price_no_regular",
164
+ "sev": "warn",
165
+ "mode": "doc",
166
+ "trigger": [
167
+ "first (month|year|\\d+ months?)",
168
+ "intro(ductory)? (price|offer|rate)",
169
+ "for the first \\d+",
170
+ "\\d+% off"
171
+ ],
172
+ "required": [
173
+ "(then|after that|thereafter|regular price|renews at|standard price|full price)[^.<\\n]{0,60}\\$\\s?\\d",
174
+ "\\$\\s?\\d[^.<\\n]{0,40}(thereafter|after the (first|intro)|regular price)"
175
+ ],
176
+ "msg": "An introductory price is advertised without the regular price that follows it or the date it starts. A promotional rate does not change what has to be disclosed about the renewal."
177
+ },
178
+ {
179
+ "check": "arl_annual_reminder_missing",
180
+ "sev": "warn",
181
+ "mode": "doc",
182
+ "trigger": [
183
+ "billed (annually|yearly)",
184
+ "per year",
185
+ "/\\s?(yr|year)\\b",
186
+ "annual (plan|subscription|billing)",
187
+ "12[- ]month"
188
+ ],
189
+ "required": [
190
+ "(annual|yearly|once a year) reminder",
191
+ "remind you (each|every) year",
192
+ "15[-–]45 days",
193
+ "reminder[^.<\\n]{0,40}before (each|every|your)",
194
+ "before (each|your) (annual )?renewal"
195
+ ],
196
+ "msg": "A term of a year or longer with no annual reminder anywhere. AB 2863 requires notice at least once a year, 15 to 45 days before the renewal date."
197
+ },
198
+ {
199
+ "check": "arl_consent_record_missing",
200
+ "sev": "info",
201
+ "mode": "doc",
202
+ "trigger": [
203
+ "type=[\\\"']?checkbox",
204
+ "role=[\\\"']checkbox"
205
+ ],
206
+ "required": [
207
+ "consent(ed)?[_-]?(at|date|timestamp)",
208
+ "data-arl",
209
+ "agreed_?At",
210
+ "accepted_?At",
211
+ "consentTimestamp",
212
+ "name=[\\\"']consent"
213
+ ],
214
+ "msg": "Nothing on the page captures what was shown and when it was accepted. The business has to keep proof of the customer's consent for three years, and a screenshot of the design is not it."
215
+ },
216
+ {
217
+ "check": "arl_vacated_ftc_rule",
218
+ "sev": "error",
219
+ "mode": "line",
220
+ "any": [
221
+ "click[- ]to[- ]cancel rule",
222
+ "FTC[^.<\\n]{0,40}negative[- ]option rule",
223
+ "negative[- ]option rule[^.<\\n]{0,40}(takes effect|effective|requires|compliant)"
224
+ ],
225
+ "msg": "This page leans on the FTC click-to-cancel (Negative Option) Rule. The Eighth Circuit vacated that rule on 2025-07-08, so it binds nobody; California's ARL is what still governs this checkout."
226
+ },
227
+ {
228
+ "check": "arl_disclosure_not_adjacent",
229
+ "sev": "warn",
230
+ "mode": "near",
231
+ "window": 12,
232
+ "trigger": [
233
+ "<button[^>]*type=[\\\"']?submit",
234
+ "type=[\\\"']?submit",
235
+ "<button[^>]*>\\s*(subscribe|start|continue|pay|place order|sign up|upgrade)"
236
+ ],
237
+ "near": [
238
+ "until (you )?cancel",
239
+ "renews? (each|every|automatically)",
240
+ "\\$\\s?\\d[\\d.,]*\\s*(/|per\\s+)?\\s*(mo\\b|month|yr\\b|year)",
241
+ "cancel any ?time"
242
+ ],
243
+ "msg": "The submit control sits more than 12 lines away from any statement of the renewal terms. The law asks for those terms in visual proximity to the request for consent, not further down the page."
244
+ },
245
+ {
246
+ "check": "arl_retention_maze",
247
+ "sev": "warn",
248
+ "mode": "line",
249
+ "all": [
250
+ "cancel",
251
+ "are you sure|before you go|wait[!,]|keep (my|your) (plan|subscription|benefits)|don'?t (go|leave)|stay with us"
252
+ ],
253
+ "msg": "A save offer stands between the customer and cancelling. A retention step is allowed only if a plain cancel control stays visible on the same screen and works immediately."
254
+ },
255
+ {
256
+ "check": "arl_in_force",
257
+ "sev": "info",
258
+ "mode": "deadline",
259
+ "date": "2026-01-01",
260
+ "trigger": [
261
+ "auto[- ]?renew",
262
+ "renews automatically",
263
+ "billed (monthly|annually|yearly|every)",
264
+ "recurring (charge|payment|billing)",
265
+ "\\$\\s?\\d[\\d.,]*\\s*(/|per\\s+)?\\s*(mo\\b|month|yr\\b|year)",
266
+ "free trial",
267
+ "start (my |your )?(free )?trial",
268
+ "subscribe",
269
+ "subscription"
270
+ ],
271
+ "msg": "Auto-renewal offer detected. California's Automatic Renewal Law as amended by AB 2863 {when}. Goods or services billed in violation are an unconditional gift to the customer under Cal. Bus. & Prof. Code 17603."
272
+ }
273
+ ]
package/strings.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "Auto-Renewal Signup Lint (California ARL)",
3
+ "subtitle": "Sixteen statutory checks on the signup page AB 2863 rewrote, in your editor and in the browser",
4
+ "bin": "autorenew-signup-lint",
5
+ "price": 29,
6
+ "free": "Check the signup or pricing page open in the editor against all 16 rules, free and unlimited, plus the same engine free in the browser.",
7
+ "paid": "Sweep every matching page in the workspace and write one dated report file into the folder, yours to keep and attach to a review.",
8
+ "need_key": "This option needs a licence (Auto-Renewal Signup Lint (California ARL)).",
9
+ "exts": []
10
+ }