selfguard 0.0.1 → 0.2.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/dist/report.js ADDED
@@ -0,0 +1,126 @@
1
+ import { homedir } from "node:os";
2
+ import { severityRank } from "./match.js";
3
+ import { compare, isValid } from "./semver.js";
4
+ import { clean } from "./sanitize.js";
5
+ function useColor(stream) {
6
+ return Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb";
7
+ }
8
+ export function paint(stream) {
9
+ const on = useColor(stream);
10
+ const wrap = (open, close) => (s) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : s);
11
+ return {
12
+ bold: wrap(1, 22),
13
+ dim: wrap(2, 22),
14
+ red: wrap(31, 39),
15
+ yellow: wrap(33, 39),
16
+ green: wrap(32, 39),
17
+ magenta: wrap(35, 39),
18
+ cyan: wrap(36, 39),
19
+ inverseRed: wrap(41, 49),
20
+ };
21
+ }
22
+ export function severityLabel(s, c) {
23
+ switch (s) {
24
+ case "malicious":
25
+ return c.inverseRed(c.bold(" MALICIOUS "));
26
+ case "critical":
27
+ return c.red(c.bold("CRITICAL"));
28
+ case "high":
29
+ return c.red("HIGH");
30
+ case "moderate":
31
+ return c.yellow("MODERATE");
32
+ case "low":
33
+ return c.dim("LOW");
34
+ default:
35
+ return c.dim("UNKNOWN");
36
+ }
37
+ }
38
+ export function tildify(p) {
39
+ const home = homedir();
40
+ return p === home || p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
41
+ }
42
+ export function countBySeverity(findings) {
43
+ const counts = {};
44
+ for (const f of findings)
45
+ counts[f.severity] = (counts[f.severity] ?? 0) + 1;
46
+ return counts;
47
+ }
48
+ export function summarizeCounts(findings) {
49
+ const counts = countBySeverity(findings);
50
+ const order = ["malicious", "critical", "high", "moderate", "low", "unknown"];
51
+ return order
52
+ .filter((s) => counts[s])
53
+ .map((s) => `${counts[s]} ${s}`)
54
+ .join(", ");
55
+ }
56
+ export function renderFindings(rec, stream, verbose = false) {
57
+ const c = paint(stream);
58
+ const lines = [];
59
+ const title = `${c.bold(tildify(rec.path))} ${c.dim(`${rec.deps.length} packages`)}`;
60
+ lines.push(title);
61
+ if (rec.error)
62
+ lines.push(` ${c.yellow("!")} ${clean(rec.error)}`);
63
+ if (rec.skipped > 0)
64
+ lines.push(c.dim(` ${rec.skipped} entries skipped (git/file/link deps)`));
65
+ if (rec.findings.length === 0) {
66
+ if (!rec.error)
67
+ lines.push(` ${c.green("✓")} no known vulnerabilities`);
68
+ return lines.join("\n");
69
+ }
70
+ const groups = groupByPackage(rec.findings);
71
+ lines.push(` ${groups.length} vulnerable package(s), ${rec.findings.length} advisories: ${summarizeCounts(rec.findings)}`);
72
+ for (const g of groups) {
73
+ const pkg = `${clean(g.name, 120)}@${clean(g.version, 60)}`;
74
+ const tags = [g.direct ? "direct" : "transitive", g.dev ? "dev" : ""].filter(Boolean).join(", ");
75
+ lines.push("");
76
+ lines.push(` ${severityLabel(g.worst, c)} ${c.bold(pkg)} ${c.dim(`(${tags})`)}`);
77
+ if (g.worst === "malicious") {
78
+ lines.push(` ${c.red("→ remove it now and rotate any credentials available on this machine/CI")}`);
79
+ }
80
+ else if (g.upgradeTo) {
81
+ const partial = g.unfixed > 0 ? c.yellow(` (${g.unfixed} advisory(ies) have no fix yet)`) : "";
82
+ lines.push(` ${c.green(`→ upgrade to ${clean(g.upgradeTo, 60)} or later`)}${partial}`);
83
+ }
84
+ else {
85
+ lines.push(` ${c.yellow("→ no fixed version published; consider replacing it")}`);
86
+ }
87
+ const shown = verbose ? g.findings : g.findings.slice(0, 3);
88
+ for (const f of shown) {
89
+ lines.push(` ${c.dim("•")} ${clean(f.id, 64)} ${c.dim(f.severity)}${f.summary ? ` ${clean(f.summary, 90)}` : ""}`);
90
+ }
91
+ if (shown.length < g.findings.length)
92
+ lines.push(c.dim(` • …and ${g.findings.length - shown.length} more (--verbose)`));
93
+ }
94
+ lines.push("");
95
+ lines.push(c.dim(" Details: https://osv.dev/vulnerability/<ID>"));
96
+ return lines.join("\n");
97
+ }
98
+ export function worstSeverity(findings) {
99
+ const order = ["malicious", "critical", "high", "moderate", "low", "unknown"];
100
+ for (const s of order)
101
+ if (findings.some((f) => f.severity === s))
102
+ return s;
103
+ return null;
104
+ }
105
+ export function groupByPackage(findings) {
106
+ const map = new Map();
107
+ for (const f of findings) {
108
+ const key = `${f.name}@${f.version}`;
109
+ let g = map.get(key);
110
+ if (!g) {
111
+ g = { name: f.name, version: f.version, direct: f.direct, dev: f.dev, worst: f.severity, findings: [], unfixed: 0 };
112
+ map.set(key, g);
113
+ }
114
+ g.findings.push(f);
115
+ if (severityRank(f.severity) < severityRank(g.worst))
116
+ g.worst = f.severity;
117
+ if (f.fixedIn && isValid(f.fixedIn)) {
118
+ if (!g.upgradeTo || compare(f.fixedIn, g.upgradeTo) > 0)
119
+ g.upgradeTo = f.fixedIn;
120
+ }
121
+ else {
122
+ g.unfixed++;
123
+ }
124
+ }
125
+ return [...map.values()].sort((a, b) => severityRank(a.worst) - severityRank(b.worst) || Number(b.direct) - Number(a.direct) || a.name.localeCompare(b.name));
126
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Everything we print comes from untrusted sources (lockfiles, advisory text).
3
+ * Strip control characters so nobody can inject terminal escape sequences
4
+ * (cursor movement, OSC 8 links, title changes, clipboard writes...) and
5
+ * bidirectional overrides ("Trojan Source" style spoofing).
6
+ */
7
+ const CONTROL = new RegExp("[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]", "g");
8
+ export function clean(input, maxLen = 300, opts = {}) {
9
+ const stripped = String(input ?? "").replace(CONTROL, "");
10
+ const s = (opts.multiline ? stripped.replace(/\n{3,}/g, "\n\n") : stripped.replace(/\s*\n\s*/g, " ")).trim();
11
+ return s.length > maxLen ? `${s.slice(0, maxLen - 1)}…` : s;
12
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,197 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { sha256 } from "./fsutil.js";
4
+ import { isSupported, parseLockfile } from "./lockfile/index.js";
5
+ import { evaluate, severityOf, severityRank } from "./match.js";
6
+ import { getVulns, queryBatch } from "./osv.js";
7
+ import { repoIdentity } from "./repoid.js";
8
+ import { clean } from "./sanitize.js";
9
+ import { findingKey, loadRepo } from "./store.js";
10
+ const MAX_LOCKFILE_BYTES = 100 * 1024 * 1024;
11
+ export function lockState(lockfile) {
12
+ try {
13
+ const st = statSync(lockfile);
14
+ if (!st.isFile() || st.size > MAX_LOCKFILE_BYTES)
15
+ return null;
16
+ return { hash: sha256(readFileSync(lockfile)), mtimeMs: st.mtimeMs, size: st.size };
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /** Cheap check first (mtime+size), hash only if those changed. */
23
+ export function lockChanged(rec, project) {
24
+ if (rec.lockfile !== project.lockfile)
25
+ return true;
26
+ if (!project.lockfile)
27
+ return false;
28
+ try {
29
+ const st = statSync(project.lockfile);
30
+ if (st.mtimeMs === rec.lockMtimeMs && st.size === rec.lockSize)
31
+ return false;
32
+ }
33
+ catch {
34
+ return true;
35
+ }
36
+ return lockState(project.lockfile)?.hash !== rec.lockHash;
37
+ }
38
+ /** Parse a project's lockfile into a fresh (findings-less) record. Never executes project code. */
39
+ function inventory(project) {
40
+ const previous = loadRepo(project.root);
41
+ const now = new Date().toISOString();
42
+ const record = {
43
+ schema: 1,
44
+ path: project.root,
45
+ lockfile: project.lockfile,
46
+ lockType: project.lockType,
47
+ lockHash: null,
48
+ lockMtimeMs: null,
49
+ lockSize: null,
50
+ registeredAt: previous?.registeredAt ?? now,
51
+ scannedAt: null,
52
+ deps: [],
53
+ skipped: 0,
54
+ findings: [],
55
+ branch: repoIdentity(project.root)?.branch ?? null,
56
+ };
57
+ if (!project.lockfile || !project.lockType) {
58
+ record.error = "no lockfile found (run your package manager's install to create one)";
59
+ record.scannedAt = now;
60
+ return { record, previous };
61
+ }
62
+ const st = lockState(project.lockfile);
63
+ if (!st) {
64
+ record.error = "lockfile unreadable or too large";
65
+ record.scannedAt = now;
66
+ return { record, previous };
67
+ }
68
+ Object.assign(record, { lockHash: st.hash, lockMtimeMs: st.mtimeMs, lockSize: st.size });
69
+ if (!isSupported(project.lockType, project.lockfile)) {
70
+ record.error = "bun.lockb (binary) is not supported; run `bun install --save-text-lockfile` to create bun.lock";
71
+ record.scannedAt = now;
72
+ return { record, previous };
73
+ }
74
+ try {
75
+ let manifest;
76
+ try {
77
+ manifest = JSON.parse(readFileSync(join(project.root, "package.json"), "utf8"));
78
+ }
79
+ catch {
80
+ manifest = undefined;
81
+ }
82
+ const parsed = parseLockfile(project.lockType, readFileSync(project.lockfile, "utf8"), manifest);
83
+ record.deps = parsed.deps;
84
+ record.skipped = parsed.skipped;
85
+ }
86
+ catch (err) {
87
+ record.error = `could not parse lockfile: ${clean(err.message, 200)}`;
88
+ record.scannedAt = now;
89
+ }
90
+ return { record, previous };
91
+ }
92
+ function buildFindings(deps, refsPerDep, vulns, previous, now) {
93
+ const prevSeen = new Map((previous?.findings ?? []).map((f) => [findingKey(f), f.firstSeen]));
94
+ const findings = [];
95
+ for (const dep of deps) {
96
+ const ids = refsPerDep.get(`${dep.name}@${dep.version}`) ?? [];
97
+ const reported = new Set();
98
+ for (const id of ids) {
99
+ const vuln = vulns.get(id);
100
+ if (vuln?.withdrawn)
101
+ continue;
102
+ // Collapse duplicates published under several ids (e.g. GHSA + CVE alias).
103
+ if (vuln?.aliases?.some((a) => reported.has(a)) || reported.has(id))
104
+ continue;
105
+ reported.add(id);
106
+ const ev = vuln ? evaluate(vuln, dep.name, dep.version) : { affected: true };
107
+ const f = {
108
+ id,
109
+ aliases: (vuln?.aliases ?? []).map((a) => clean(a, 64)).slice(0, 10),
110
+ severity: vuln ? severityOf(vuln) : id.startsWith("MAL-") ? "malicious" : "unknown",
111
+ summary: clean(vuln?.summary ?? vuln?.details ?? "", 200),
112
+ name: dep.name,
113
+ version: dep.version,
114
+ direct: dep.direct,
115
+ dev: dep.dev,
116
+ firstSeen: "",
117
+ };
118
+ if (ev.fixedIn)
119
+ f.fixedIn = ev.fixedIn;
120
+ f.firstSeen = prevSeen.get(findingKey(f)) ?? now;
121
+ findings.push(f);
122
+ }
123
+ }
124
+ return findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || Number(b.direct) - Number(a.direct) || a.name.localeCompare(b.name));
125
+ }
126
+ const SEEN_TTL_MS = 180 * 24 * 60 * 60 * 1000;
127
+ const SEEN_MAX = 5000;
128
+ /** Remember every finding this checkout has had recently, on any branch. */
129
+ function updateSeen(previous, findings, now) {
130
+ const seen = Object.create(null);
131
+ const cutoff = Date.parse(now) - SEEN_TTL_MS;
132
+ for (const [k, at] of Object.entries(previous?.seen ?? {})) {
133
+ if (typeof at === "string" && Date.parse(at) >= cutoff)
134
+ seen[k] = at;
135
+ }
136
+ for (const f of previous?.findings ?? [])
137
+ seen[findingKey(f)] ??= previous?.scannedAt ?? now;
138
+ for (const f of findings)
139
+ seen[findingKey(f)] = now;
140
+ const entries = Object.entries(seen);
141
+ if (entries.length <= SEEN_MAX)
142
+ return seen;
143
+ entries.sort((a, b) => b[1].localeCompare(a[1]));
144
+ return Object.fromEntries(entries.slice(0, SEEN_MAX));
145
+ }
146
+ /**
147
+ * Scan one or many projects with a single deduplicated OSV query.
148
+ * Records are returned, not saved: the caller decides.
149
+ */
150
+ export async function scanProjects(projects) {
151
+ const inventories = projects.map(inventory);
152
+ const unique = new Map();
153
+ for (const { record } of inventories) {
154
+ if (record.error)
155
+ continue;
156
+ for (const d of record.deps)
157
+ unique.set(`${d.name}@${d.version}`, { name: d.name, version: d.version });
158
+ }
159
+ const queries = [...unique.values()];
160
+ let refsPerDep = new Map();
161
+ let vulns = new Map();
162
+ let networkError;
163
+ if (queries.length > 0) {
164
+ try {
165
+ const refs = await queryBatch(queries);
166
+ refsPerDep = new Map(queries.map((q, i) => [`${q.name}@${q.version}`, refs[i].map((r) => r.id)]));
167
+ vulns = await getVulns(refs.flat());
168
+ }
169
+ catch (err) {
170
+ networkError = `could not reach OSV: ${clean(err.message, 200)}`;
171
+ }
172
+ }
173
+ const now = new Date().toISOString();
174
+ return inventories.map(({ record, previous }) => {
175
+ if (networkError && !record.error) {
176
+ // Keep the last known findings rather than pretending everything is fine.
177
+ record.error = networkError;
178
+ record.findings = previous?.findings ?? [];
179
+ record.scannedAt = previous?.scannedAt ?? null;
180
+ record.seen = previous?.seen ?? {};
181
+ return { record, newFindings: [], resolvedFindings: [], firstScan: previous === null };
182
+ }
183
+ if (!record.error) {
184
+ record.findings = buildFindings(record.deps, refsPerDep, vulns, previous, now);
185
+ record.scannedAt = now;
186
+ }
187
+ const before = new Set((previous?.findings ?? []).map(findingKey));
188
+ const history = previous?.seen ?? {};
189
+ const newFindings = record.findings.filter((f) => !before.has(findingKey(f)) && !Object.hasOwn(history, findingKey(f)));
190
+ const after = new Set(record.findings.map(findingKey));
191
+ // Only a successful scan on the same branch can say something was fixed.
192
+ const sameBranch = previous?.branch === undefined || previous.branch === record.branch;
193
+ const resolvedFindings = previous && !record.error && sameBranch ? previous.findings.filter((f) => !after.has(findingKey(f))) : [];
194
+ record.seen = updateSeen(previous, record.findings, now);
195
+ return { record, newFindings, resolvedFindings, firstScan: previous === null };
196
+ });
197
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Background work via per-user launchd agents (macOS):
3
+ * - dev.selfguard.sync: `selfguard sync` every N hours (discovery, new advisories, reminders);
4
+ * - dev.selfguard.watch: `selfguard check` as soon as a lockfile of an active checkout changes
5
+ * (git pull, installs, merges), so fixes and new risks show up in seconds.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, readFileSync, rmSync } from "node:fs";
9
+ import { homedir, userInfo } from "node:os";
10
+ import { join } from "node:path";
11
+ import { atomicWrite } from "./fsutil.js";
12
+ import { cliEntry } from "./hook.js";
13
+ import { logFile } from "./paths.js";
14
+ const SYNC_LABEL = "dev.selfguard.sync";
15
+ const WATCH_LABEL = "dev.selfguard.watch";
16
+ /** launchd watches each path with a kqueue descriptor; keep the set bounded. */
17
+ export const MAX_WATCH_PATHS = 200;
18
+ const plistPath = (label) => join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
19
+ const domain = () => `gui/${userInfo().uid}`;
20
+ function xml(s) {
21
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
22
+ }
23
+ export function parseInterval(input) {
24
+ const m = /^(\d+)\s*(m|h|d)$/.exec(input.trim());
25
+ if (!m)
26
+ throw new Error(`invalid interval "${input}" (use e.g. 30m, 6h, 1d)`);
27
+ const n = Number(m[1]) * { m: 60, h: 3600, d: 86400 }[m[2]];
28
+ if (n < 15 * 60)
29
+ throw new Error("minimum interval is 15m");
30
+ return n;
31
+ }
32
+ function assertMac() {
33
+ if (process.platform !== "darwin")
34
+ throw new Error("scheduling is only implemented for macOS (launchd) for now");
35
+ }
36
+ /** Plist for one of our agents. Pure: tested directly. */
37
+ export function agentPlist(label, args, trigger, log) {
38
+ const triggerXml = "interval" in trigger
39
+ ? ` <key>StartInterval</key><integer>${trigger.interval}</integer>\n <key>RunAtLoad</key><true/>`
40
+ : ` <key>WatchPaths</key>\n <array>\n${trigger.watch.map((p) => ` <string>${xml(p)}</string>`).join("\n")}\n </array>\n <key>ThrottleInterval</key><integer>15</integer>`;
41
+ return `<?xml version="1.0" encoding="UTF-8"?>
42
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
43
+ <plist version="1.0">
44
+ <dict>
45
+ <key>Label</key><string>${xml(label)}</string>
46
+ <key>ProgramArguments</key>
47
+ <array>
48
+ ${args.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
49
+ </array>
50
+ ${triggerXml}
51
+ <key>ProcessType</key><string>Background</string>
52
+ <key>LowPriorityIO</key><true/>
53
+ <key>StandardOutPath</key><string>${xml(log)}</string>
54
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
55
+ </dict>
56
+ </plist>
57
+ `;
58
+ }
59
+ function unload(label) {
60
+ try {
61
+ execFileSync("/bin/launchctl", ["bootout", `${domain()}/${label}`], { stdio: "ignore" });
62
+ }
63
+ catch {
64
+ /* not loaded */
65
+ }
66
+ }
67
+ function install(label, plist) {
68
+ unload(label);
69
+ atomicWrite(plistPath(label), plist);
70
+ execFileSync("/bin/launchctl", ["bootstrap", domain(), plistPath(label)], { stdio: "ignore" });
71
+ }
72
+ function isLoaded(label) {
73
+ try {
74
+ execFileSync("/bin/launchctl", ["print", `${domain()}/${label}`], { stdio: "ignore" });
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ export function scheduleOn(seconds, watchPaths) {
82
+ assertMac();
83
+ install(SYNC_LABEL, agentPlist(SYNC_LABEL, [process.execPath, cliEntry(), "sync", "--background"], { interval: seconds }, logFile()));
84
+ updateWatcher(watchPaths, { force: true });
85
+ return plistPath(SYNC_LABEL);
86
+ }
87
+ /**
88
+ * (Re)install the lockfile watcher with these paths, only when the schedule is on and the
89
+ * path set changed. Never call it from the watcher's own run: unloading would kill it.
90
+ */
91
+ export function updateWatcher(paths, opts = {}) {
92
+ if (process.platform !== "darwin" || process.env.SELFGUARD_NO_LAUNCHD === "1")
93
+ return { watching: 0, changed: false };
94
+ if (!opts.force && !existsSync(plistPath(SYNC_LABEL)))
95
+ return { watching: 0, changed: false };
96
+ const list = [...new Set(paths)].slice(0, MAX_WATCH_PATHS);
97
+ if (list.length === 0) {
98
+ const had = existsSync(plistPath(WATCH_LABEL));
99
+ unload(WATCH_LABEL);
100
+ rmSync(plistPath(WATCH_LABEL), { force: true });
101
+ return { watching: 0, changed: had };
102
+ }
103
+ const plist = agentPlist(WATCH_LABEL, [process.execPath, cliEntry(), "check", "--background"], { watch: list }, logFile());
104
+ let current = "";
105
+ try {
106
+ current = readFileSync(plistPath(WATCH_LABEL), "utf8");
107
+ }
108
+ catch {
109
+ /* not installed */
110
+ }
111
+ if (current === plist && isLoaded(WATCH_LABEL))
112
+ return { watching: list.length, changed: false };
113
+ install(WATCH_LABEL, plist);
114
+ return { watching: list.length, changed: true };
115
+ }
116
+ export function scheduleOff() {
117
+ assertMac();
118
+ let existed = false;
119
+ for (const label of [SYNC_LABEL, WATCH_LABEL]) {
120
+ unload(label);
121
+ existed = existsSync(plistPath(label)) || existed;
122
+ rmSync(plistPath(label), { force: true });
123
+ }
124
+ return existed;
125
+ }
126
+ export function scheduleStatus() {
127
+ assertMac();
128
+ let paths = 0;
129
+ try {
130
+ const section = /<key>WatchPaths<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(readFileSync(plistPath(WATCH_LABEL), "utf8"));
131
+ paths = section?.[1]?.match(/<string>/g)?.length ?? 0;
132
+ }
133
+ catch {
134
+ paths = 0;
135
+ }
136
+ return {
137
+ installed: existsSync(plistPath(SYNC_LABEL)),
138
+ loaded: isLoaded(SYNC_LABEL),
139
+ plist: plistPath(SYNC_LABEL),
140
+ watcher: { installed: existsSync(plistPath(WATCH_LABEL)), loaded: isLoaded(WATCH_LABEL), paths },
141
+ };
142
+ }
package/dist/semver.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Minimal SemVer 2.0.0 parser/comparator (no dependencies on purpose).
3
+ * Only what we need to evaluate OSV ranges for the npm ecosystem.
4
+ */
5
+ const SEMVER_RE = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
6
+ export function parse(input) {
7
+ if (typeof input !== "string" || input.length > 256)
8
+ return null;
9
+ const m = SEMVER_RE.exec(input.trim());
10
+ if (!m)
11
+ return null;
12
+ const major = Number(m[1]);
13
+ const minor = Number(m[2]);
14
+ const patch = Number(m[3]);
15
+ if (![major, minor, patch].every(Number.isSafeInteger))
16
+ return null;
17
+ const pre = m[4]
18
+ ? m[4].split(".").map((id) => (/^\d+$/.test(id) && Number.isSafeInteger(Number(id)) ? Number(id) : id))
19
+ : [];
20
+ return { major, minor, patch, pre };
21
+ }
22
+ export function isValid(input) {
23
+ return parse(input) !== null;
24
+ }
25
+ function cmpNum(a, b) {
26
+ return a < b ? -1 : a > b ? 1 : 0;
27
+ }
28
+ function cmpIdent(a, b) {
29
+ const an = typeof a === "number";
30
+ const bn = typeof b === "number";
31
+ if (an && bn)
32
+ return cmpNum(a, b);
33
+ if (an)
34
+ return -1; // numeric identifiers have lower precedence
35
+ if (bn)
36
+ return 1;
37
+ return a < b ? -1 : a > b ? 1 : 0;
38
+ }
39
+ export function compareParsed(a, b) {
40
+ const core = cmpNum(a.major, b.major) || cmpNum(a.minor, b.minor) || cmpNum(a.patch, b.patch);
41
+ if (core !== 0)
42
+ return core;
43
+ // A version without pre-release has higher precedence than one with.
44
+ if (a.pre.length === 0 && b.pre.length === 0)
45
+ return 0;
46
+ if (a.pre.length === 0)
47
+ return 1;
48
+ if (b.pre.length === 0)
49
+ return -1;
50
+ const len = Math.min(a.pre.length, b.pre.length);
51
+ for (let i = 0; i < len; i++) {
52
+ const c = cmpIdent(a.pre[i], b.pre[i]);
53
+ if (c !== 0)
54
+ return c;
55
+ }
56
+ return cmpNum(a.pre.length, b.pre.length);
57
+ }
58
+ /** Compare two version strings. Throws if either is not valid SemVer. */
59
+ export function compare(a, b) {
60
+ const pa = parse(a);
61
+ const pb = parse(b);
62
+ if (!pa || !pb)
63
+ throw new Error(`invalid semver: ${!pa ? a : b}`);
64
+ return compareParsed(pa, pb);
65
+ }