install-guard 1.1.1 → 1.1.2

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/src/format.js CHANGED
@@ -1,106 +1,127 @@
1
1
  import chalk from "chalk";
2
2
 
3
- const SEPARATOR = chalk.gray("━".repeat(50));
4
- const THIN_SEP = chalk.gray("".repeat(50));
3
+ const W = 58;
4
+ const SEPARATOR = chalk.gray("".repeat(W));
5
+ const THIN_SEP = chalk.gray("─".repeat(W));
5
6
 
6
7
  function riskBar(score) {
7
8
  const filled = score;
8
9
  const empty = 10 - score;
9
10
  const color =
10
- score <= 3 ? chalk.green : score <= 6 ? chalk.yellow : chalk.red;
11
+ score <= 2 ? chalk.green : score <= 5 ? chalk.yellow : score <= 7 ? chalk.red : chalk.redBright;
11
12
  return color("█".repeat(filled)) + chalk.gray("░".repeat(empty));
12
13
  }
13
14
 
14
- function riskLabel(level) {
15
- switch (level) {
16
- case "low":
17
- return chalk.green.bold("✅ Low Risk");
18
- case "medium":
19
- return chalk.yellow.bold("⚠ Medium Risk");
20
- case "high":
21
- return chalk.red.bold("🚨 High Risk");
15
+ function riskLabel(label) {
16
+ switch (label) {
17
+ case "LOW":
18
+ return chalk.green.bold("✅ LOW");
19
+ case "MEDIUM":
20
+ return chalk.yellow.bold("⚠ MEDIUM");
21
+ case "HIGH":
22
+ return chalk.red.bold("🚨 HIGH");
23
+ case "CRITICAL":
24
+ return chalk.redBright.bold("💀 CRITICAL");
25
+ default:
26
+ return label;
22
27
  }
23
28
  }
24
29
 
25
- function checkIcon(status) {
26
- switch (status) {
27
- case "pass":
28
- return chalk.green("");
29
- case "warn":
30
- return chalk.yellow("⚠");
31
- case "fail":
30
+ function severityIcon(severity) {
31
+ switch (severity) {
32
+ case "critical":
33
+ return chalk.redBright("");
34
+ case "high":
32
35
  return chalk.red("✘");
36
+ case "medium":
37
+ return chalk.yellow("⚠");
38
+ case "info":
39
+ return chalk.green("✔");
40
+ default:
41
+ return chalk.gray("·");
42
+ }
43
+ }
44
+
45
+ function severityColor(severity) {
46
+ switch (severity) {
47
+ case "critical":
48
+ return chalk.redBright;
49
+ case "high":
50
+ return chalk.red;
51
+ case "medium":
52
+ return chalk.yellow;
53
+ case "info":
54
+ return chalk.green;
55
+ default:
56
+ return chalk.white;
33
57
  }
34
58
  }
35
59
 
36
60
  function timeAgo(dateStr) {
37
61
  if (!dateStr) return "Unknown";
38
62
  const diff = Date.now() - new Date(dateStr).getTime();
39
- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
40
- if (days < 1) return "today";
63
+ const hours = Math.floor(diff / (1000 * 60 * 60));
64
+ if (hours < 1) return "just now";
65
+ if (hours < 24) return `${hours} hour(s) ago`;
66
+ const days = Math.floor(hours / 24);
41
67
  if (days === 1) return "1 day ago";
42
68
  if (days < 30) return `${days} days ago`;
43
69
  const months = Math.floor(days / 30);
44
- if (months === 1) return "1 month ago";
45
- if (months < 12) return `${months} months ago`;
70
+ if (months < 12) return `${months} month(s) ago`;
46
71
  const years = Math.floor(days / 365);
47
- if (years === 1) return "1 year ago";
48
- return `${years} years ago`;
72
+ return `${years} year(s) ago`;
49
73
  }
50
74
 
51
- export function formatAnalysis(data, result) {
75
+ /**
76
+ * Formats the full pipeline result for CLI display.
77
+ */
78
+ export function formatPipelineResult(result) {
52
79
  const lines = [];
53
80
 
54
81
  lines.push("");
55
82
  lines.push(SEPARATOR);
56
83
  lines.push(
57
- chalk.bold(` 📦 ${data.name} `) + chalk.gray(`v${data.version}`)
84
+ chalk.bold(` 📦 ${result.package} `) + chalk.gray(`v${result.version}`)
58
85
  );
59
- if (data.description) {
60
- lines.push(chalk.gray(` ${data.description.slice(0, 70)}`));
86
+ if (result.description) {
87
+ lines.push(chalk.gray(` ${result.description.slice(0, W - 4)}`));
61
88
  }
62
89
  lines.push(SEPARATOR);
63
90
 
64
- // Risk score
91
+ // ── Risk Score ──────────────────────────────
65
92
  lines.push("");
66
93
  lines.push(
67
- ` Risk Score: ${chalk.bold(`${result.score}/10`)} ${riskLabel(result.level)}`
94
+ ` Risk Level: ${riskLabel(result.label)} ${chalk.bold(`(${result.score}/10)`)}`
68
95
  );
69
96
  lines.push(` ${riskBar(result.score)}`);
70
97
 
71
98
  lines.push("");
72
99
  lines.push(THIN_SEP);
73
100
 
74
- // Package info
101
+ // ── Package Info ────────────────────────────
75
102
  lines.push("");
76
103
  lines.push(chalk.bold(" 📊 Package Info"));
77
104
  lines.push("");
78
105
 
79
106
  const info = [
80
- ["Downloads (weekly)", data.downloads.toLocaleString()],
81
- ["Maintainers", String(data.maintainers.length)],
82
- [
83
- "License",
84
- typeof data.license === "string"
85
- ? data.license
86
- : data.license?.type || "Unknown",
87
- ],
88
- ["Last Published", timeAgo(data.lastPublished)],
89
- ["Dependencies", String(data.dependencies)],
90
- ["Versions", String(data.totalVersions)],
107
+ ["Downloads (weekly)", result.downloads.toLocaleString()],
108
+ ["Maintainers", result.maintainers.join(", ") || "None"],
109
+ ["License", typeof result.license === "string" ? result.license : result.license?.type || "Unknown"],
110
+ ["Published", timeAgo(result.publishedAt)],
111
+ ["Dependencies", String(result.dependencyCount)],
112
+ ["Total Versions", String(result.totalVersions)],
113
+ ["Repository", result.repository ? "" : "✘ None"],
91
114
  ];
92
115
 
93
116
  for (const [label, value] of info) {
94
- lines.push(
95
- ` ${chalk.gray(label.padEnd(22))} ${chalk.white(value)}`
96
- );
117
+ lines.push(` ${chalk.gray(label.padEnd(22))} ${chalk.white(value)}`);
97
118
  }
98
119
 
99
- if (data.deprecated) {
120
+ if (result.deprecated) {
100
121
  lines.push("");
101
122
  lines.push(
102
123
  chalk.red.bold(
103
- ` ⚠ DEPRECATED: ${typeof data.deprecated === "string" ? data.deprecated : "This package is deprecated"}`
124
+ ` ⚠ DEPRECATED: ${typeof result.deprecated === "string" ? result.deprecated : "This package is deprecated"}`
104
125
  )
105
126
  );
106
127
  }
@@ -108,20 +129,51 @@ export function formatAnalysis(data, result) {
108
129
  lines.push("");
109
130
  lines.push(THIN_SEP);
110
131
 
111
- // Security checks
132
+ // ── Findings ────────────────────────────────
112
133
  lines.push("");
113
- lines.push(chalk.bold(" 🔍 Security Checks"));
134
+ lines.push(chalk.bold(" 🔍 Findings"));
114
135
  lines.push("");
115
136
 
116
- for (const check of result.checks) {
117
- const icon = checkIcon(check.status);
118
- const detail =
119
- check.status === "pass"
120
- ? chalk.green(check.detail)
121
- : check.status === "warn"
122
- ? chalk.yellow(check.detail)
123
- : chalk.red(check.detail);
124
- lines.push(` ${icon} ${chalk.gray(check.label + ":")} ${detail}`);
137
+ // Group by severity
138
+ const critical = result.findings.filter((f) => f.severity === "critical");
139
+ const high = result.findings.filter((f) => f.severity === "high");
140
+ const medium = result.findings.filter((f) => f.severity === "medium");
141
+ const info2 = result.findings.filter((f) => f.severity === "info");
142
+
143
+ for (const group of [critical, high, medium, info2]) {
144
+ for (const f of group) {
145
+ const icon = severityIcon(f.severity);
146
+ const color = severityColor(f.severity);
147
+ lines.push(` ${icon} ${color(f.message)}`);
148
+ }
149
+ }
150
+
151
+ // ── Recommendation ──────────────────────────
152
+ if (result.recommendation) {
153
+ lines.push("");
154
+ lines.push(THIN_SEP);
155
+ lines.push("");
156
+ lines.push(chalk.bold(" 💡 Recommendation"));
157
+ lines.push("");
158
+ if (result.label === "CRITICAL" || result.label === "HIGH") {
159
+ lines.push(chalk.red(` ⛔ Avoid installing ${result.package}@${result.version}`));
160
+ }
161
+ lines.push(
162
+ chalk.green(
163
+ ` ✔ Safe alternative: ${result.package}@${chalk.bold(result.recommendation.version)}`
164
+ )
165
+ );
166
+ lines.push(
167
+ chalk.gray(` ${result.recommendation.reason}`)
168
+ );
169
+ } else if (result.label === "CRITICAL" || result.label === "HIGH") {
170
+ lines.push("");
171
+ lines.push(THIN_SEP);
172
+ lines.push("");
173
+ lines.push(chalk.bold(" 💡 Recommendation"));
174
+ lines.push("");
175
+ lines.push(chalk.red(` ⛔ Avoid installing this version`));
176
+ lines.push(chalk.gray(" No safe alternative version found"));
125
177
  }
126
178
 
127
179
  lines.push("");
@@ -131,10 +183,13 @@ export function formatAnalysis(data, result) {
131
183
  return lines.join("\n");
132
184
  }
133
185
 
186
+ /**
187
+ * Formats a summary table for scan results.
188
+ */
134
189
  export function formatScanSummary(results) {
135
190
  const lines = [];
136
191
 
137
- results.sort((a, b) => b.result.score - a.result.score);
192
+ results.sort((a, b) => b.score - a.score);
138
193
 
139
194
  lines.push("");
140
195
  lines.push(SEPARATOR);
@@ -142,57 +197,58 @@ export function formatScanSummary(results) {
142
197
  lines.push(SEPARATOR);
143
198
  lines.push("");
144
199
 
145
- const nameWidth = Math.max(
146
- 20,
147
- ...results.map((r) => r.data.name.length + 2)
148
- );
200
+ const nameWidth = Math.max(20, ...results.map((r) => r.package.length + 2));
149
201
 
150
202
  lines.push(
151
203
  chalk.gray(
152
204
  " " +
153
205
  "Package".padEnd(nameWidth) +
154
- "Risk".padEnd(12) +
155
- "Status"
206
+ "Score".padEnd(10) +
207
+ "Level"
156
208
  )
157
209
  );
158
- lines.push(chalk.gray(" " + "─".repeat(nameWidth + 26)));
159
-
160
- for (const { data, result } of results) {
161
- const name = data.name.padEnd(nameWidth);
162
- const risk = `${result.score}/10`.padEnd(12);
163
- let status;
164
- switch (result.level) {
165
- case "low":
166
- status = chalk.green("✅ Safe");
210
+ lines.push(chalk.gray(" " + "─".repeat(nameWidth + 24)));
211
+
212
+ for (const r of results) {
213
+ const name = r.package.padEnd(nameWidth);
214
+ const score = `${r.score}/10`.padEnd(10);
215
+ let level;
216
+ switch (r.label) {
217
+ case "LOW":
218
+ level = chalk.green("✅ Low");
219
+ break;
220
+ case "MEDIUM":
221
+ level = chalk.yellow("⚠ Medium");
167
222
  break;
168
- case "medium":
169
- status = chalk.yellow("⚠ Medium");
223
+ case "HIGH":
224
+ level = chalk.red("🚨 High");
170
225
  break;
171
- case "high":
172
- status = chalk.red("🚨 High Risk");
226
+ case "CRITICAL":
227
+ level = chalk.redBright("💀 Critical");
173
228
  break;
174
229
  }
175
230
 
176
231
  const colorFn =
177
- result.level === "high"
232
+ r.label === "CRITICAL" || r.label === "HIGH"
178
233
  ? chalk.red
179
- : result.level === "medium"
234
+ : r.label === "MEDIUM"
180
235
  ? chalk.yellow
181
236
  : chalk.white;
182
- lines.push(` ${colorFn(name)}${risk}${status}`);
237
+ lines.push(` ${colorFn(name)}${score}${level}`);
183
238
  }
184
239
 
185
240
  lines.push("");
186
241
  lines.push(THIN_SEP);
187
242
 
188
- const safe = results.filter((r) => r.result.level === "low").length;
189
- const medium = results.filter(
190
- (r) => r.result.level === "medium"
191
- ).length;
192
- const high = results.filter((r) => r.result.level === "high").length;
243
+ const low = results.filter((r) => r.label === "LOW").length;
244
+ const med = results.filter((r) => r.label === "MEDIUM").length;
245
+ const high = results.filter((r) => r.label === "HIGH").length;
246
+ const crit = results.filter((r) => r.label === "CRITICAL").length;
193
247
 
194
248
  lines.push(
195
- ` ${chalk.bold("Summary:")} ${chalk.green(`${safe} safe`)} · ${chalk.yellow(`${medium} medium`)} · ${chalk.red(`${high} high risk`)}`
249
+ ` ${chalk.bold("Total:")} ${results.length} packages ` +
250
+ `${chalk.green(`${low} low`)} · ${chalk.yellow(`${med} medium`)} · ` +
251
+ `${chalk.red(`${high} high`)} · ${chalk.redBright(`${crit} critical`)}`
196
252
  );
197
253
  lines.push("");
198
254
  lines.push(SEPARATOR);
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { analyze } from "./analyze.js";
2
2
 
3
- export async function analyzePackage(pkg) {
4
- return await analyze(pkg);
3
+ export async function analyzePackage(pkg, version, opts) {
4
+ return await analyze(pkg, version, opts);
5
5
  }
package/src/install.js CHANGED
@@ -2,9 +2,8 @@ import { execFileSync } from "child_process";
2
2
  import readline from "readline";
3
3
  import chalk from "chalk";
4
4
  import ora from "ora";
5
- import { getPackageData } from "./npm.js";
6
- import { calculateRisk } from "./score.js";
7
- import { formatAnalysis } from "./format.js";
5
+ import { runPipeline } from "./services/pipeline.js";
6
+ import { formatPipelineResult } from "./format.js";
8
7
 
9
8
  const VALID_PKG_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9._^~>=<|-]+)?$/i;
10
9
 
@@ -30,27 +29,34 @@ export async function analyzeAndPrompt(pkgName) {
30
29
 
31
30
  const spinner = ora(`Analyzing ${pkgName}...`).start();
32
31
 
33
- let data, result;
32
+ let result;
34
33
  try {
35
- data = await getPackageData(pkgName);
36
- result = calculateRisk(data);
34
+ result = await runPipeline(pkgName);
37
35
  spinner.stop();
38
36
  } catch (err) {
39
37
  spinner.fail(`Failed to analyze "${pkgName}": ${err.message}`);
40
38
  return;
41
39
  }
42
40
 
43
- console.log(formatAnalysis(data, result));
41
+ console.log(formatPipelineResult(result));
44
42
 
45
- if (result.level === "high") {
43
+ if (result.label === "CRITICAL") {
46
44
  const ans = await askQuestion(
47
- chalk.red.bold(" High risk package. Continue install? (y/n): ")
45
+ chalk.redBright.bold(" 💀 CRITICAL risk. Are you absolutely sure? (y/n): ")
48
46
  );
49
47
  if (ans.toLowerCase() !== "y") {
50
48
  console.log(chalk.yellow("\n Installation aborted.\n"));
51
49
  return;
52
50
  }
53
- } else if (result.level === "medium") {
51
+ } else if (result.label === "HIGH") {
52
+ const ans = await askQuestion(
53
+ chalk.red.bold(" 🚨 HIGH risk package. Continue install? (y/n): ")
54
+ );
55
+ if (ans.toLowerCase() !== "y") {
56
+ console.log(chalk.yellow("\n Installation aborted.\n"));
57
+ return;
58
+ }
59
+ } else if (result.label === "MEDIUM") {
54
60
  const ans = await askQuestion(
55
61
  chalk.yellow(" ⚠ Medium risk. Continue install? (y/n): ")
56
62
  );
package/src/scan.js CHANGED
@@ -2,11 +2,10 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import chalk from "chalk";
4
4
  import ora from "ora";
5
- import { getPackageData } from "./npm.js";
6
- import { calculateRisk } from "./score.js";
7
- import { formatAnalysis, formatScanSummary } from "./format.js";
5
+ import { runPipeline } from "./services/pipeline.js";
6
+ import { formatPipelineResult, formatScanSummary } from "./format.js";
8
7
 
9
- export async function scanProject({ verbose } = {}) {
8
+ export async function scanProject({ verbose, json, skipGithub } = {}) {
10
9
  const pkgPath = path.resolve("package.json");
11
10
 
12
11
  if (!fs.existsSync(pkgPath)) {
@@ -36,18 +35,17 @@ export async function scanProject({ verbose } = {}) {
36
35
  for (const dep of depNames) {
37
36
  spinner.start(`Analyzing ${dep}...`);
38
37
  try {
39
- const data = await getPackageData(dep);
40
- const result = calculateRisk(data);
41
- results.push({ data, result });
38
+ const result = await runPipeline(dep, undefined, { skipGithub: skipGithub !== false });
39
+ results.push(result);
42
40
 
43
41
  if (verbose) {
44
42
  spinner.stop();
45
- console.log(formatAnalysis(data, result));
43
+ console.log(formatPipelineResult(result));
46
44
  } else {
47
45
  const icon =
48
- result.level === "high"
46
+ result.label === "CRITICAL" || result.label === "HIGH"
49
47
  ? "🚨"
50
- : result.level === "medium"
48
+ : result.label === "MEDIUM"
51
49
  ? "⚠"
52
50
  : "✅";
53
51
  spinner.succeed(
@@ -59,5 +57,9 @@ export async function scanProject({ verbose } = {}) {
59
57
  }
60
58
  }
61
59
 
62
- console.log(formatScanSummary(results));
60
+ if (json) {
61
+ console.log(JSON.stringify(results, null, 2));
62
+ } else {
63
+ console.log(formatScanSummary(results));
64
+ }
63
65
  }
@@ -0,0 +1,105 @@
1
+ import { buildContext } from "../utils/registry.js";
2
+ import {
3
+ checkRecentPublish,
4
+ checkDependencyDiff,
5
+ checkScripts,
6
+ checkTyposquat,
7
+ checkMaintainers,
8
+ checkLicense,
9
+ checkGithubVerify,
10
+ checkDeprecation,
11
+ } from "../checks/index.js";
12
+ import { computeScore } from "./scorer.js";
13
+
14
+ /**
15
+ * Runs the full detection pipeline on a package+version.
16
+ * Returns structured results consumable by CLI or JSON output.
17
+ */
18
+ export async function runPipeline(packageName, version, opts = {}) {
19
+ const ctx = await buildContext(packageName, version);
20
+
21
+ // Run sync checks immediately
22
+ const syncResults = [
23
+ checkRecentPublish(ctx),
24
+ checkScripts(ctx),
25
+ checkTyposquat(ctx),
26
+ checkMaintainers(ctx),
27
+ checkLicense(ctx),
28
+ checkDeprecation(ctx),
29
+ ];
30
+
31
+ // Run async checks in parallel
32
+ const asyncChecks = [checkDependencyDiff(ctx)];
33
+
34
+ if (!opts.skipGithub) {
35
+ asyncChecks.push(checkGithubVerify(ctx));
36
+ }
37
+
38
+ const asyncResults = await Promise.all(asyncChecks);
39
+ const allChecks = [...syncResults, ...asyncResults];
40
+
41
+ const { score, label, findings } = computeScore(allChecks);
42
+
43
+ // Find a safe version recommendation
44
+ const safeVersion = findSafeVersion(ctx, score);
45
+
46
+ return {
47
+ package: ctx.name,
48
+ version: ctx.version,
49
+ previousVersion: ctx.previousVersion,
50
+ description: ctx.description,
51
+ downloads: ctx.downloads,
52
+ maintainers: ctx.currentMaintainers.map((m) => m.name || m.email),
53
+ license: ctx.license,
54
+ publishedAt: ctx.publishedAt,
55
+ repository: ctx.repository,
56
+ deprecated: ctx.deprecated,
57
+ totalVersions: ctx.totalVersions,
58
+ dependencyCount: Object.keys(ctx.dependencies).length,
59
+
60
+ score,
61
+ label,
62
+ findings,
63
+ checks: allChecks,
64
+
65
+ recommendation: safeVersion,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Suggests the latest safe version — one that is older, with no install scripts,
71
+ * and published more than 7 days ago.
72
+ */
73
+ function findSafeVersion(ctx, currentScore) {
74
+ if (currentScore <= 3) return null; // current version is fine
75
+
76
+ const registry = ctx._registry;
77
+ const timeData = registry.time || {};
78
+ const versions = ctx.allVersions.slice().reverse(); // newest first
79
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
80
+
81
+ for (const v of versions) {
82
+ if (v === ctx.version) continue;
83
+
84
+ const vData = registry.versions[v];
85
+ if (!vData) continue;
86
+ if (vData.deprecated) continue;
87
+ if (
88
+ vData.scripts?.postinstall ||
89
+ vData.scripts?.preinstall ||
90
+ vData.scripts?.install
91
+ )
92
+ continue;
93
+
94
+ const publishTime = new Date(timeData[v] || 0).getTime();
95
+ if (publishTime > sevenDaysAgo) continue;
96
+
97
+ return {
98
+ version: v,
99
+ publishedAt: timeData[v],
100
+ reason: "No install scripts, published > 7 days ago",
101
+ };
102
+ }
103
+
104
+ return null;
105
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Weighted risk scorer.
3
+ * Takes raw check results and computes a normalized 0-10 score with label.
4
+ */
5
+
6
+ // Max raw score (theoretical) — used to normalize
7
+ const MAX_RAW = 50;
8
+
9
+ export function computeScore(checkResults) {
10
+ let rawScore = 0;
11
+ const allFindings = [];
12
+ let hasCritical = false;
13
+
14
+ for (const check of checkResults) {
15
+ for (const f of check.findings) {
16
+ allFindings.push({ ...f, checkId: check.id });
17
+ rawScore += f.score;
18
+ if (f.severity === "critical") hasCritical = true;
19
+ }
20
+ }
21
+
22
+ // Normalize to 0-10
23
+ let score = Math.round((rawScore / MAX_RAW) * 10);
24
+ score = Math.max(0, Math.min(10, score));
25
+
26
+ // Critical findings floor at 7
27
+ if (hasCritical && score < 7) score = 7;
28
+
29
+ let label;
30
+ if (score <= 2) label = "LOW";
31
+ else if (score <= 5) label = "MEDIUM";
32
+ else if (score <= 7) label = "HIGH";
33
+ else label = "CRITICAL";
34
+
35
+ return { score, label, rawScore, findings: allFindings };
36
+ }
@@ -0,0 +1,44 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { createHash } from "crypto";
4
+
5
+ const CACHE_DIR = path.join(
6
+ process.env.HOME || process.env.USERPROFILE || "/tmp",
7
+ ".install-guard-cache"
8
+ );
9
+ const TTL_MS = 15 * 60 * 1000; // 15 minutes
10
+
11
+ function ensureDir() {
12
+ if (!fs.existsSync(CACHE_DIR)) {
13
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
14
+ }
15
+ }
16
+
17
+ function keyToFile(key) {
18
+ const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
19
+ return path.join(CACHE_DIR, `${hash}.json`);
20
+ }
21
+
22
+ export function getCached(key) {
23
+ try {
24
+ const file = keyToFile(key);
25
+ if (!fs.existsSync(file)) return null;
26
+ const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
27
+ if (Date.now() - raw.ts > TTL_MS) {
28
+ fs.unlinkSync(file);
29
+ return null;
30
+ }
31
+ return raw.data;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function setCache(key, data) {
38
+ try {
39
+ ensureDir();
40
+ fs.writeFileSync(keyToFile(key), JSON.stringify({ ts: Date.now(), data }));
41
+ } catch {
42
+ // cache write failures are non-fatal
43
+ }
44
+ }