@rainy-updates/cli 0.5.1 → 0.5.2-rc.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/CHANGELOG.md +93 -1
- package/README.md +88 -25
- package/dist/bin/cli.js +50 -1
- package/dist/commands/audit/fetcher.d.ts +2 -6
- package/dist/commands/audit/fetcher.js +2 -79
- package/dist/commands/audit/mapper.d.ts +8 -1
- package/dist/commands/audit/mapper.js +106 -10
- package/dist/commands/audit/parser.js +36 -2
- package/dist/commands/audit/runner.js +179 -15
- package/dist/commands/audit/sources/github.d.ts +2 -0
- package/dist/commands/audit/sources/github.js +125 -0
- package/dist/commands/audit/sources/index.d.ts +6 -0
- package/dist/commands/audit/sources/index.js +92 -0
- package/dist/commands/audit/sources/osv.d.ts +2 -0
- package/dist/commands/audit/sources/osv.js +131 -0
- package/dist/commands/audit/sources/types.d.ts +21 -0
- package/dist/commands/audit/sources/types.js +1 -0
- package/dist/commands/audit/targets.d.ts +20 -0
- package/dist/commands/audit/targets.js +314 -0
- package/dist/commands/changelog/fetcher.d.ts +9 -0
- package/dist/commands/changelog/fetcher.js +130 -0
- package/dist/commands/licenses/parser.d.ts +2 -0
- package/dist/commands/licenses/parser.js +116 -0
- package/dist/commands/licenses/runner.d.ts +9 -0
- package/dist/commands/licenses/runner.js +163 -0
- package/dist/commands/licenses/sbom.d.ts +10 -0
- package/dist/commands/licenses/sbom.js +70 -0
- package/dist/commands/resolve/graph/builder.d.ts +20 -0
- package/dist/commands/resolve/graph/builder.js +183 -0
- package/dist/commands/resolve/graph/conflict.d.ts +20 -0
- package/dist/commands/resolve/graph/conflict.js +52 -0
- package/dist/commands/resolve/graph/resolver.d.ts +17 -0
- package/dist/commands/resolve/graph/resolver.js +71 -0
- package/dist/commands/resolve/parser.d.ts +2 -0
- package/dist/commands/resolve/parser.js +89 -0
- package/dist/commands/resolve/runner.d.ts +13 -0
- package/dist/commands/resolve/runner.js +136 -0
- package/dist/commands/snapshot/parser.d.ts +2 -0
- package/dist/commands/snapshot/parser.js +80 -0
- package/dist/commands/snapshot/runner.d.ts +11 -0
- package/dist/commands/snapshot/runner.js +115 -0
- package/dist/commands/snapshot/store.d.ts +35 -0
- package/dist/commands/snapshot/store.js +158 -0
- package/dist/commands/unused/matcher.d.ts +22 -0
- package/dist/commands/unused/matcher.js +95 -0
- package/dist/commands/unused/parser.d.ts +2 -0
- package/dist/commands/unused/parser.js +95 -0
- package/dist/commands/unused/runner.d.ts +11 -0
- package/dist/commands/unused/runner.js +113 -0
- package/dist/commands/unused/scanner.d.ts +18 -0
- package/dist/commands/unused/scanner.js +129 -0
- package/dist/core/impact.d.ts +36 -0
- package/dist/core/impact.js +82 -0
- package/dist/core/options.d.ts +13 -1
- package/dist/core/options.js +35 -13
- package/dist/types/index.d.ts +187 -1
- package/dist/ui/tui.d.ts +6 -0
- package/dist/ui/tui.js +50 -0
- package/dist/utils/semver.d.ts +18 -0
- package/dist/utils/semver.js +88 -3
- package/package.json +8 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AuditOptions, AuditSourceStatus, AuditSourceName, CveAdvisory } from "../../../types/index.js";
|
|
2
|
+
import type { AuditTarget } from "../targets.js";
|
|
3
|
+
export interface AuditSourceTargetResult {
|
|
4
|
+
advisories: CveAdvisory[];
|
|
5
|
+
ok: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AuditSourceFetchResult {
|
|
9
|
+
advisories: CveAdvisory[];
|
|
10
|
+
warnings: string[];
|
|
11
|
+
health: AuditSourceStatus;
|
|
12
|
+
}
|
|
13
|
+
export interface AuditSourceAggregateResult {
|
|
14
|
+
advisories: CveAdvisory[];
|
|
15
|
+
warnings: string[];
|
|
16
|
+
sourceHealth: AuditSourceStatus[];
|
|
17
|
+
}
|
|
18
|
+
export interface AuditSourceAdapter {
|
|
19
|
+
name: AuditSourceName;
|
|
20
|
+
fetch(targets: AuditTarget[], options: Pick<AuditOptions, "concurrency" | "registryTimeoutMs">): Promise<AuditSourceFetchResult>;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PackageDependency } from "../../types/index.js";
|
|
2
|
+
export interface AuditTarget {
|
|
3
|
+
name: string;
|
|
4
|
+
version: string;
|
|
5
|
+
packageDir: string;
|
|
6
|
+
manifestRange: string;
|
|
7
|
+
resolution: "lockfile" | "manifest";
|
|
8
|
+
lockfilePath?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AuditTargetResolution {
|
|
11
|
+
targets: AuditTarget[];
|
|
12
|
+
warnings: string[];
|
|
13
|
+
resolution: {
|
|
14
|
+
lockfile: number;
|
|
15
|
+
manifest: number;
|
|
16
|
+
unresolved: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export declare function extractAuditVersion(range: string): string | null;
|
|
20
|
+
export declare function resolveAuditTargets(rootCwd: string, packageDirs: string[], depsByDir: Map<string, PackageDependency[]>): Promise<AuditTargetResolution>;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const LOCKFILE_PRIORITY = [
|
|
4
|
+
"package-lock.json",
|
|
5
|
+
"npm-shrinkwrap.json",
|
|
6
|
+
"pnpm-lock.yaml",
|
|
7
|
+
"bun.lock",
|
|
8
|
+
];
|
|
9
|
+
const packageLockCache = new Map();
|
|
10
|
+
const pnpmLockCache = new Map();
|
|
11
|
+
const bunLockCache = new Map();
|
|
12
|
+
export function extractAuditVersion(range) {
|
|
13
|
+
const trimmed = range.trim();
|
|
14
|
+
const match = trimmed.match(/^(?:\^|~|>=|<=|>|<|=)?\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/);
|
|
15
|
+
return match?.[1] ?? null;
|
|
16
|
+
}
|
|
17
|
+
export async function resolveAuditTargets(rootCwd, packageDirs, depsByDir) {
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const targets = new Map();
|
|
20
|
+
const resolution = {
|
|
21
|
+
lockfile: 0,
|
|
22
|
+
manifest: 0,
|
|
23
|
+
unresolved: 0,
|
|
24
|
+
};
|
|
25
|
+
for (const dir of packageDirs) {
|
|
26
|
+
const deps = depsByDir.get(dir) ?? [];
|
|
27
|
+
for (const dep of deps) {
|
|
28
|
+
const resolved = await resolveDependencyVersion(rootCwd, dir, dep);
|
|
29
|
+
if (!resolved) {
|
|
30
|
+
resolution.unresolved += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const key = `${resolved.name}@${resolved.version}`;
|
|
34
|
+
targets.set(key, resolved);
|
|
35
|
+
if (resolved.resolution === "lockfile") {
|
|
36
|
+
resolution.lockfile += 1;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
resolution.manifest += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (resolution.unresolved > 0) {
|
|
44
|
+
warnings.push(`Skipped ${resolution.unresolved} dependency range${resolution.unresolved === 1 ? "" : "s"} that could not be resolved from a lockfile or concrete manifest version.`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
targets: [...targets.values()],
|
|
48
|
+
warnings,
|
|
49
|
+
resolution,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function resolveDependencyVersion(rootCwd, packageDir, dep) {
|
|
53
|
+
const lockfiles = await findNearestLockfiles(rootCwd, packageDir);
|
|
54
|
+
for (const lockfilePath of lockfiles) {
|
|
55
|
+
const fileName = path.basename(lockfilePath);
|
|
56
|
+
const version = fileName === "pnpm-lock.yaml"
|
|
57
|
+
? await resolveFromPnpmLock(lockfilePath, packageDir, dep.name)
|
|
58
|
+
: fileName === "bun.lock"
|
|
59
|
+
? await resolveFromBunLock(lockfilePath, packageDir, dep.name)
|
|
60
|
+
: await resolveFromPackageLock(lockfilePath, packageDir, dep.name);
|
|
61
|
+
if (version) {
|
|
62
|
+
return {
|
|
63
|
+
name: dep.name,
|
|
64
|
+
version,
|
|
65
|
+
packageDir,
|
|
66
|
+
manifestRange: dep.range,
|
|
67
|
+
resolution: "lockfile",
|
|
68
|
+
lockfilePath,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const manifestVersion = extractAuditVersion(dep.range);
|
|
73
|
+
if (!manifestVersion)
|
|
74
|
+
return null;
|
|
75
|
+
return {
|
|
76
|
+
name: dep.name,
|
|
77
|
+
version: manifestVersion,
|
|
78
|
+
packageDir,
|
|
79
|
+
manifestRange: dep.range,
|
|
80
|
+
resolution: "manifest",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function findNearestLockfiles(rootCwd, startDir) {
|
|
84
|
+
const found = [];
|
|
85
|
+
let current = startDir;
|
|
86
|
+
while (true) {
|
|
87
|
+
for (const fileName of LOCKFILE_PRIORITY) {
|
|
88
|
+
const candidate = path.join(current, fileName);
|
|
89
|
+
try {
|
|
90
|
+
await fs.access(candidate);
|
|
91
|
+
found.push(candidate);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// ignore missing
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (current === rootCwd)
|
|
98
|
+
break;
|
|
99
|
+
const parent = path.dirname(current);
|
|
100
|
+
if (parent === current)
|
|
101
|
+
break;
|
|
102
|
+
current = parent;
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
async function resolveFromPackageLock(lockfilePath, packageDir, packageName) {
|
|
107
|
+
const parsed = await readPackageLock(lockfilePath);
|
|
108
|
+
const rootDir = path.dirname(lockfilePath);
|
|
109
|
+
const relDir = normalizeRelativePath(rootDir, packageDir);
|
|
110
|
+
const candidatePaths = relDir
|
|
111
|
+
? [`${relDir}/node_modules/${packageName}`, `node_modules/${packageName}`]
|
|
112
|
+
: [`node_modules/${packageName}`];
|
|
113
|
+
for (const key of candidatePaths) {
|
|
114
|
+
const version = parsed.packages?.[key]?.version;
|
|
115
|
+
if (version)
|
|
116
|
+
return version;
|
|
117
|
+
}
|
|
118
|
+
if (!relDir) {
|
|
119
|
+
return parsed.dependencies?.[packageName]?.version ?? null;
|
|
120
|
+
}
|
|
121
|
+
return parsed.dependencies?.[packageName]?.version ?? null;
|
|
122
|
+
}
|
|
123
|
+
async function resolveFromPnpmLock(lockfilePath, packageDir, packageName) {
|
|
124
|
+
const parsed = await readPnpmLock(lockfilePath);
|
|
125
|
+
const rootDir = path.dirname(lockfilePath);
|
|
126
|
+
const relDir = normalizeRelativePath(rootDir, packageDir) || ".";
|
|
127
|
+
const importers = [relDir, "."];
|
|
128
|
+
for (const importerKey of importers) {
|
|
129
|
+
const importer = parsed.importers.get(importerKey);
|
|
130
|
+
const version = importer?.get(packageName);
|
|
131
|
+
if (version)
|
|
132
|
+
return version;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
async function resolveFromBunLock(lockfilePath, packageDir, packageName) {
|
|
137
|
+
const parsed = await readBunLock(lockfilePath);
|
|
138
|
+
const rootDir = path.dirname(lockfilePath);
|
|
139
|
+
const relDir = normalizeRelativePath(rootDir, packageDir);
|
|
140
|
+
const workspaceKeys = [relDir, ""];
|
|
141
|
+
for (const workspaceKey of workspaceKeys) {
|
|
142
|
+
const workspace = parsed.workspaces.get(workspaceKey);
|
|
143
|
+
const version = workspace?.get(packageName);
|
|
144
|
+
if (version)
|
|
145
|
+
return version;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
async function readPackageLock(lockfilePath) {
|
|
150
|
+
let promise = packageLockCache.get(lockfilePath);
|
|
151
|
+
if (!promise) {
|
|
152
|
+
promise = fs
|
|
153
|
+
.readFile(lockfilePath, "utf8")
|
|
154
|
+
.then((content) => JSON.parse(content));
|
|
155
|
+
packageLockCache.set(lockfilePath, promise);
|
|
156
|
+
}
|
|
157
|
+
return await promise;
|
|
158
|
+
}
|
|
159
|
+
async function readPnpmLock(lockfilePath) {
|
|
160
|
+
let promise = pnpmLockCache.get(lockfilePath);
|
|
161
|
+
if (!promise) {
|
|
162
|
+
promise = fs.readFile(lockfilePath, "utf8").then(parsePnpmLock);
|
|
163
|
+
pnpmLockCache.set(lockfilePath, promise);
|
|
164
|
+
}
|
|
165
|
+
return await promise;
|
|
166
|
+
}
|
|
167
|
+
async function readBunLock(lockfilePath) {
|
|
168
|
+
let promise = bunLockCache.get(lockfilePath);
|
|
169
|
+
if (!promise) {
|
|
170
|
+
promise = fs.readFile(lockfilePath, "utf8").then(parseBunLock);
|
|
171
|
+
bunLockCache.set(lockfilePath, promise);
|
|
172
|
+
}
|
|
173
|
+
return await promise;
|
|
174
|
+
}
|
|
175
|
+
function parsePnpmLock(content) {
|
|
176
|
+
const importers = new Map();
|
|
177
|
+
const lines = content.split(/\r?\n/);
|
|
178
|
+
let inImporters = false;
|
|
179
|
+
let currentImporter = null;
|
|
180
|
+
let inDependencySection = false;
|
|
181
|
+
let currentPackageName = null;
|
|
182
|
+
for (const rawLine of lines) {
|
|
183
|
+
const indent = rawLine.match(/^ */)?.[0].length ?? 0;
|
|
184
|
+
const trimmed = rawLine.trim();
|
|
185
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
186
|
+
continue;
|
|
187
|
+
if (indent === 0) {
|
|
188
|
+
inImporters = trimmed === "importers:";
|
|
189
|
+
currentImporter = null;
|
|
190
|
+
inDependencySection = false;
|
|
191
|
+
currentPackageName = null;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!inImporters)
|
|
195
|
+
continue;
|
|
196
|
+
if (indent === 2 && trimmed.endsWith(":")) {
|
|
197
|
+
currentImporter = trimYamlKey(trimmed.slice(0, -1));
|
|
198
|
+
importers.set(currentImporter, new Map());
|
|
199
|
+
inDependencySection = false;
|
|
200
|
+
currentPackageName = null;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!currentImporter)
|
|
204
|
+
continue;
|
|
205
|
+
if (indent === 4 && trimmed.endsWith(":")) {
|
|
206
|
+
const key = trimYamlKey(trimmed.slice(0, -1));
|
|
207
|
+
inDependencySection =
|
|
208
|
+
key === "dependencies" ||
|
|
209
|
+
key === "devDependencies" ||
|
|
210
|
+
key === "optionalDependencies";
|
|
211
|
+
currentPackageName = null;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!inDependencySection)
|
|
215
|
+
continue;
|
|
216
|
+
if (indent === 6) {
|
|
217
|
+
currentPackageName = null;
|
|
218
|
+
const separator = trimmed.indexOf(":");
|
|
219
|
+
if (separator === -1)
|
|
220
|
+
continue;
|
|
221
|
+
const key = trimYamlKey(trimmed.slice(0, separator));
|
|
222
|
+
const value = trimmed.slice(separator + 1).trim();
|
|
223
|
+
if (!value) {
|
|
224
|
+
currentPackageName = key;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const version = normalizePnpmVersion(value);
|
|
228
|
+
if (version) {
|
|
229
|
+
importers.get(currentImporter)?.set(key, version);
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (indent === 8 && currentPackageName && trimmed.startsWith("version:")) {
|
|
234
|
+
const version = normalizePnpmVersion(trimmed.slice("version:".length));
|
|
235
|
+
if (version) {
|
|
236
|
+
importers.get(currentImporter)?.set(currentPackageName, version);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { importers };
|
|
241
|
+
}
|
|
242
|
+
function parseBunLock(content) {
|
|
243
|
+
const workspaces = new Map();
|
|
244
|
+
const lines = content.split(/\r?\n/);
|
|
245
|
+
let inWorkspaces = false;
|
|
246
|
+
let currentWorkspace = "";
|
|
247
|
+
let currentSection = null;
|
|
248
|
+
for (const rawLine of lines) {
|
|
249
|
+
const indent = rawLine.match(/^ */)?.[0].length ?? 0;
|
|
250
|
+
const trimmed = rawLine.trim();
|
|
251
|
+
if (!trimmed)
|
|
252
|
+
continue;
|
|
253
|
+
if (!inWorkspaces && trimmed === '"workspaces": {') {
|
|
254
|
+
inWorkspaces = true;
|
|
255
|
+
currentWorkspace = "";
|
|
256
|
+
currentSection = null;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (inWorkspaces && indent <= 2 && trimmed === "},") {
|
|
260
|
+
inWorkspaces = false;
|
|
261
|
+
currentWorkspace = "";
|
|
262
|
+
currentSection = null;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (indent === 4 && trimmed.endsWith("{")) {
|
|
266
|
+
const keyMatch = trimmed.match(/^"([^"]*)": \{$/);
|
|
267
|
+
if (!keyMatch)
|
|
268
|
+
continue;
|
|
269
|
+
currentWorkspace = keyMatch[1] === "" ? "" : keyMatch[1];
|
|
270
|
+
workspaces.set(currentWorkspace, new Map());
|
|
271
|
+
currentSection = null;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!workspaces.has(currentWorkspace))
|
|
275
|
+
continue;
|
|
276
|
+
if (indent === 6 && trimmed.endsWith("{")) {
|
|
277
|
+
const keyMatch = trimmed.match(/^"(dependencies|devDependencies|optionalDependencies)": \{$/);
|
|
278
|
+
const sectionName = keyMatch?.[1];
|
|
279
|
+
currentSection =
|
|
280
|
+
sectionName === "dependencies" ||
|
|
281
|
+
sectionName === "devDependencies" ||
|
|
282
|
+
sectionName === "optionalDependencies"
|
|
283
|
+
? sectionName
|
|
284
|
+
: null;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!currentSection)
|
|
288
|
+
continue;
|
|
289
|
+
if (indent === 8) {
|
|
290
|
+
const depMatch = trimmed.match(/^"([^"]+)": "([^"]+)",?$/);
|
|
291
|
+
if (!depMatch)
|
|
292
|
+
continue;
|
|
293
|
+
const packageName = depMatch[1];
|
|
294
|
+
const version = extractAuditVersion(depMatch[2]);
|
|
295
|
+
if (version) {
|
|
296
|
+
workspaces.get(currentWorkspace)?.set(packageName, version);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { workspaces };
|
|
301
|
+
}
|
|
302
|
+
function normalizeRelativePath(rootDir, targetDir) {
|
|
303
|
+
const relative = path.relative(rootDir, targetDir).replace(/\\/g, "/");
|
|
304
|
+
return relative === "" ? "" : relative;
|
|
305
|
+
}
|
|
306
|
+
function normalizePnpmVersion(value) {
|
|
307
|
+
const cleaned = trimYamlKey(value.trim());
|
|
308
|
+
const base = cleaned.split("(")[0] ?? cleaned;
|
|
309
|
+
const match = base.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/);
|
|
310
|
+
return match?.[1] ?? null;
|
|
311
|
+
}
|
|
312
|
+
function trimYamlKey(value) {
|
|
313
|
+
return value.trim().replace(/^['"]|['"]$/g, "");
|
|
314
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ChangelogEntry {
|
|
2
|
+
content: string;
|
|
3
|
+
fetchedAt: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Fetches the changelog or release notes for a given package and repository URL.
|
|
7
|
+
* Uses SQLite caching to avoid API rate limits.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fetchChangelog(packageName: string, repositoryUrl?: string): Promise<string | null>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
5
|
+
class ChangelogCache {
|
|
6
|
+
db = null;
|
|
7
|
+
dbPath;
|
|
8
|
+
constructor() {
|
|
9
|
+
const basePath = path.join(os.homedir(), ".cache", "rainy-updates");
|
|
10
|
+
this.dbPath = path.join(basePath, "cache.db");
|
|
11
|
+
}
|
|
12
|
+
async init() {
|
|
13
|
+
if (this.db)
|
|
14
|
+
return;
|
|
15
|
+
try {
|
|
16
|
+
if (typeof Bun !== "undefined") {
|
|
17
|
+
await fs.mkdir(path.dirname(this.dbPath), { recursive: true });
|
|
18
|
+
const mod = await import("bun:sqlite");
|
|
19
|
+
this.db = new mod.Database(this.dbPath, { create: true });
|
|
20
|
+
this.db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS changelogs (
|
|
22
|
+
package TEXT PRIMARY KEY,
|
|
23
|
+
content TEXT,
|
|
24
|
+
fetched_at INTEGER NOT NULL
|
|
25
|
+
);
|
|
26
|
+
`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
// Fail silently if sqlite isn't available
|
|
31
|
+
this.db = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async get(packageName) {
|
|
35
|
+
if (!this.db)
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
const row = this.db
|
|
39
|
+
.prepare("SELECT content, fetched_at FROM changelogs WHERE package = ?")
|
|
40
|
+
.get(packageName);
|
|
41
|
+
if (!row)
|
|
42
|
+
return null;
|
|
43
|
+
const isExpired = Date.now() - row.fetched_at > CACHE_TTL_MS;
|
|
44
|
+
if (isExpired)
|
|
45
|
+
return null;
|
|
46
|
+
return row.content;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async set(packageName, content) {
|
|
53
|
+
if (!this.db)
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
this.db
|
|
57
|
+
.prepare("INSERT OR REPLACE INTO changelogs (package, content, fetched_at) VALUES (?, ?, ?)")
|
|
58
|
+
.run(packageName, content, Date.now());
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Ignore cache write errors
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const cache = new ChangelogCache();
|
|
66
|
+
/**
|
|
67
|
+
* Parses a repository URL into a GitHub owner and repo.
|
|
68
|
+
*/
|
|
69
|
+
function parseGitHubUrl(url) {
|
|
70
|
+
const match = url.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?/);
|
|
71
|
+
if (match && match[1] && match[2]) {
|
|
72
|
+
return { owner: match[1], repo: match[2] };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fetches the changelog or release notes for a given package and repository URL.
|
|
78
|
+
* Uses SQLite caching to avoid API rate limits.
|
|
79
|
+
*/
|
|
80
|
+
export async function fetchChangelog(packageName, repositoryUrl) {
|
|
81
|
+
if (!repositoryUrl)
|
|
82
|
+
return null;
|
|
83
|
+
await cache.init();
|
|
84
|
+
// 1. Check Cache
|
|
85
|
+
const cached = await cache.get(packageName);
|
|
86
|
+
if (cached)
|
|
87
|
+
return cached;
|
|
88
|
+
const githubInfo = parseGitHubUrl(repositoryUrl);
|
|
89
|
+
if (!githubInfo)
|
|
90
|
+
return null;
|
|
91
|
+
const { owner, repo } = githubInfo;
|
|
92
|
+
try {
|
|
93
|
+
// 2. Fetch from GitHub API
|
|
94
|
+
// Try releases first, fallback to CHANGELOG.md file
|
|
95
|
+
const headers = {
|
|
96
|
+
"User-Agent": "rainy-updates-cli",
|
|
97
|
+
Accept: "application/vnd.github.v3+json",
|
|
98
|
+
};
|
|
99
|
+
let content = "";
|
|
100
|
+
// Attempt to get the latest release notes
|
|
101
|
+
const releasesRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, { headers });
|
|
102
|
+
if (releasesRes.ok) {
|
|
103
|
+
const release = await releasesRes.json();
|
|
104
|
+
if (release.body) {
|
|
105
|
+
content = `# Release ${release.name || release.tag_name}\n\n${release.body}`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!content) {
|
|
109
|
+
// Fallback: try to fetch CHANGELOG.md from the root
|
|
110
|
+
const contentsRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/CHANGELOG.md`, { headers });
|
|
111
|
+
if (contentsRes.ok) {
|
|
112
|
+
const fileContent = await contentsRes.json();
|
|
113
|
+
if (fileContent.content && fileContent.encoding === "base64") {
|
|
114
|
+
content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (content) {
|
|
119
|
+
// 3. Cache the fetched content
|
|
120
|
+
await cache.set(packageName, content);
|
|
121
|
+
return content;
|
|
122
|
+
}
|
|
123
|
+
// Nothing found, cache empty string to prevent spamming
|
|
124
|
+
await cache.set(packageName, "");
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export function parseLicensesArgs(args) {
|
|
2
|
+
const options = {
|
|
3
|
+
cwd: process.cwd(),
|
|
4
|
+
workspace: false,
|
|
5
|
+
allow: undefined,
|
|
6
|
+
deny: undefined,
|
|
7
|
+
sbomFile: undefined,
|
|
8
|
+
jsonFile: undefined,
|
|
9
|
+
diffMode: false,
|
|
10
|
+
concurrency: 12,
|
|
11
|
+
registryTimeoutMs: 10_000,
|
|
12
|
+
cacheTtlSeconds: 3600,
|
|
13
|
+
};
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const current = args[i];
|
|
16
|
+
const next = args[i + 1];
|
|
17
|
+
if (current === "--cwd" && next) {
|
|
18
|
+
options.cwd = next;
|
|
19
|
+
i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (current === "--cwd")
|
|
23
|
+
throw new Error("Missing value for --cwd");
|
|
24
|
+
if (current === "--workspace") {
|
|
25
|
+
options.workspace = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (current === "--diff") {
|
|
29
|
+
options.diffMode = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (current === "--allow" && next) {
|
|
33
|
+
options.allow = next
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((s) => s.trim())
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (current === "--allow")
|
|
41
|
+
throw new Error("Missing value for --allow");
|
|
42
|
+
if (current === "--deny" && next) {
|
|
43
|
+
options.deny = next
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((s) => s.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
i++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (current === "--deny")
|
|
51
|
+
throw new Error("Missing value for --deny");
|
|
52
|
+
if (current === "--sbom" && next) {
|
|
53
|
+
options.sbomFile = next;
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (current === "--sbom")
|
|
58
|
+
throw new Error("Missing value for --sbom");
|
|
59
|
+
if (current === "--json-file" && next) {
|
|
60
|
+
options.jsonFile = next;
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (current === "--json-file")
|
|
65
|
+
throw new Error("Missing value for --json-file");
|
|
66
|
+
if (current === "--concurrency" && next) {
|
|
67
|
+
const n = Number(next);
|
|
68
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
69
|
+
throw new Error("--concurrency must be a positive integer");
|
|
70
|
+
options.concurrency = n;
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (current === "--concurrency")
|
|
75
|
+
throw new Error("Missing value for --concurrency");
|
|
76
|
+
if (current === "--timeout" && next) {
|
|
77
|
+
const ms = Number(next);
|
|
78
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
79
|
+
throw new Error("--timeout must be a positive number");
|
|
80
|
+
options.registryTimeoutMs = ms;
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (current === "--timeout")
|
|
85
|
+
throw new Error("Missing value for --timeout");
|
|
86
|
+
if (current === "--help" || current === "-h") {
|
|
87
|
+
process.stdout.write(LICENSES_HELP);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
if (current.startsWith("-"))
|
|
91
|
+
throw new Error(`Unknown option: ${current}`);
|
|
92
|
+
}
|
|
93
|
+
return options;
|
|
94
|
+
}
|
|
95
|
+
const LICENSES_HELP = `
|
|
96
|
+
rup licenses — Scan dependency licenses and generate SPDX SBOM
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
99
|
+
rup licenses [options]
|
|
100
|
+
|
|
101
|
+
Options:
|
|
102
|
+
--allow <spdx,...> Allow only these SPDX identifiers (e.g. MIT,Apache-2.0)
|
|
103
|
+
--deny <spdx,...> Deny these SPDX identifiers (e.g. GPL-3.0)
|
|
104
|
+
--sbom <path> Write SPDX 2.3 SBOM JSON to file
|
|
105
|
+
--json-file <path> Write JSON report to file
|
|
106
|
+
--diff Show only packages with a different license than last scan
|
|
107
|
+
--workspace Scan all workspace packages
|
|
108
|
+
--timeout <ms> Registry request timeout (default: 10000)
|
|
109
|
+
--concurrency <n> Parallel registry requests (default: 12)
|
|
110
|
+
--cwd <path> Working directory (default: cwd)
|
|
111
|
+
--help Show this help
|
|
112
|
+
|
|
113
|
+
Exit codes:
|
|
114
|
+
0 No violations
|
|
115
|
+
1 License violations detected
|
|
116
|
+
`.trimStart();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LicenseOptions, LicenseResult } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Entry point for `rup licenses`. Lazy-loaded by cli.ts.
|
|
4
|
+
*
|
|
5
|
+
* Fetches the SPDX license field from each dependency's packument,
|
|
6
|
+
* checks it against --allow/--deny lists, and optionally generates
|
|
7
|
+
* an SPDX 2.3 SBOM JSON document.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runLicenses(options: LicenseOptions): Promise<LicenseResult>;
|