install-guard 1.0.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/install.js CHANGED
@@ -1,7 +1,11 @@
1
- import { execSync } from "child_process";
1
+ import { execFileSync } from "child_process";
2
2
  import readline from "readline";
3
- import { getPackageData } from "./npm.js";
4
- import { calculateRisk } from "./score.js";
3
+ import chalk from "chalk";
4
+ import ora from "ora";
5
+ import { runPipeline } from "./services/pipeline.js";
6
+ import { formatPipelineResult } from "./format.js";
7
+
8
+ const VALID_PKG_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9._^~>=<|-]+)?$/i;
5
9
 
6
10
  function askQuestion(query) {
7
11
  const rl = readline.createInterface({
@@ -18,27 +22,51 @@ function askQuestion(query) {
18
22
  }
19
23
 
20
24
  export async function analyzeAndPrompt(pkgName) {
21
- console.log(`\nAnalyzing ${pkgName}...\n`);
25
+ if (!VALID_PKG_NAME.test(pkgName)) {
26
+ console.log(chalk.red("\n ✘ Invalid package name.\n"));
27
+ return;
28
+ }
22
29
 
23
- const data = await getPackageData(pkgName);
24
- const { score, warnings } = calculateRisk(data);
30
+ const spinner = ora(`Analyzing ${pkgName}...`).start();
25
31
 
26
- console.log(`Risk Score: ${score}/10`);
32
+ let result;
33
+ try {
34
+ result = await runPipeline(pkgName);
35
+ spinner.stop();
36
+ } catch (err) {
37
+ spinner.fail(`Failed to analyze "${pkgName}": ${err.message}`);
38
+ return;
39
+ }
27
40
 
28
- warnings.forEach((w) => console.log(`⚠ ${w}`));
41
+ console.log(formatPipelineResult(result));
29
42
 
30
- if (score >= 6) {
43
+ if (result.label === "CRITICAL") {
31
44
  const ans = await askQuestion(
32
- "\nHigh risk package. Continue install? (y/n): "
45
+ chalk.redBright.bold(" 💀 CRITICAL risk. Are you absolutely sure? (y/n): ")
33
46
  );
34
-
35
47
  if (ans.toLowerCase() !== "y") {
36
- console.log("Installation aborted.");
48
+ console.log(chalk.yellow("\n Installation aborted.\n"));
49
+ return;
50
+ }
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") {
60
+ const ans = await askQuestion(
61
+ chalk.yellow(" ⚠ Medium risk. Continue install? (y/n): ")
62
+ );
63
+ if (ans.toLowerCase() !== "y") {
64
+ console.log(chalk.yellow("\n Installation aborted.\n"));
37
65
  return;
38
66
  }
39
67
  }
40
68
 
41
- console.log("\nInstalling...\n");
42
-
43
- execSync(`npm install ${pkgName}`, { stdio: "inherit" });
69
+ console.log(chalk.green("\n Installing...\n"));
70
+ execFileSync("npm", ["install", pkgName], { stdio: "inherit" });
71
+ console.log(chalk.green(`\n ✔ ${pkgName} installed successfully.\n`));
44
72
  }
package/src/npm.js CHANGED
@@ -1,27 +1,42 @@
1
- import axios from "axios";
2
-
3
1
  export async function getPackageData(pkg) {
4
- const registryUrl = `https://registry.npmjs.org/${pkg}`;
5
- const downloadUrl = `https://api.npmjs.org/downloads/point/last-week/${pkg}`;
2
+ const encodedPkg = encodeURIComponent(pkg).replace("%40", "@");
3
+ const registryUrl = `https://registry.npmjs.org/${encodedPkg}`;
4
+ const downloadUrl = `https://api.npmjs.org/downloads/point/last-week/${encodedPkg}`;
6
5
 
7
6
  const [registryRes, downloadRes] = await Promise.all([
8
- axios.get(registryUrl),
9
- axios.get(downloadUrl),
7
+ fetch(registryUrl).then((r) => {
8
+ if (!r.ok) throw new Error(`Package "${pkg}" not found on npm`);
9
+ return r.json();
10
+ }),
11
+ fetch(downloadUrl)
12
+ .then((r) => r.json())
13
+ .catch(() => ({ downloads: 0 })),
10
14
  ]);
11
15
 
12
- const data = registryRes.data;
13
- const latest = data["dist-tags"].latest;
14
- const versionData = data.versions[latest];
16
+ const data = registryRes;
17
+ const latest = data["dist-tags"]?.latest;
18
+ if (!latest) throw new Error(`No published version found for "${pkg}"`);
19
+
20
+ const versionData = data.versions?.[latest] || {};
21
+ const timeData = data.time || {};
15
22
 
16
23
  return {
17
24
  name: data.name,
18
- maintainers: data.maintainers?.length || 0,
19
- lastPublished: data.time?.[latest],
20
- hasInstallScript:
21
- versionData.scripts?.install ||
22
- versionData.scripts?.postinstall ||
23
- false,
24
25
  version: latest,
25
- downloads: downloadRes.data.downloads || 0,
26
+ description: data.description || "",
27
+ downloads: downloadRes.downloads || 0,
28
+ maintainers: data.maintainers || [],
29
+ license: versionData.license || data.license || "Unknown",
30
+ lastPublished: timeData[latest],
31
+ firstPublished: timeData.created,
32
+ hasInstallScript: !!(
33
+ versionData.scripts?.install ||
34
+ versionData.scripts?.preinstall ||
35
+ versionData.scripts?.postinstall
36
+ ),
37
+ deprecated: versionData.deprecated || false,
38
+ repository: data.repository?.url || versionData.repository?.url || null,
39
+ dependencies: Object.keys(versionData.dependencies || {}).length,
40
+ totalVersions: Object.keys(data.versions || {}).length,
26
41
  };
27
42
  }
package/src/scan.js CHANGED
@@ -1,15 +1,65 @@
1
1
  import fs from "fs";
2
- import { analyzePackage } from "./index.js";
2
+ import path from "path";
3
+ import chalk from "chalk";
4
+ import ora from "ora";
5
+ import { runPipeline } from "./services/pipeline.js";
6
+ import { formatPipelineResult, formatScanSummary } from "./format.js";
3
7
 
4
- export async function scanProject() {
5
- const pkg = JSON.parse(fs.readFileSync("package.json"));
8
+ export async function scanProject({ verbose, json, skipGithub } = {}) {
9
+ const pkgPath = path.resolve("package.json");
10
+
11
+ if (!fs.existsSync(pkgPath)) {
12
+ console.log(chalk.red("\n ✘ No package.json found in current directory\n"));
13
+ process.exit(1);
14
+ }
15
+
16
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
6
17
 
7
18
  const deps = {
8
19
  ...pkg.dependencies,
9
20
  ...pkg.devDependencies,
10
21
  };
11
22
 
12
- for (const dep of Object.keys(deps)) {
13
- await analyzePackage(dep);
23
+ const depNames = Object.keys(deps);
24
+
25
+ if (depNames.length === 0) {
26
+ console.log(chalk.yellow("\n No dependencies found.\n"));
27
+ return;
28
+ }
29
+
30
+ console.log(chalk.bold(`\n Scanning ${depNames.length} dependencies...\n`));
31
+
32
+ const results = [];
33
+ const spinner = ora();
34
+
35
+ for (const dep of depNames) {
36
+ spinner.start(`Analyzing ${dep}...`);
37
+ try {
38
+ const result = await runPipeline(dep, undefined, { skipGithub: skipGithub !== false });
39
+ results.push(result);
40
+
41
+ if (verbose) {
42
+ spinner.stop();
43
+ console.log(formatPipelineResult(result));
44
+ } else {
45
+ const icon =
46
+ result.label === "CRITICAL" || result.label === "HIGH"
47
+ ? "🚨"
48
+ : result.label === "MEDIUM"
49
+ ? "⚠"
50
+ : "✅";
51
+ spinner.succeed(
52
+ `${dep} ${chalk.gray(`(${result.score}/10)`)} ${icon}`
53
+ );
54
+ }
55
+ } catch {
56
+ spinner.fail(`${dep} - failed to fetch`);
57
+ }
58
+ }
59
+
60
+ if (json) {
61
+ console.log(JSON.stringify(results, null, 2));
62
+ } else {
63
+ console.log(formatScanSummary(results));
14
64
  }
15
65
  }
package/src/score.js CHANGED
@@ -1,47 +1,128 @@
1
+ import { checkTyposquat } from "./typosquat.js";
2
+
1
3
  export function calculateRisk(pkg) {
2
4
  let score = 0;
3
- const warnings = [];
5
+ const checks = [];
6
+ const now = new Date();
4
7
 
5
- // 🟢 Popularity (VERY IMPORTANT)
6
- if (pkg.downloads < 1000) {
8
+ // ── Downloads ──────────────────────────────────
9
+ if (pkg.downloads < 100) {
7
10
  score += 3;
8
- warnings.push("Very low downloads");
9
- } else if (pkg.downloads < 10000) {
11
+ checks.push({ label: "Downloads", status: "fail", detail: `Very low (${pkg.downloads.toLocaleString()}/week)` });
12
+ } else if (pkg.downloads < 1_000) {
10
13
  score += 2;
11
- warnings.push("Low downloads");
14
+ checks.push({ label: "Downloads", status: "warn", detail: `Low (${pkg.downloads.toLocaleString()}/week)` });
15
+ } else if (pkg.downloads < 10_000) {
16
+ score += 1;
17
+ checks.push({ label: "Downloads", status: "warn", detail: `Moderate (${pkg.downloads.toLocaleString()}/week)` });
18
+ } else {
19
+ checks.push({ label: "Downloads", status: "pass", detail: `${pkg.downloads.toLocaleString()}/week` });
12
20
  }
13
21
 
14
- // 🟡 Update recency
15
- const lastUpdate = new Date(pkg.lastPublished);
16
- const now = new Date();
17
- const diffMonths = (now - lastUpdate) / (1000 * 60 * 60 * 24 * 30);
22
+ if (pkg.downloads > 1_000_000) score -= 2;
23
+ if (pkg.downloads > 5_000_000) score -= 1;
18
24
 
19
- if (diffMonths > 12) {
20
- score += 3;
21
- warnings.push("Not updated in over a year");
22
- } else if (diffMonths > 6) {
23
- score += 1;
24
- warnings.push("Not updated recently");
25
+ // ── Update recency ────────────────────────────
26
+ if (pkg.lastPublished) {
27
+ const diffMonths = (now - new Date(pkg.lastPublished)) / (1000 * 60 * 60 * 24 * 30);
28
+ if (diffMonths > 24) {
29
+ score += 3;
30
+ checks.push({ label: "Last Updated", status: "fail", detail: "Not updated in over 2 years" });
31
+ } else if (diffMonths > 12) {
32
+ score += 2;
33
+ checks.push({ label: "Last Updated", status: "warn", detail: "Not updated in over a year" });
34
+ } else if (diffMonths > 6) {
35
+ score += 1;
36
+ checks.push({ label: "Last Updated", status: "warn", detail: "Not updated in 6+ months" });
37
+ } else {
38
+ checks.push({ label: "Last Updated", status: "pass", detail: "Recently updated" });
39
+ }
25
40
  }
26
41
 
27
- // 🔴 Install scripts (HIGH RISK)
42
+ // ── Install scripts ───────────────────────────
28
43
  if (pkg.hasInstallScript) {
29
44
  score += 3;
30
- warnings.push("Uses install/postinstall scripts");
45
+ checks.push({ label: "Install Scripts", status: "fail", detail: "Has install/postinstall scripts" });
46
+ } else {
47
+ checks.push({ label: "Install Scripts", status: "pass", detail: "No install scripts" });
48
+ }
49
+
50
+ // ── Maintainers ───────────────────────────────
51
+ const maintainerCount = pkg.maintainers?.length || 0;
52
+ if (maintainerCount === 0) {
53
+ score += 2;
54
+ checks.push({ label: "Maintainers", status: "fail", detail: "No maintainers listed" });
55
+ } else if (maintainerCount === 1) {
56
+ score += 1;
57
+ checks.push({ label: "Maintainers", status: "warn", detail: "Single maintainer" });
58
+ } else {
59
+ checks.push({ label: "Maintainers", status: "pass", detail: `${maintainerCount} maintainers` });
60
+ }
61
+
62
+ // ── License ───────────────────────────────────
63
+ const license = (typeof pkg.license === "string" ? pkg.license : pkg.license?.type) || "Unknown";
64
+ const permissive = ["MIT", "ISC", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "0BSD", "Unlicense"];
65
+ if (license === "Unknown" || license === "UNLICENSED") {
66
+ score += 2;
67
+ checks.push({ label: "License", status: "fail", detail: "No license specified" });
68
+ } else if (permissive.includes(license)) {
69
+ checks.push({ label: "License", status: "pass", detail: license });
70
+ } else {
71
+ score += 1;
72
+ checks.push({ label: "License", status: "warn", detail: `${license} (review recommended)` });
73
+ }
74
+
75
+ // ── Repository ────────────────────────────────
76
+ if (!pkg.repository) {
77
+ score += 1;
78
+ checks.push({ label: "Repository", status: "warn", detail: "No repository URL" });
79
+ } else {
80
+ checks.push({ label: "Repository", status: "pass", detail: "Has repository link" });
81
+ }
82
+
83
+ // ── Package age ───────────────────────────────
84
+ if (pkg.firstPublished) {
85
+ const ageDays = (now - new Date(pkg.firstPublished)) / (1000 * 60 * 60 * 24);
86
+ if (ageDays < 30) {
87
+ score += 2;
88
+ checks.push({ label: "Package Age", status: "fail", detail: "Published less than 30 days ago" });
89
+ } else if (ageDays < 180) {
90
+ score += 1;
91
+ checks.push({ label: "Package Age", status: "warn", detail: "Published less than 6 months ago" });
92
+ } else {
93
+ const years = Math.floor(ageDays / 365);
94
+ checks.push({ label: "Package Age", status: "pass", detail: years > 0 ? `${years}+ year(s) old` : "6+ months old" });
95
+ }
96
+ }
97
+
98
+ // ── Dependency count ──────────────────────────
99
+ if (pkg.dependencies > 20) {
100
+ score += 1;
101
+ checks.push({ label: "Dependencies", status: "warn", detail: `${pkg.dependencies} direct dependencies (high)` });
102
+ } else {
103
+ checks.push({ label: "Dependencies", status: "pass", detail: `${pkg.dependencies} direct dependencies` });
31
104
  }
32
105
 
33
- // 🟢 High trust signal
34
- if (pkg.downloads > 1000000) {
35
- score -= 2; // reduce risk
106
+ // ── Deprecated ────────────────────────────────
107
+ if (pkg.deprecated) {
108
+ score += 3;
109
+ checks.push({
110
+ label: "Deprecated",
111
+ status: "fail",
112
+ detail: typeof pkg.deprecated === "string" ? pkg.deprecated : "Package is deprecated",
113
+ });
36
114
  }
37
115
 
38
- if (pkg.downloads > 5000000) {
39
- score -= 2;
116
+ // ── Typosquatting ─────────────────────────────
117
+ const typosquatMatch = checkTyposquat(pkg.name);
118
+ if (typosquatMatch) {
119
+ score += 3;
120
+ checks.push({ label: "Typosquatting", status: "fail", detail: `Name is suspiciously similar to "${typosquatMatch}"` });
40
121
  }
41
122
 
42
123
  // Normalize
43
- if (score < 0) score = 0;
44
- if (score > 10) score = 10;
124
+ score = Math.max(0, Math.min(10, score));
125
+ const level = score <= 3 ? "low" : score <= 6 ? "medium" : "high";
45
126
 
46
- return { score, warnings };
127
+ return { score, checks, level };
47
128
  }
@@ -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,50 @@
1
+ const POPULAR_PACKAGES = [
2
+ "express", "react", "vue", "angular", "lodash", "axios", "moment",
3
+ "webpack", "babel", "typescript", "eslint", "prettier", "jest",
4
+ "mocha", "chai", "next", "nuxt", "gatsby", "svelte", "jquery",
5
+ "underscore", "async", "chalk", "commander", "inquirer", "ora",
6
+ "nodemon", "dotenv", "cors", "mongoose", "sequelize", "prisma",
7
+ "socket.io", "passport", "bcrypt", "jsonwebtoken", "uuid",
8
+ "mysql", "pg", "redis", "mongodb", "fastify", "koa", "hapi",
9
+ "request", "node-fetch", "got", "cheerio", "puppeteer",
10
+ "sharp", "multer", "helmet", "morgan", "winston", "debug",
11
+ "bluebird", "rxjs", "ramda", "date-fns", "dayjs", "zod",
12
+ "yup", "ajv", "joi", "class-validator", "formik",
13
+ "tailwindcss", "bootstrap", "sass", "less", "styled-components",
14
+ "vite", "esbuild", "rollup", "parcel", "turbo",
15
+ ];
16
+
17
+ function levenshtein(a, b) {
18
+ const m = a.length;
19
+ const n = b.length;
20
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
21
+
22
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
23
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
24
+
25
+ for (let i = 1; i <= m; i++) {
26
+ for (let j = 1; j <= n; j++) {
27
+ dp[i][j] =
28
+ a[i - 1] === b[j - 1]
29
+ ? dp[i - 1][j - 1]
30
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
31
+ }
32
+ }
33
+
34
+ return dp[m][n];
35
+ }
36
+
37
+ export function checkTyposquat(name) {
38
+ const lower = name.toLowerCase();
39
+
40
+ if (POPULAR_PACKAGES.includes(lower)) return null;
41
+
42
+ for (const popular of POPULAR_PACKAGES) {
43
+ const dist = levenshtein(lower, popular);
44
+ if (dist > 0 && dist <= 2 && Math.abs(lower.length - popular.length) <= 2) {
45
+ return popular;
46
+ }
47
+ }
48
+
49
+ return null;
50
+ }
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ import { getCached, setCache } from "./cache.js";
2
+
3
+ /**
4
+ * Extracts owner/repo from a repository URL.
5
+ */
6
+ function parseRepoUrl(url) {
7
+ if (!url) return null;
8
+ // Handles: git+https://github.com/owner/repo.git, https://github.com/owner/repo, etc.
9
+ const match = url.match(
10
+ /github\.com[/:]([^/]+)\/([^/.#]+)/
11
+ );
12
+ if (!match) return null;
13
+ return { owner: match[1], repo: match[2] };
14
+ }
15
+
16
+ async function ghFetch(path) {
17
+ const headers = { "User-Agent": "install-guard-cli" };
18
+ // Use token if available to avoid rate limits
19
+ if (process.env.GITHUB_TOKEN) {
20
+ headers.Authorization = `token ${process.env.GITHUB_TOKEN}`;
21
+ }
22
+ const res = await fetch(`https://api.github.com${path}`, { headers });
23
+ if (!res.ok) return null;
24
+ return res.json();
25
+ }
26
+
27
+ /**
28
+ * Checks if a given version tag exists on GitHub.
29
+ * Tries both `v1.2.3` and `1.2.3` tag formats.
30
+ */
31
+ export async function checkGitHubTag(repoUrl, version) {
32
+ const repo = parseRepoUrl(repoUrl);
33
+ if (!repo) return { hasRepo: false, tagFound: false, recentCommits: false };
34
+
35
+ const key = `gh-tag:${repo.owner}/${repo.repo}:${version}`;
36
+ const cached = getCached(key);
37
+ if (cached) return cached;
38
+
39
+ // Try v-prefixed and plain tag
40
+ const tags = await ghFetch(`/repos/${repo.owner}/${repo.repo}/tags?per_page=100`);
41
+ if (!tags) {
42
+ const result = { hasRepo: true, tagFound: false, recentCommits: false, error: "rate-limited or private" };
43
+ setCache(key, result);
44
+ return result;
45
+ }
46
+
47
+ const tagNames = tags.map((t) => t.name);
48
+ const tagFound = tagNames.includes(`v${version}`) || tagNames.includes(version);
49
+
50
+ // Check recent commits
51
+ const commits = await ghFetch(
52
+ `/repos/${repo.owner}/${repo.repo}/commits?per_page=1`
53
+ );
54
+ let recentCommits = false;
55
+ if (commits && commits.length > 0) {
56
+ const lastCommitDate = new Date(commits[0].commit?.committer?.date || 0);
57
+ const daysSinceCommit = (Date.now() - lastCommitDate.getTime()) / (1000 * 60 * 60 * 24);
58
+ recentCommits = daysSinceCommit < 90;
59
+ }
60
+
61
+ const result = { hasRepo: true, tagFound, recentCommits };
62
+ setCache(key, result);
63
+ return result;
64
+ }