@readystack/dotnet-eol-support-gate 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/LICENSE.txt +16 -0
- package/README.md +52 -0
- package/cli.js +120 -0
- package/engine.js +115 -0
- package/license.js +68 -0
- package/package.json +41 -0
- package/rules.json +62 -0
- package/strings.json +10 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.NET EOL & CRA Support Window Gate — Licence
|
|
2
|
+
|
|
3
|
+
Free scope
|
|
4
|
+
Dates one .csproj or global.json you have open: every target framework, SDK pin and runtime-bound package gets its real end-of-support date, the days left, and the months it falls short of the support period you promise. 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
|
+
Runs the same dating over every project file in the workspace at once and writes the dated support-window evidence file (Markdown + CSV) you keep and hand to an auditor. 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,52 @@
|
|
|
1
|
+
# .NET EOL & CRA Support Window Gate
|
|
2
|
+
|
|
3
|
+
Dates every target framework in a .csproj against Microsoft's end-of-support day and against the support period you promise
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx @readystack/dotnet-eol-support-gate file
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node 18+. The same 10 rules as the VS Code extension, from a terminal or CI.
|
|
12
|
+
|
|
13
|
+
## Free
|
|
14
|
+
|
|
15
|
+
- Dates one .csproj, Directory.Build.props or global.json you have open: every target framework, SDK pin and runtime-bound package gets its real end-of-support date, the days left, and the months it falls short of the support period you promise. 10 checks, no key.
|
|
16
|
+
- `--rules` lists every rule
|
|
17
|
+
|
|
18
|
+
## With a licence ($29 once)
|
|
19
|
+
|
|
20
|
+
- Runs the same dating over every project file in the workspace in one pass and writes the dated support-window evidence file you keep and hand to an auditor.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
@readystack/dotnet-eol-support-gate --dir ./templates --report html --out report.html
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Having a contractor inventory runtime support dates across a solution and write the evidence page is commonly quoted as a multi-day engagement
|
|
27
|
+
|
|
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": { "dotnet-eol-support-gate": { "command": "npx", "args": ["-y", "@readystack/dotnet-eol-support-gate", "--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: .NET EOL & CRA Support Window Gate
|
|
42
|
+
run: npx -y @readystack/dotnet-eol-support-gate --dir . --ci
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
(container: `docker run --rm -v "$PWD:/work" getreadystack/dotnet-eol-support-gate --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.
|
|
48
|
+
|
|
49
|
+
[Get a licence](https://buy.polar.sh/polar_cl_Z3kgDSemeuFusqV4CgHenwXtVCDgUEjo92a1x2d70T6)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
<!-- dotnet eol support gate -->
|
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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
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,115 @@
|
|
|
1
|
+
// .NET EOL & CRA Support Window Gate — one brain, used by the extension and by the free web page.
|
|
2
|
+
'use strict';
|
|
3
|
+
var RULES = (typeof module !== 'undefined') ? require('./rules.json') : window.DOTNET_EOL_RULES;
|
|
4
|
+
|
|
5
|
+
// Microsoft .NET release lifecycle — published end-of-support dates.
|
|
6
|
+
var TFM_EOL = {
|
|
7
|
+
'netcoreapp3.1': '2022-12-13', 'net5.0': '2022-05-10', 'net6.0': '2024-11-12',
|
|
8
|
+
'net7.0': '2024-05-14', 'net8.0': '2026-11-10', 'net9.0': '2026-05-12', 'net10.0': '2028-11-14'
|
|
9
|
+
};
|
|
10
|
+
var MAJOR_EOL = { '3': '2022-12-13', '5': '2022-05-10', '6': '2024-11-12', '7': '2024-05-14', '8': '2026-11-10', '9': '2026-05-12', '10': '2028-11-14' };
|
|
11
|
+
var FX_RETIRED = { 'net45': '2016-01-12', 'net451': '2016-01-12', 'net452': '2022-04-26', 'net46': '2022-04-26', 'net461': '2022-04-26' };
|
|
12
|
+
var XAMARIN_END = '2024-05-01';
|
|
13
|
+
var RUNTIME_PKG = /^(Microsoft\.AspNetCore\.|Microsoft\.EntityFrameworkCore|Microsoft\.Extensions\.)/;
|
|
14
|
+
var SOON_DAYS = 180;
|
|
15
|
+
|
|
16
|
+
function day(s) { var p = String(s || '').slice(0, 10).split('-'); return Date.UTC(+p[0], (+p[1] || 1) - 1, +p[2] || 1); }
|
|
17
|
+
function daysBetween(a, b) { return Math.round((day(b) - day(a)) / 86400000); }
|
|
18
|
+
function iso(ms) { return new Date(ms).toISOString().slice(0, 10); }
|
|
19
|
+
function addYears(s, n) { var d = new Date(day(s)); d.setUTCFullYear(d.getUTCFullYear() + Math.round(n)); return iso(d.getTime()); }
|
|
20
|
+
function months(a, b) { return Math.round(daysBetween(a, b) / 30.44); }
|
|
21
|
+
|
|
22
|
+
function check(text, opts) {
|
|
23
|
+
opts = opts || {};
|
|
24
|
+
var today = String(opts.today || new Date().toISOString().slice(0, 10)).slice(0, 10);
|
|
25
|
+
var years = Number(opts.support_years || 5) || 5;
|
|
26
|
+
var placed = String(opts.placed_on_market || today).slice(0, 10);
|
|
27
|
+
var lines = String(text == null ? '' : text).split(/\r?\n/);
|
|
28
|
+
var out = [];
|
|
29
|
+
var declared = null, declaredLine = 0, sawProject = false, sawTfm = false;
|
|
30
|
+
var auditOff = false, auditMode = false, sawAudit = false;
|
|
31
|
+
|
|
32
|
+
function add(id, line, msg) { out.push({ check: id, sev: (RULES.filter(function (r) { return r.id === id; })[0] || {}).sev || 'error', msg: msg, line: line }); }
|
|
33
|
+
|
|
34
|
+
// pass 1 — declared support period and audit settings
|
|
35
|
+
lines.forEach(function (raw, i) {
|
|
36
|
+
var n = i + 1;
|
|
37
|
+
var d = raw.match(/(?:SupportedUntil|SupportPeriodEnd|SupportEndDate|CRA-support-until)\s*[>:=]\s*"?(\d{4}-\d{2}-\d{2})/i);
|
|
38
|
+
if (d && !declared) { declared = d[1]; declaredLine = n; }
|
|
39
|
+
if (/<Project\b|"sdk"\s*:/i.test(raw)) sawProject = true;
|
|
40
|
+
if (/<NuGetAudit>\s*false\s*</i.test(raw)) { auditOff = true; add('nuget_audit_off', n, 'NuGetAudit is switched off, so no package in this project is checked against the advisory database at build time. CRA Annex I Part II expects shipped components to be screened.'); }
|
|
41
|
+
if (/<NuGetAudit>\s*true\s*</i.test(raw)) sawAudit = true;
|
|
42
|
+
if (/<NuGetAuditMode>\s*all\s*</i.test(raw)) { auditMode = true; sawAudit = true; }
|
|
43
|
+
});
|
|
44
|
+
if (sawProject && !auditOff && sawAudit && !auditMode) add('nuget_audit_off', 1, 'NuGetAuditMode is not set to "all", so only your direct PackageReferences are screened and transitive dependencies — where most shipped CVEs live — are skipped.');
|
|
45
|
+
|
|
46
|
+
var windowEnd = declared || addYears(placed, years);
|
|
47
|
+
if (sawProject && !declared) {
|
|
48
|
+
add('support_period_undeclared', 1, 'No support period is declared in this file, so the gate uses the CRA Article 13(8) floor: ' + years + ' years from ' + placed + ', ending ' + windowEnd + '. Add <SupportedUntil>' + windowEnd + '</SupportedUntil> to make the promise explicit.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function dateTfm(tfm, n) {
|
|
52
|
+
var t = tfm.toLowerCase().trim();
|
|
53
|
+
if (/-(preview|rc)/.test(t) || /^net(1[1-9])\.0/.test(t)) {
|
|
54
|
+
add('preview_runtime', n, tfm + ' is a preview or unreleased runtime. Preview builds carry no support period at all, so nothing you promise a buyer is backed by a patch stream.');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (/^(monoandroid|xamarin|xamarinios|xamarinmac)/.test(t)) {
|
|
58
|
+
add('xamarin_retired', n, tfm + ' is a Xamarin target framework. Xamarin support ended on ' + XAMARIN_END + ', ' + daysBetween(XAMARIN_END, today) + ' days ago; move the head project to a net10.0-android / net10.0-ios .NET MAUI target.');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (FX_RETIRED[t]) {
|
|
62
|
+
add('framework_retired', n, tfm + ' (.NET Framework) left support on ' + FX_RETIRED[t] + ', ' + daysBetween(FX_RETIRED[t], today) + ' days ago. Retarget to net472 or later, or to net10.0.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
var base = t.split('-')[0];
|
|
66
|
+
var eol = TFM_EOL[base];
|
|
67
|
+
if (!eol) return;
|
|
68
|
+
var left = daysBetween(today, eol);
|
|
69
|
+
if (left < 0) {
|
|
70
|
+
add('tfm_out_of_support', n, tfm + ' lost security servicing on ' + eol + ' — ' + (-left) + ' days ago. Every runtime CVE published since that day is still open in this build.');
|
|
71
|
+
} else if (left <= SOON_DAYS) {
|
|
72
|
+
add('tfm_ends_soon', n, tfm + ' stops receiving security fixes on ' + eol + ', in ' + left + ' days. A runtime bump is a release, not a patch — schedule it now.');
|
|
73
|
+
}
|
|
74
|
+
if (left >= 0 && day(eol) < day(windowEnd)) {
|
|
75
|
+
var tail = (day(TFM_EOL['net10.0']) >= day(windowEnd)) ? ' net10.0 covers that promise to ' + TFM_EOL['net10.0'] + '.' : ' Even net10.0 only reaches ' + TFM_EOL['net10.0'] + ', so this promise needs a planned retarget on the roadmap, not a one-off bump.';
|
|
76
|
+
add('support_window_gap', n, tfm + ' dies on ' + eol + ', but the support period running to ' + windowEnd + ' leaves ' + months(eol, windowEnd) + ' months you have promised to patch on a runtime nobody patches.' + tail);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// pass 2 — target frameworks, SDK pins, packages
|
|
81
|
+
lines.forEach(function (raw, i) {
|
|
82
|
+
var n = i + 1, m;
|
|
83
|
+
if ((m = raw.match(/<TargetFramework>\s*([^<\s]+)\s*</i))) { sawTfm = true; dateTfm(m[1], n); }
|
|
84
|
+
if ((m = raw.match(/<TargetFrameworks>\s*([^<]+)</i))) {
|
|
85
|
+
sawTfm = true;
|
|
86
|
+
m[1].split(';').forEach(function (t) { if (t.trim()) dateTfm(t, n); });
|
|
87
|
+
}
|
|
88
|
+
if ((m = raw.match(/"version"\s*:\s*"(\d+)\.(\d+)\.(\d+)/))) {
|
|
89
|
+
var eol = MAJOR_EOL[m[1]];
|
|
90
|
+
if (eol && daysBetween(today, eol) < 0) {
|
|
91
|
+
add('sdk_pin_out_of_support', n, 'global.json pins SDK ' + m[1] + '.' + m[2] + '.' + m[3] + '. That band went out of support on ' + eol + ', ' + daysBetween(eol, today) + ' days ago, and the pin overrides every up-to-date SDK on the build machine.');
|
|
92
|
+
} else if (eol && daysBetween(today, eol) <= SOON_DAYS) {
|
|
93
|
+
add('sdk_pin_out_of_support', n, 'global.json pins SDK ' + m[1] + '.' + m[2] + '.' + m[3] + ', whose band goes out of support on ' + eol + ', in ' + daysBetween(today, eol) + ' days. Every CI run will keep building on it until the pin moves.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if ((m = raw.match(/<PackageReference\s+Include="([^"]+)"[^>]*Version="([^"]+)"/i))) {
|
|
97
|
+
var id = m[1], ver = m[2];
|
|
98
|
+
if (/^Xamarin\./i.test(id)) {
|
|
99
|
+
add('xamarin_retired', n, id + ' ' + ver + ' is a Xamarin package. Xamarin support ended on ' + XAMARIN_END + ', ' + daysBetween(XAMARIN_END, today) + ' days ago, and no fix is coming for anything found in it.');
|
|
100
|
+
} else if (RUNTIME_PKG.test(id)) {
|
|
101
|
+
var maj = (ver.match(/^(\d+)/) || [])[1];
|
|
102
|
+
var pe = MAJOR_EOL[maj];
|
|
103
|
+
if (pe && daysBetween(today, pe) < 0) {
|
|
104
|
+
add('runtime_bound_package', n, id + ' ' + ver + ' follows the .NET ' + maj + ' lifecycle, which ended on ' + pe + ' — ' + daysBetween(pe, today) + ' days ago. This package line is unpatched.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return { findings: out, today: today, window_end: windowEnd, support_years: years, declared: !!declared, declared_line: declaredLine, rules: RULES.length, targets_seen: sawTfm };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
var API = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length, TFM_EOL: TFM_EOL };
|
|
114
|
+
if (typeof module !== 'undefined') module.exports = API;
|
|
115
|
+
if (typeof window !== 'undefined') window.DOTNET_EOL_ENGINE = API;
|
package/license.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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 = '9069ee21-6b85-46bb-9a95-17f2e0db8bb0'; // 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_Z3kgDSemeuFusqV4CgHenwXtVCDgUEjo92a1x2d70T6';
|
|
7
|
+
const SLUG = 'dotnet-eol-support-gate';
|
|
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
|
+
const ALL_BENEFIT_ID = '22692551-5203-4467-b1a3-e33cdba6589d'; // s149 2026-09-17 — 팀 키(전 린터 한 키 · Polar benefit) · 상품 benefit 다음에 한 번 더 묻는다
|
|
14
|
+
function validate(key) {
|
|
15
|
+
return validate1(key, BENEFIT_ID).then(function (r) { return (r.ok || r.offline || !/^[0-9a-f-]{36}$/.test(ALL_BENEFIT_ID)) ? r : validate1(key, ALL_BENEFIT_ID); });
|
|
16
|
+
}
|
|
17
|
+
function validate1(key, ben) {
|
|
18
|
+
return new Promise(function (resolve) {
|
|
19
|
+
if (!ORG_ID) return resolve({ ok: false, offline: false });
|
|
20
|
+
const body = JSON.stringify(/^[0-9a-f-]{36}$/.test(ben) ? { key: key, organization_id: ORG_ID, benefit_id: ben } : { key: key, organization_id: ORG_ID });
|
|
21
|
+
const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
|
|
22
|
+
headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
|
|
23
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
24
|
+
res.on('end', function () {
|
|
25
|
+
if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
|
|
26
|
+
try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
|
|
27
|
+
catch (e) { resolve({ ok: false, offline: false }); }
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
|
|
31
|
+
req.on('error', function () { resolve({ ok: false, offline: true }); });
|
|
32
|
+
req.write(body); req.end();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function ensure(explicitKey) {
|
|
36
|
+
const st = load();
|
|
37
|
+
const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
|
|
38
|
+
if (!key) return { ok: false, why: 'no_key' };
|
|
39
|
+
const age = Date.now() - (st.okAt || 0);
|
|
40
|
+
if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
|
|
41
|
+
const r = await validate(String(key).trim());
|
|
42
|
+
if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
|
|
43
|
+
if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
|
|
44
|
+
return { ok: false, why: r.offline ? 'offline' : 'invalid' };
|
|
45
|
+
}
|
|
46
|
+
// ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
|
|
47
|
+
const FEED_URL = 'https://getreadystack.com/api/rules/';
|
|
48
|
+
function pullFeed() {
|
|
49
|
+
const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
|
|
50
|
+
if (!key) return Promise.resolve(cached);
|
|
51
|
+
if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
|
|
52
|
+
return new Promise(function (resolve) {
|
|
53
|
+
let req;
|
|
54
|
+
try {
|
|
55
|
+
req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
|
|
56
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
57
|
+
res.on('end', function () {
|
|
58
|
+
if (res.statusCode !== 200) return resolve(cached);
|
|
59
|
+
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); }
|
|
60
|
+
catch (e) { resolve(cached); }
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
} catch (e) { return resolve(cached); }
|
|
64
|
+
req.on('timeout', function () { req.destroy(); resolve(cached); });
|
|
65
|
+
req.on('error', function () { resolve(cached); });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
module.exports = { ensure, BUY_URL, storePath, pullFeed };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@readystack/dotnet-eol-support-gate",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Dates every target framework in a .csproj against Microsoft's end-of-support day and against the support period you promise",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"dotnet",
|
|
11
|
+
"net8.0 end of support",
|
|
12
|
+
"csproj",
|
|
13
|
+
"end of life",
|
|
14
|
+
"Cyber Resilience Act",
|
|
15
|
+
"maui",
|
|
16
|
+
"lifecycle"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://getreadystack.com",
|
|
19
|
+
"funding": "https://buy.polar.sh/polar_cl_Z3kgDSemeuFusqV4CgHenwXtVCDgUEjo92a1x2d70T6",
|
|
20
|
+
"bin": {
|
|
21
|
+
"dotnet-eol-support-gate": "cli.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"mcpName": "io.github.jmshinhwa/dotnet-eol-support-gate",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/jmshinhwa/readystack-themes.git",
|
|
30
|
+
"directory": "dotnet-eol-support-gate"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"cli.js",
|
|
34
|
+
"license.js",
|
|
35
|
+
"rules.json",
|
|
36
|
+
"strings.json",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE.txt",
|
|
39
|
+
"engine.js"
|
|
40
|
+
]
|
|
41
|
+
}
|
package/rules.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "tfm_out_of_support",
|
|
4
|
+
"sev": "error",
|
|
5
|
+
"title": "Target framework is past its end-of-support date",
|
|
6
|
+
"why": "Microsoft ships no security fixes for it, so every later CVE in the runtime stays open in your build."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "tfm_ends_soon",
|
|
10
|
+
"sev": "error",
|
|
11
|
+
"title": "Target framework loses support inside 180 days",
|
|
12
|
+
"why": "A runtime bump is a release-planning item, not a patch; 180 days is the last comfortable window to schedule it."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "support_window_gap",
|
|
16
|
+
"sev": "error",
|
|
17
|
+
"title": "Runtime dies before the support period you promise ends",
|
|
18
|
+
"why": "CRA Article 13(8) makes the manufacturer support the product for the declared period, at least 5 years, so the runtime has to outlive the promise."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "support_period_undeclared",
|
|
22
|
+
"sev": "warn",
|
|
23
|
+
"title": "No support period declared in the project file",
|
|
24
|
+
"why": "CRA Article 13(8) requires a determined support period; if it is nowhere in the repo, the gate has to assume the 5-year floor."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "framework_retired",
|
|
28
|
+
"sev": "error",
|
|
29
|
+
"title": ".NET Framework 4.6.1 or older",
|
|
30
|
+
"why": ".NET Framework 4.5.2, 4.6 and 4.6.1 all left support on 2022-04-26 and never come back."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "xamarin_retired",
|
|
34
|
+
"sev": "error",
|
|
35
|
+
"title": "Xamarin target or package reference",
|
|
36
|
+
"why": "Xamarin support ended on 2024-05-01; the .NET MAUI move is the only supported path."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "sdk_pin_out_of_support",
|
|
40
|
+
"sev": "error",
|
|
41
|
+
"title": "global.json pins an SDK band that is out of support",
|
|
42
|
+
"why": "A pinned dead SDK silently overrides an up-to-date machine and rebuilds the dead runtime on every CI run."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "preview_runtime",
|
|
46
|
+
"sev": "error",
|
|
47
|
+
"title": "Preview or release-candidate runtime in a shipping project",
|
|
48
|
+
"why": "Preview builds carry no support period at all, so they cannot back any declared support promise."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "runtime_bound_package",
|
|
52
|
+
"sev": "error",
|
|
53
|
+
"title": "Package pinned to a runtime version that is out of support",
|
|
54
|
+
"why": "ASP.NET Core, EF Core and Microsoft.Extensions.* follow the .NET release lifecycle exactly, so a 6.x or 9.x pin is already unpatched."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "nuget_audit_off",
|
|
58
|
+
"sev": "error",
|
|
59
|
+
"title": "NuGet vulnerability audit disabled or not covering transitive packages",
|
|
60
|
+
"why": "CRA Annex I Part II asks manufacturers to handle vulnerabilities in the components they ship, including the ones they did not pick directly."
|
|
61
|
+
}
|
|
62
|
+
]
|
package/strings.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": ".NET EOL & CRA Support Window Gate",
|
|
3
|
+
"subtitle": "Dates every target framework in a .csproj against Microsoft's end-of-support day and against the support period you promise",
|
|
4
|
+
"bin": "dotnet-eol-support-gate",
|
|
5
|
+
"price": 29,
|
|
6
|
+
"free": "Dates one .csproj, Directory.Build.props or global.json you have open: every target framework, SDK pin and runtime-bound package gets its real end-of-support date, the days left, and the months it falls short of the support period you promise. 10 checks, no key.",
|
|
7
|
+
"paid": "Runs the same dating over every project file in the workspace in one pass and writes the dated support-window evidence file you keep and hand to an auditor.",
|
|
8
|
+
"need_key": "This option needs a licence (.NET EOL & CRA Support Window Gate).",
|
|
9
|
+
"exts": []
|
|
10
|
+
}
|