@readystack/email-footer-law-lint 1.0.0 → 1.0.1
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 +20 -3
- package/cli.js +51 -2
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# Email Footer Law Lint
|
|
2
2
|
|
|
3
|
-

|
|
4
|
-
|
|
5
3
|
Fourteen checks on an HTML email footer: CAN-SPAM postal address and 10-business-day opt-out, CASL's 60-day window, and German 5 DDG, the statute that replaced 5 TMG on 2024-05-14.
|
|
6
4
|
|
|
7
5
|
## Install
|
|
@@ -27,7 +25,26 @@ Node 18+. The same 14 rules as the VS Code extension, from a terminal or CI.
|
|
|
27
25
|
|
|
28
26
|
Outside counsel reads one email footer against CAN-SPAM, CASL and 5 DDG at about $300 an hour, and reads it once.
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
## Use from an AI agent (MCP)
|
|
29
|
+
|
|
30
|
+
Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{ "mcpServers": { "email-footer-law-lint": { "command": "npx", "args": ["-y", "@readystack/email-footer-law-lint", "--mcp"] } } }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
## Use in CI
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
- name: Email Footer Law Lint
|
|
42
|
+
run: npx -y @readystack/email-footer-law-lint --dir . --ci
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
(container: `docker run --rm -v "$PWD:/work" getreadystack/email-footer-law-lint --dir /work --ci`)
|
|
46
|
+
|
|
47
|
+
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.
|
|
31
48
|
|
|
32
49
|
[Get a licence](https://buy.polar.sh/polar_cl_UpcPQkomm2d1N52PaYHHfPjdB89okIRPLaTa70CzAj0)
|
|
33
50
|
|
package/cli.js
CHANGED
|
@@ -38,19 +38,64 @@ function render(rows, fmt) {
|
|
|
38
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
39
|
return o;
|
|
40
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
|
+
}
|
|
41
84
|
function help() {
|
|
42
85
|
return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
|
|
43
86
|
' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
|
|
44
87
|
' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
|
|
45
88
|
' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
|
|
46
89
|
' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
|
|
47
|
-
' ' + S.bin + ' --rules list the ' + RULE_N + ' rules',
|
|
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', '',
|
|
48
92
|
'Free: ' + S.free, 'Licence ($' + S.price + ', once, 7-day refund): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
|
|
49
93
|
}
|
|
50
94
|
(async function main() {
|
|
51
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 구독 피드 병합 (키 있는 손님만)
|
|
52
96
|
const a = process.argv.slice(2);
|
|
53
97
|
const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
|
|
98
|
+
if (a.includes('--mcp')) { mcpServe(); return; }
|
|
54
99
|
if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
|
|
55
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; }
|
|
56
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); }
|
|
@@ -59,7 +104,11 @@ function help() {
|
|
|
59
104
|
const paid = !!(dir || fmt || ci);
|
|
60
105
|
if (paid) {
|
|
61
106
|
const r = await lic.ensure();
|
|
62
|
-
if (!r.ok) {
|
|
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
|
+
}
|
|
63
112
|
}
|
|
64
113
|
const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
|
|
65
114
|
if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@readystack/email-footer-law-lint",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Fourteen checks on an HTML email footer: CAN-SPAM postal address and 10-business-day opt-out, CASL's 60-day window, and German 5 DDG, the statute that replaced 5 TMG on 2024-05-14.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"publishConfig": {
|
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
"engines": {
|
|
26
26
|
"node": ">=18"
|
|
27
27
|
},
|
|
28
|
+
"mcpName": "io.github.jmshinhwa/email-footer-law-lint",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/jmshinhwa/readystack-themes.git",
|
|
32
|
+
"directory": "email-footer-law-lint"
|
|
33
|
+
},
|
|
28
34
|
"files": [
|
|
29
35
|
"cli.js",
|
|
30
36
|
"license.js",
|