@rainy-updates/cli 0.5.0-rc.2 → 0.5.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/CHANGELOG.md +29 -0
- package/README.md +1 -0
- package/dist/bin/cli.js +28 -26
- package/dist/cache/cache.d.ts +2 -0
- package/dist/cache/cache.js +8 -3
- package/dist/config/loader.d.ts +3 -1
- package/dist/config/policy.d.ts +15 -4
- package/dist/config/policy.js +35 -7
- package/dist/core/check.js +77 -10
- package/dist/core/fix-pr.js +24 -9
- package/dist/core/options.js +28 -2
- package/dist/core/summary.d.ts +22 -0
- package/dist/core/summary.js +78 -0
- package/dist/core/warm-cache.js +33 -5
- package/dist/output/format.js +20 -1
- package/dist/output/github.js +20 -9
- package/dist/output/sarif.js +16 -2
- package/dist/registry/npm.js +48 -4
- package/dist/types/index.d.ts +28 -4
- package/dist/utils/io.d.ts +1 -0
- package/dist/utils/io.js +10 -0
- package/dist/utils/stable-json.d.ts +1 -0
- package/dist/utils/stable-json.js +20 -0
- package/dist/workspace/discover.js +7 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-02-27
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Promoted `0.5.0-rc.4` to General Availability.
|
|
10
|
+
- Stabilized deterministic CI artifact behavior for JSON, SARIF, and GitHub outputs.
|
|
11
|
+
- Finalized fix-PR summary metadata contract defaults for automation pipelines.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- GA release gate includes `perf:smoke` CI check for regression protection.
|
|
16
|
+
|
|
17
|
+
## [0.5.0-rc.4] - 2026-02-27
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Hardened deterministic CI artifacts:
|
|
22
|
+
- stable key ordering for JSON and SARIF files,
|
|
23
|
+
- deterministic sorting for updates, warnings, and errors in output pipelines.
|
|
24
|
+
- Improved fail-reason classification consistency for registry/runtime failures across commands.
|
|
25
|
+
- Fix-PR metadata in summary now has stable defaults (`fixPrApplied`, `fixBranchName`, `fixCommitSha`) to reduce contract drift.
|
|
26
|
+
- Fix-PR staging now includes only updated manifests plus explicit report files, with deterministic file ordering.
|
|
27
|
+
- Added warning when Bun runtime falls back from SQLite cache backend to file cache backend.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Added `perf:smoke` script and CI gate to enforce a basic performance regression threshold.
|
|
32
|
+
- Added deterministic output and summary regression tests.
|
|
33
|
+
|
|
5
34
|
## [0.5.0-rc.2] - 2026-02-27
|
|
6
35
|
|
|
7
36
|
### Added
|
package/README.md
CHANGED
|
@@ -205,6 +205,7 @@ rainy-updates --version
|
|
|
205
205
|
This package ships with production CI/CD pipelines in the repository:
|
|
206
206
|
|
|
207
207
|
- Continuous integration pipeline for typecheck, tests, build, and production smoke checks.
|
|
208
|
+
- Performance smoke gate (`perf:smoke`) to catch startup/runtime regressions in CI.
|
|
208
209
|
- Tag-driven release pipeline for npm publishing with provenance.
|
|
209
210
|
- Release preflight validation for npm auth/scope checks before publishing.
|
|
210
211
|
|
package/dist/bin/cli.js
CHANGED
|
@@ -14,6 +14,9 @@ import { renderResult } from "../output/format.js";
|
|
|
14
14
|
import { writeGitHubOutput } from "../output/github.js";
|
|
15
15
|
import { createSarifReport } from "../output/sarif.js";
|
|
16
16
|
import { renderPrReport } from "../output/pr-report.js";
|
|
17
|
+
import { writeFileAtomic } from "../utils/io.js";
|
|
18
|
+
import { resolveFailReason } from "../core/summary.js";
|
|
19
|
+
import { stableStringify } from "../utils/stable-json.js";
|
|
17
20
|
async function main() {
|
|
18
21
|
try {
|
|
19
22
|
const argv = process.argv.slice(2);
|
|
@@ -64,30 +67,36 @@ async function main() {
|
|
|
64
67
|
const result = await runCommand(parsed);
|
|
65
68
|
if (parsed.options.prReportFile) {
|
|
66
69
|
const markdown = renderPrReport(result);
|
|
67
|
-
await
|
|
68
|
-
await fs.writeFile(parsed.options.prReportFile, markdown + "\n", "utf8");
|
|
70
|
+
await writeFileAtomic(parsed.options.prReportFile, markdown + "\n");
|
|
69
71
|
}
|
|
70
72
|
if (parsed.options.fixPr && (parsed.command === "check" || parsed.command === "upgrade")) {
|
|
73
|
+
result.summary.fixPrApplied = false;
|
|
74
|
+
result.summary.fixBranchName = parsed.options.fixBranch ?? "chore/rainy-updates";
|
|
75
|
+
result.summary.fixCommitSha = "";
|
|
71
76
|
const fixResult = await applyFixPr(parsed.options, result, parsed.options.prReportFile ? [parsed.options.prReportFile] : []);
|
|
72
77
|
result.summary.fixPrApplied = fixResult.applied;
|
|
73
|
-
result.summary.fixBranchName = fixResult.branchName;
|
|
74
|
-
result.summary.fixCommitSha = fixResult.commitSha;
|
|
78
|
+
result.summary.fixBranchName = fixResult.branchName ?? "";
|
|
79
|
+
result.summary.fixCommitSha = fixResult.commitSha ?? "";
|
|
80
|
+
}
|
|
81
|
+
result.summary.failReason = resolveFailReason(result.updates, result.errors, parsed.options.failOn, parsed.options.maxUpdates, parsed.options.ci);
|
|
82
|
+
const renderStartedAt = Date.now();
|
|
83
|
+
let rendered = renderResult(result, parsed.options.format);
|
|
84
|
+
result.summary.durationMs.render = Math.max(0, Date.now() - renderStartedAt);
|
|
85
|
+
if (parsed.options.format === "json" || parsed.options.format === "metrics") {
|
|
86
|
+
rendered = renderResult(result, parsed.options.format);
|
|
75
87
|
}
|
|
76
88
|
if (parsed.options.jsonFile) {
|
|
77
|
-
await
|
|
78
|
-
await fs.writeFile(parsed.options.jsonFile, JSON.stringify(result, null, 2) + "\n", "utf8");
|
|
89
|
+
await writeFileAtomic(parsed.options.jsonFile, stableStringify(result, 2) + "\n");
|
|
79
90
|
}
|
|
80
91
|
if (parsed.options.githubOutputFile) {
|
|
81
92
|
await writeGitHubOutput(parsed.options.githubOutputFile, result);
|
|
82
93
|
}
|
|
83
94
|
if (parsed.options.sarifFile) {
|
|
84
95
|
const sarif = createSarifReport(result);
|
|
85
|
-
await
|
|
86
|
-
await fs.writeFile(parsed.options.sarifFile, JSON.stringify(sarif, null, 2) + "\n", "utf8");
|
|
96
|
+
await writeFileAtomic(parsed.options.sarifFile, stableStringify(sarif, 2) + "\n");
|
|
87
97
|
}
|
|
88
|
-
const rendered = renderResult(result, parsed.options.format);
|
|
89
98
|
process.stdout.write(rendered + "\n");
|
|
90
|
-
process.exitCode = resolveExitCode(result,
|
|
99
|
+
process.exitCode = resolveExitCode(result, result.summary.failReason);
|
|
91
100
|
}
|
|
92
101
|
catch (error) {
|
|
93
102
|
process.stderr.write(`rainy-updates: ${String(error)}\n`);
|
|
@@ -116,6 +125,7 @@ Options:
|
|
|
116
125
|
--fix-branch <name>
|
|
117
126
|
--fix-commit-message <text>
|
|
118
127
|
--fix-dry-run
|
|
128
|
+
--fix-pr-no-checkout
|
|
119
129
|
--no-pr-report
|
|
120
130
|
--json-file <path>
|
|
121
131
|
--github-output <path>
|
|
@@ -123,6 +133,7 @@ Options:
|
|
|
123
133
|
--pr-report-file <path>
|
|
124
134
|
--fail-on none|patch|minor|major|any
|
|
125
135
|
--max-updates <n>
|
|
136
|
+
--log-level error|warn|info|debug
|
|
126
137
|
--ci`;
|
|
127
138
|
}
|
|
128
139
|
if (isCommand && command === "warm-cache") {
|
|
@@ -161,6 +172,7 @@ Options:
|
|
|
161
172
|
--fix-branch <name>
|
|
162
173
|
--fix-commit-message <text>
|
|
163
174
|
--fix-dry-run
|
|
175
|
+
--fix-pr-no-checkout
|
|
164
176
|
--no-pr-report
|
|
165
177
|
--json-file <path>
|
|
166
178
|
--pr-report-file <path>`;
|
|
@@ -202,7 +214,7 @@ Global options:
|
|
|
202
214
|
--cwd <path>
|
|
203
215
|
--workspace
|
|
204
216
|
--target patch|minor|major|latest
|
|
205
|
-
--format table|json|minimal|github
|
|
217
|
+
--format table|json|minimal|github|metrics
|
|
206
218
|
--json-file <path>
|
|
207
219
|
--github-output <path>
|
|
208
220
|
--sarif-file <path>
|
|
@@ -214,7 +226,9 @@ Global options:
|
|
|
214
226
|
--fix-branch <name>
|
|
215
227
|
--fix-commit-message <text>
|
|
216
228
|
--fix-dry-run
|
|
229
|
+
--fix-pr-no-checkout
|
|
217
230
|
--no-pr-report
|
|
231
|
+
--log-level error|warn|info|debug
|
|
218
232
|
--concurrency <n>
|
|
219
233
|
--cache-ttl <seconds>
|
|
220
234
|
--offline
|
|
@@ -247,22 +261,10 @@ async function readPackageVersion() {
|
|
|
247
261
|
const parsed = JSON.parse(content);
|
|
248
262
|
return parsed.version ?? "0.0.0";
|
|
249
263
|
}
|
|
250
|
-
function resolveExitCode(result,
|
|
264
|
+
function resolveExitCode(result, failReason) {
|
|
251
265
|
if (result.errors.length > 0)
|
|
252
266
|
return 2;
|
|
253
|
-
if (
|
|
267
|
+
if (failReason !== "none")
|
|
254
268
|
return 1;
|
|
255
|
-
|
|
256
|
-
if (!shouldFailForUpdates(result.updates, effectiveFailOn))
|
|
257
|
-
return 0;
|
|
258
|
-
return 1;
|
|
259
|
-
}
|
|
260
|
-
function shouldFailForUpdates(updates, failOn) {
|
|
261
|
-
if (failOn === "none")
|
|
262
|
-
return false;
|
|
263
|
-
if (failOn === "any" || failOn === "patch")
|
|
264
|
-
return updates.length > 0;
|
|
265
|
-
if (failOn === "minor")
|
|
266
|
-
return updates.some((update) => update.diffType === "minor" || update.diffType === "major");
|
|
267
|
-
return updates.some((update) => update.diffType === "major");
|
|
269
|
+
return 0;
|
|
268
270
|
}
|
package/dist/cache/cache.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { CachedVersion, TargetLevel } from "../types/index.js";
|
|
2
2
|
export declare class VersionCache {
|
|
3
3
|
private readonly store;
|
|
4
|
+
readonly backend: "sqlite" | "file";
|
|
5
|
+
readonly degraded: boolean;
|
|
4
6
|
private constructor();
|
|
5
7
|
static create(customPath?: string): Promise<VersionCache>;
|
|
6
8
|
getValid(packageName: string, target: TargetLevel): Promise<CachedVersion | null>;
|
package/dist/cache/cache.js
CHANGED
|
@@ -106,17 +106,22 @@ class SqliteCacheStore {
|
|
|
106
106
|
}
|
|
107
107
|
export class VersionCache {
|
|
108
108
|
store;
|
|
109
|
-
|
|
109
|
+
backend;
|
|
110
|
+
degraded;
|
|
111
|
+
constructor(store, backend, degraded) {
|
|
110
112
|
this.store = store;
|
|
113
|
+
this.backend = backend;
|
|
114
|
+
this.degraded = degraded;
|
|
111
115
|
}
|
|
112
116
|
static async create(customPath) {
|
|
113
117
|
const basePath = customPath ?? path.join(os.homedir(), ".cache", "rainy-updates");
|
|
114
118
|
const sqlitePath = path.join(basePath, "cache.db");
|
|
115
119
|
const sqliteStore = await tryCreateSqliteStore(sqlitePath);
|
|
116
120
|
if (sqliteStore)
|
|
117
|
-
return new VersionCache(sqliteStore);
|
|
121
|
+
return new VersionCache(sqliteStore, "sqlite", false);
|
|
118
122
|
const jsonPath = path.join(basePath, "cache.json");
|
|
119
|
-
|
|
123
|
+
const degraded = typeof Bun !== "undefined";
|
|
124
|
+
return new VersionCache(new FileCacheStore(jsonPath), "file", degraded);
|
|
120
125
|
}
|
|
121
126
|
async getValid(packageName, target) {
|
|
122
127
|
const entry = await this.store.get(packageName, target);
|
package/dist/config/loader.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DependencyKind, FailOnLevel, OutputFormat, TargetLevel } from "../types/index.js";
|
|
1
|
+
import type { DependencyKind, FailOnLevel, LogLevel, OutputFormat, TargetLevel } from "../types/index.js";
|
|
2
2
|
export interface FileConfig {
|
|
3
3
|
target?: TargetLevel;
|
|
4
4
|
filter?: string;
|
|
@@ -21,7 +21,9 @@ export interface FileConfig {
|
|
|
21
21
|
fixBranch?: string;
|
|
22
22
|
fixCommitMessage?: string;
|
|
23
23
|
fixDryRun?: boolean;
|
|
24
|
+
fixPrNoCheckout?: boolean;
|
|
24
25
|
noPrReport?: boolean;
|
|
26
|
+
logLevel?: LogLevel;
|
|
25
27
|
install?: boolean;
|
|
26
28
|
packageManager?: "auto" | "npm" | "pnpm";
|
|
27
29
|
sync?: boolean;
|
package/dist/config/policy.d.ts
CHANGED
|
@@ -2,15 +2,26 @@ import type { TargetLevel } from "../types/index.js";
|
|
|
2
2
|
export interface PolicyConfig {
|
|
3
3
|
ignore?: string[];
|
|
4
4
|
packageRules?: Record<string, {
|
|
5
|
+
match?: string;
|
|
5
6
|
maxTarget?: TargetLevel;
|
|
6
7
|
ignore?: boolean;
|
|
8
|
+
maxUpdatesPerRun?: number;
|
|
9
|
+
cooldownDays?: number;
|
|
10
|
+
allowPrerelease?: boolean;
|
|
7
11
|
}>;
|
|
8
12
|
}
|
|
13
|
+
export interface PolicyRule {
|
|
14
|
+
match?: string;
|
|
15
|
+
maxTarget?: TargetLevel;
|
|
16
|
+
ignore: boolean;
|
|
17
|
+
maxUpdatesPerRun?: number;
|
|
18
|
+
cooldownDays?: number;
|
|
19
|
+
allowPrerelease?: boolean;
|
|
20
|
+
}
|
|
9
21
|
export interface ResolvedPolicy {
|
|
10
22
|
ignorePatterns: string[];
|
|
11
|
-
packageRules: Map<string,
|
|
12
|
-
|
|
13
|
-
ignore: boolean;
|
|
14
|
-
}>;
|
|
23
|
+
packageRules: Map<string, PolicyRule>;
|
|
24
|
+
matchRules: PolicyRule[];
|
|
15
25
|
}
|
|
16
26
|
export declare function loadPolicy(cwd: string, policyFile?: string): Promise<ResolvedPolicy>;
|
|
27
|
+
export declare function resolvePolicyRule(packageName: string, policy: ResolvedPolicy): PolicyRule | undefined;
|
package/dist/config/policy.js
CHANGED
|
@@ -12,13 +12,10 @@ export async function loadPolicy(cwd, policyFile) {
|
|
|
12
12
|
const parsed = JSON.parse(content);
|
|
13
13
|
return {
|
|
14
14
|
ignorePatterns: parsed.ignore ?? [],
|
|
15
|
-
packageRules: new Map(Object.entries(parsed.packageRules ?? {}).map(([pkg, rule]) => [
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
ignore: rule.ignore === true,
|
|
20
|
-
},
|
|
21
|
-
])),
|
|
15
|
+
packageRules: new Map(Object.entries(parsed.packageRules ?? {}).map(([pkg, rule]) => [pkg, normalizeRule(rule)])),
|
|
16
|
+
matchRules: Object.values(parsed.packageRules ?? {})
|
|
17
|
+
.map((rule) => normalizeRule(rule))
|
|
18
|
+
.filter((rule) => typeof rule.match === "string" && rule.match.length > 0),
|
|
22
19
|
};
|
|
23
20
|
}
|
|
24
21
|
catch {
|
|
@@ -28,5 +25,36 @@ export async function loadPolicy(cwd, policyFile) {
|
|
|
28
25
|
return {
|
|
29
26
|
ignorePatterns: [],
|
|
30
27
|
packageRules: new Map(),
|
|
28
|
+
matchRules: [],
|
|
31
29
|
};
|
|
32
30
|
}
|
|
31
|
+
export function resolvePolicyRule(packageName, policy) {
|
|
32
|
+
const exact = policy.packageRules.get(packageName);
|
|
33
|
+
if (exact)
|
|
34
|
+
return exact;
|
|
35
|
+
return policy.matchRules.find((rule) => matchesPattern(packageName, rule.match));
|
|
36
|
+
}
|
|
37
|
+
function normalizeRule(rule) {
|
|
38
|
+
return {
|
|
39
|
+
match: typeof rule.match === "string" ? rule.match : undefined,
|
|
40
|
+
maxTarget: rule.maxTarget,
|
|
41
|
+
ignore: rule.ignore === true,
|
|
42
|
+
maxUpdatesPerRun: asNonNegativeInt(rule.maxUpdatesPerRun),
|
|
43
|
+
cooldownDays: asNonNegativeInt(rule.cooldownDays),
|
|
44
|
+
allowPrerelease: rule.allowPrerelease === true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function asNonNegativeInt(value) {
|
|
48
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0)
|
|
49
|
+
return undefined;
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function matchesPattern(value, pattern) {
|
|
53
|
+
if (!pattern || pattern.length === 0)
|
|
54
|
+
return false;
|
|
55
|
+
if (pattern === "*")
|
|
56
|
+
return true;
|
|
57
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
58
|
+
const regex = new RegExp(`^${escaped}$`);
|
|
59
|
+
return regex.test(value);
|
|
60
|
+
}
|
package/dist/core/check.js
CHANGED
|
@@ -6,16 +6,26 @@ import { VersionCache } from "../cache/cache.js";
|
|
|
6
6
|
import { NpmRegistryClient } from "../registry/npm.js";
|
|
7
7
|
import { detectPackageManager } from "../pm/detect.js";
|
|
8
8
|
import { discoverPackageDirs } from "../workspace/discover.js";
|
|
9
|
-
import { loadPolicy } from "../config/policy.js";
|
|
9
|
+
import { loadPolicy, resolvePolicyRule } from "../config/policy.js";
|
|
10
|
+
import { createSummary, finalizeSummary } from "./summary.js";
|
|
10
11
|
export async function check(options) {
|
|
12
|
+
const startedAt = Date.now();
|
|
13
|
+
let discoveryMs = 0;
|
|
14
|
+
let cacheMs = 0;
|
|
15
|
+
let registryMs = 0;
|
|
16
|
+
const discoveryStartedAt = Date.now();
|
|
11
17
|
const packageManager = await detectPackageManager(options.cwd);
|
|
12
18
|
const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
|
|
19
|
+
discoveryMs += Date.now() - discoveryStartedAt;
|
|
13
20
|
const cache = await VersionCache.create();
|
|
14
21
|
const registryClient = new NpmRegistryClient(options.cwd);
|
|
15
22
|
const policy = await loadPolicy(options.cwd, options.policyFile);
|
|
16
23
|
const updates = [];
|
|
17
24
|
const errors = [];
|
|
18
25
|
const warnings = [];
|
|
26
|
+
if (cache.degraded) {
|
|
27
|
+
warnings.push("SQLite cache backend unavailable in Bun runtime. Falling back to file cache backend.");
|
|
28
|
+
}
|
|
19
29
|
let totalDependencies = 0;
|
|
20
30
|
const tasks = [];
|
|
21
31
|
let skipped = 0;
|
|
@@ -35,7 +45,7 @@ export async function check(options) {
|
|
|
35
45
|
continue;
|
|
36
46
|
if (options.reject && matchesPattern(dep.name, options.reject))
|
|
37
47
|
continue;
|
|
38
|
-
const rule =
|
|
48
|
+
const rule = resolvePolicyRule(dep.name, policy);
|
|
39
49
|
if (rule?.ignore === true) {
|
|
40
50
|
skipped += 1;
|
|
41
51
|
continue;
|
|
@@ -47,9 +57,10 @@ export async function check(options) {
|
|
|
47
57
|
tasks.push({ packageDir, dependency: dep });
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
|
-
const uniquePackageNames = Array.from(new Set(tasks.map((task) => task.dependency.name)));
|
|
60
|
+
const uniquePackageNames = Array.from(new Set(tasks.map((task) => task.dependency.name))).sort((a, b) => a.localeCompare(b));
|
|
51
61
|
const resolvedVersions = new Map();
|
|
52
62
|
const unresolvedPackages = [];
|
|
63
|
+
const cacheLookupStartedAt = Date.now();
|
|
53
64
|
for (const packageName of uniquePackageNames) {
|
|
54
65
|
const cached = await cache.getValid(packageName, options.target);
|
|
55
66
|
if (cached) {
|
|
@@ -62,8 +73,10 @@ export async function check(options) {
|
|
|
62
73
|
unresolvedPackages.push(packageName);
|
|
63
74
|
}
|
|
64
75
|
}
|
|
76
|
+
cacheMs += Date.now() - cacheLookupStartedAt;
|
|
65
77
|
if (unresolvedPackages.length > 0) {
|
|
66
78
|
if (options.offline) {
|
|
79
|
+
const cacheFallbackStartedAt = Date.now();
|
|
67
80
|
for (const packageName of unresolvedPackages) {
|
|
68
81
|
const stale = await cache.getAny(packageName, options.target);
|
|
69
82
|
if (stale) {
|
|
@@ -77,11 +90,15 @@ export async function check(options) {
|
|
|
77
90
|
errors.push(`Offline cache miss for ${packageName}. Run once without --offline to warm cache.`);
|
|
78
91
|
}
|
|
79
92
|
}
|
|
93
|
+
cacheMs += Date.now() - cacheFallbackStartedAt;
|
|
80
94
|
}
|
|
81
95
|
else {
|
|
96
|
+
const registryStartedAt = Date.now();
|
|
82
97
|
const fetched = await registryClient.resolveManyPackageMetadata(unresolvedPackages, {
|
|
83
98
|
concurrency: options.concurrency,
|
|
84
99
|
});
|
|
100
|
+
registryMs += Date.now() - registryStartedAt;
|
|
101
|
+
const cacheWriteStartedAt = Date.now();
|
|
85
102
|
for (const [packageName, metadata] of fetched.metadata) {
|
|
86
103
|
resolvedVersions.set(packageName, {
|
|
87
104
|
latestVersion: metadata.latestVersion,
|
|
@@ -91,6 +108,8 @@ export async function check(options) {
|
|
|
91
108
|
await cache.set(packageName, options.target, metadata.latestVersion, metadata.versions, options.cacheTtlSeconds);
|
|
92
109
|
}
|
|
93
110
|
}
|
|
111
|
+
cacheMs += Date.now() - cacheWriteStartedAt;
|
|
112
|
+
const cacheStaleStartedAt = Date.now();
|
|
94
113
|
for (const [packageName, error] of fetched.errors) {
|
|
95
114
|
const stale = await cache.getAny(packageName, options.target);
|
|
96
115
|
if (stale) {
|
|
@@ -104,13 +123,14 @@ export async function check(options) {
|
|
|
104
123
|
errors.push(`Unable to resolve ${packageName}: ${error}`);
|
|
105
124
|
}
|
|
106
125
|
}
|
|
126
|
+
cacheMs += Date.now() - cacheStaleStartedAt;
|
|
107
127
|
}
|
|
108
128
|
}
|
|
109
129
|
for (const task of tasks) {
|
|
110
130
|
const metadata = resolvedVersions.get(task.dependency.name);
|
|
111
131
|
if (!metadata?.latestVersion)
|
|
112
132
|
continue;
|
|
113
|
-
const rule =
|
|
133
|
+
const rule = resolvePolicyRule(task.dependency.name, policy);
|
|
114
134
|
const effectiveTarget = clampTarget(options.target, rule?.maxTarget);
|
|
115
135
|
const picked = pickTargetVersionFromAvailable(task.dependency.range, metadata.availableVersions, metadata.latestVersion, effectiveTarget);
|
|
116
136
|
if (!picked)
|
|
@@ -130,15 +150,26 @@ export async function check(options) {
|
|
|
130
150
|
reason: rule?.maxTarget ? `policy maxTarget=${rule.maxTarget}` : undefined,
|
|
131
151
|
});
|
|
132
152
|
}
|
|
133
|
-
const
|
|
153
|
+
const limitedUpdates = sortUpdates(applyRuleUpdateCaps(updates, policy));
|
|
154
|
+
const sortedErrors = [...errors].sort((a, b) => a.localeCompare(b));
|
|
155
|
+
const sortedWarnings = [...warnings].sort((a, b) => a.localeCompare(b));
|
|
156
|
+
const summary = finalizeSummary(createSummary({
|
|
134
157
|
scannedPackages: packageDirs.length,
|
|
135
158
|
totalDependencies,
|
|
136
159
|
checkedDependencies: tasks.length,
|
|
137
|
-
updatesFound:
|
|
160
|
+
updatesFound: limitedUpdates.length,
|
|
138
161
|
upgraded: 0,
|
|
139
162
|
skipped,
|
|
140
163
|
warmedPackages: 0,
|
|
141
|
-
|
|
164
|
+
errors: sortedErrors,
|
|
165
|
+
warnings: sortedWarnings,
|
|
166
|
+
durations: {
|
|
167
|
+
totalMs: Date.now() - startedAt,
|
|
168
|
+
discoveryMs,
|
|
169
|
+
registryMs,
|
|
170
|
+
cacheMs,
|
|
171
|
+
},
|
|
172
|
+
}));
|
|
142
173
|
return {
|
|
143
174
|
projectPath: options.cwd,
|
|
144
175
|
packagePaths: packageDirs,
|
|
@@ -146,8 +177,44 @@ export async function check(options) {
|
|
|
146
177
|
target: options.target,
|
|
147
178
|
timestamp: new Date().toISOString(),
|
|
148
179
|
summary,
|
|
149
|
-
updates,
|
|
150
|
-
errors,
|
|
151
|
-
warnings,
|
|
180
|
+
updates: limitedUpdates,
|
|
181
|
+
errors: sortedErrors,
|
|
182
|
+
warnings: sortedWarnings,
|
|
152
183
|
};
|
|
153
184
|
}
|
|
185
|
+
function applyRuleUpdateCaps(updates, policy) {
|
|
186
|
+
const limited = [];
|
|
187
|
+
const seenPerPackage = new Map();
|
|
188
|
+
for (const update of updates) {
|
|
189
|
+
const rule = resolvePolicyRule(update.name, policy);
|
|
190
|
+
const cap = rule?.maxUpdatesPerRun;
|
|
191
|
+
if (typeof cap !== "number") {
|
|
192
|
+
limited.push(update);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const seen = seenPerPackage.get(update.name) ?? 0;
|
|
196
|
+
if (seen >= cap) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
seenPerPackage.set(update.name, seen + 1);
|
|
200
|
+
limited.push(update);
|
|
201
|
+
}
|
|
202
|
+
return limited;
|
|
203
|
+
}
|
|
204
|
+
function sortUpdates(updates) {
|
|
205
|
+
return [...updates].sort((left, right) => {
|
|
206
|
+
const byPath = left.packagePath.localeCompare(right.packagePath);
|
|
207
|
+
if (byPath !== 0)
|
|
208
|
+
return byPath;
|
|
209
|
+
const byName = left.name.localeCompare(right.name);
|
|
210
|
+
if (byName !== 0)
|
|
211
|
+
return byName;
|
|
212
|
+
const byKind = left.kind.localeCompare(right.kind);
|
|
213
|
+
if (byKind !== 0)
|
|
214
|
+
return byKind;
|
|
215
|
+
const byFrom = left.fromRange.localeCompare(right.fromRange);
|
|
216
|
+
if (byFrom !== 0)
|
|
217
|
+
return byFrom;
|
|
218
|
+
return left.toRange.localeCompare(right.toRange);
|
|
219
|
+
});
|
|
220
|
+
}
|
package/dist/core/fix-pr.js
CHANGED
|
@@ -3,28 +3,42 @@ import path from "node:path";
|
|
|
3
3
|
export async function applyFixPr(options, result, extraFiles) {
|
|
4
4
|
if (!options.fixPr)
|
|
5
5
|
return { applied: false };
|
|
6
|
-
if (result.updates.length === 0)
|
|
7
|
-
return {
|
|
6
|
+
if (result.updates.length === 0) {
|
|
7
|
+
return {
|
|
8
|
+
applied: false,
|
|
9
|
+
branchName: options.fixBranch ?? "chore/rainy-updates",
|
|
10
|
+
commitSha: "",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
8
13
|
const status = await runGit(options.cwd, ["status", "--porcelain"]);
|
|
9
14
|
if (status.stdout.trim().length > 0) {
|
|
10
15
|
throw new Error("Cannot run --fix-pr with a dirty git working tree.");
|
|
11
16
|
}
|
|
12
17
|
const branch = options.fixBranch ?? "chore/rainy-updates";
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
|
|
18
|
+
const headRef = await runGit(options.cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], true);
|
|
19
|
+
if (headRef.code !== 0 && !options.fixPrNoCheckout) {
|
|
20
|
+
throw new Error("Cannot run --fix-pr in detached HEAD state without --fix-pr-no-checkout.");
|
|
16
21
|
}
|
|
17
|
-
|
|
18
|
-
await runGit(options.cwd, ["
|
|
22
|
+
if (!options.fixPrNoCheckout) {
|
|
23
|
+
const branchCheck = await runGit(options.cwd, ["rev-parse", "--verify", "--quiet", branch], true);
|
|
24
|
+
if (branchCheck.code === 0) {
|
|
25
|
+
await runGit(options.cwd, ["checkout", branch]);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
await runGit(options.cwd, ["checkout", "-b", branch]);
|
|
29
|
+
}
|
|
19
30
|
}
|
|
20
31
|
if (options.fixDryRun) {
|
|
21
32
|
return {
|
|
22
33
|
applied: false,
|
|
23
34
|
branchName: branch,
|
|
35
|
+
commitSha: "",
|
|
24
36
|
};
|
|
25
37
|
}
|
|
26
|
-
const manifestFiles = Array.from(new Set(result.
|
|
27
|
-
const filesToStage = Array.from(new Set([...manifestFiles, ...extraFiles]
|
|
38
|
+
const manifestFiles = Array.from(new Set(result.updates.map((update) => path.resolve(update.packagePath, "package.json"))));
|
|
39
|
+
const filesToStage = Array.from(new Set([...manifestFiles, ...extraFiles]
|
|
40
|
+
.map((entry) => path.resolve(options.cwd, entry))
|
|
41
|
+
.filter((entry) => entry.startsWith(path.resolve(options.cwd) + path.sep) || entry === path.resolve(options.cwd)))).sort((a, b) => a.localeCompare(b));
|
|
28
42
|
if (filesToStage.length > 0) {
|
|
29
43
|
await runGit(options.cwd, ["add", "--", ...filesToStage]);
|
|
30
44
|
}
|
|
@@ -33,6 +47,7 @@ export async function applyFixPr(options, result, extraFiles) {
|
|
|
33
47
|
return {
|
|
34
48
|
applied: false,
|
|
35
49
|
branchName: branch,
|
|
50
|
+
commitSha: "",
|
|
36
51
|
};
|
|
37
52
|
}
|
|
38
53
|
const message = options.fixCommitMessage ?? `chore(deps): apply rainy-updates (${result.updates.length} updates)`;
|
package/dist/core/options.js
CHANGED
|
@@ -40,7 +40,9 @@ export async function parseCliArgs(argv) {
|
|
|
40
40
|
fixBranch: "chore/rainy-updates",
|
|
41
41
|
fixCommitMessage: undefined,
|
|
42
42
|
fixDryRun: false,
|
|
43
|
+
fixPrNoCheckout: false,
|
|
43
44
|
noPrReport: false,
|
|
45
|
+
logLevel: "info",
|
|
44
46
|
};
|
|
45
47
|
let force = false;
|
|
46
48
|
let initCiMode = "enterprise";
|
|
@@ -208,6 +210,18 @@ export async function parseCliArgs(argv) {
|
|
|
208
210
|
base.noPrReport = true;
|
|
209
211
|
continue;
|
|
210
212
|
}
|
|
213
|
+
if (current === "--fix-pr-no-checkout") {
|
|
214
|
+
base.fixPrNoCheckout = true;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (current === "--log-level" && next) {
|
|
218
|
+
base.logLevel = ensureLogLevel(next);
|
|
219
|
+
index += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (current === "--log-level") {
|
|
223
|
+
throw new Error("Missing value for --log-level");
|
|
224
|
+
}
|
|
211
225
|
if (current === "--install" && command === "upgrade") {
|
|
212
226
|
continue;
|
|
213
227
|
}
|
|
@@ -406,9 +420,15 @@ function applyConfig(base, config) {
|
|
|
406
420
|
if (typeof config.fixDryRun === "boolean") {
|
|
407
421
|
base.fixDryRun = config.fixDryRun;
|
|
408
422
|
}
|
|
423
|
+
if (typeof config.fixPrNoCheckout === "boolean") {
|
|
424
|
+
base.fixPrNoCheckout = config.fixPrNoCheckout;
|
|
425
|
+
}
|
|
409
426
|
if (typeof config.noPrReport === "boolean") {
|
|
410
427
|
base.noPrReport = config.noPrReport;
|
|
411
428
|
}
|
|
429
|
+
if (typeof config.logLevel === "string") {
|
|
430
|
+
base.logLevel = ensureLogLevel(config.logLevel);
|
|
431
|
+
}
|
|
412
432
|
}
|
|
413
433
|
function parsePackageManager(args) {
|
|
414
434
|
const index = args.indexOf("--pm");
|
|
@@ -427,10 +447,16 @@ function ensureTarget(value) {
|
|
|
427
447
|
throw new Error("--target must be patch, minor, major, latest");
|
|
428
448
|
}
|
|
429
449
|
function ensureFormat(value) {
|
|
430
|
-
if (value === "table" || value === "json" || value === "minimal" || value === "github") {
|
|
450
|
+
if (value === "table" || value === "json" || value === "minimal" || value === "github" || value === "metrics") {
|
|
451
|
+
return value;
|
|
452
|
+
}
|
|
453
|
+
throw new Error("--format must be table, json, minimal, github or metrics");
|
|
454
|
+
}
|
|
455
|
+
function ensureLogLevel(value) {
|
|
456
|
+
if (value === "error" || value === "warn" || value === "info" || value === "debug") {
|
|
431
457
|
return value;
|
|
432
458
|
}
|
|
433
|
-
throw new Error("--
|
|
459
|
+
throw new Error("--log-level must be error, warn, info or debug");
|
|
434
460
|
}
|
|
435
461
|
function parseDependencyKinds(value) {
|
|
436
462
|
const mapped = value
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FailOnLevel, FailReason, PackageUpdate, Summary } from "../types/index.js";
|
|
2
|
+
export interface DurationInput {
|
|
3
|
+
totalMs: number;
|
|
4
|
+
discoveryMs: number;
|
|
5
|
+
registryMs: number;
|
|
6
|
+
cacheMs: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function createSummary(input: {
|
|
9
|
+
scannedPackages: number;
|
|
10
|
+
totalDependencies: number;
|
|
11
|
+
checkedDependencies: number;
|
|
12
|
+
updatesFound: number;
|
|
13
|
+
upgraded: number;
|
|
14
|
+
skipped: number;
|
|
15
|
+
warmedPackages: number;
|
|
16
|
+
errors: string[];
|
|
17
|
+
warnings: string[];
|
|
18
|
+
durations: DurationInput;
|
|
19
|
+
}): Summary;
|
|
20
|
+
export declare function finalizeSummary(summary: Summary): Summary;
|
|
21
|
+
export declare function resolveFailReason(updates: PackageUpdate[], errors: string[], failOn: FailOnLevel | undefined, maxUpdates: number | undefined, ciMode: boolean): FailReason;
|
|
22
|
+
export declare function shouldFailForUpdates(updates: PackageUpdate[], failOn: FailOnLevel): boolean;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function createSummary(input) {
|
|
2
|
+
const offlineCacheMiss = input.errors.filter((error) => isOfflineCacheMissError(error)).length;
|
|
3
|
+
const registryFailure = input.errors.filter((error) => isRegistryFailureError(error)).length;
|
|
4
|
+
const staleCache = input.warnings.filter((warning) => warning.includes("Using stale cache")).length;
|
|
5
|
+
return {
|
|
6
|
+
contractVersion: "2",
|
|
7
|
+
scannedPackages: input.scannedPackages,
|
|
8
|
+
totalDependencies: input.totalDependencies,
|
|
9
|
+
checkedDependencies: input.checkedDependencies,
|
|
10
|
+
updatesFound: input.updatesFound,
|
|
11
|
+
upgraded: input.upgraded,
|
|
12
|
+
skipped: input.skipped,
|
|
13
|
+
warmedPackages: input.warmedPackages,
|
|
14
|
+
failReason: "none",
|
|
15
|
+
errorCounts: {
|
|
16
|
+
total: input.errors.length,
|
|
17
|
+
offlineCacheMiss,
|
|
18
|
+
registryFailure,
|
|
19
|
+
other: 0,
|
|
20
|
+
},
|
|
21
|
+
warningCounts: {
|
|
22
|
+
total: input.warnings.length,
|
|
23
|
+
staleCache,
|
|
24
|
+
other: 0,
|
|
25
|
+
},
|
|
26
|
+
durationMs: {
|
|
27
|
+
total: Math.max(0, Math.round(input.durations.totalMs)),
|
|
28
|
+
discovery: Math.max(0, Math.round(input.durations.discoveryMs)),
|
|
29
|
+
registry: Math.max(0, Math.round(input.durations.registryMs)),
|
|
30
|
+
cache: Math.max(0, Math.round(input.durations.cacheMs)),
|
|
31
|
+
render: 0,
|
|
32
|
+
},
|
|
33
|
+
fixPrApplied: false,
|
|
34
|
+
fixBranchName: "",
|
|
35
|
+
fixCommitSha: "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function finalizeSummary(summary) {
|
|
39
|
+
const errorOther = summary.errorCounts.total - summary.errorCounts.offlineCacheMiss - summary.errorCounts.registryFailure;
|
|
40
|
+
const warningOther = summary.warningCounts.total - summary.warningCounts.staleCache;
|
|
41
|
+
summary.errorCounts.other = Math.max(0, errorOther);
|
|
42
|
+
summary.warningCounts.other = Math.max(0, warningOther);
|
|
43
|
+
return summary;
|
|
44
|
+
}
|
|
45
|
+
export function resolveFailReason(updates, errors, failOn, maxUpdates, ciMode) {
|
|
46
|
+
if (errors.some((error) => isOfflineCacheMissError(error))) {
|
|
47
|
+
return "offline-cache-miss";
|
|
48
|
+
}
|
|
49
|
+
if (errors.some((error) => isRegistryFailureError(error))) {
|
|
50
|
+
return "registry-failure";
|
|
51
|
+
}
|
|
52
|
+
if (typeof maxUpdates === "number" && updates.length > maxUpdates) {
|
|
53
|
+
return "updates-threshold";
|
|
54
|
+
}
|
|
55
|
+
const effectiveFailOn = failOn && failOn !== "none" ? failOn : ciMode ? "any" : "none";
|
|
56
|
+
if (shouldFailForUpdates(updates, effectiveFailOn)) {
|
|
57
|
+
return "severity-threshold";
|
|
58
|
+
}
|
|
59
|
+
return "none";
|
|
60
|
+
}
|
|
61
|
+
export function shouldFailForUpdates(updates, failOn) {
|
|
62
|
+
if (failOn === "none")
|
|
63
|
+
return false;
|
|
64
|
+
if (failOn === "any" || failOn === "patch")
|
|
65
|
+
return updates.length > 0;
|
|
66
|
+
if (failOn === "minor")
|
|
67
|
+
return updates.some((update) => update.diffType === "minor" || update.diffType === "major");
|
|
68
|
+
return updates.some((update) => update.diffType === "major");
|
|
69
|
+
}
|
|
70
|
+
function isOfflineCacheMissError(value) {
|
|
71
|
+
return value.includes("Offline cache miss");
|
|
72
|
+
}
|
|
73
|
+
function isRegistryFailureError(value) {
|
|
74
|
+
return (value.includes("Unable to resolve") ||
|
|
75
|
+
value.includes("Unable to warm") ||
|
|
76
|
+
value.includes("Registry request failed") ||
|
|
77
|
+
value.includes("Registry temporary error"));
|
|
78
|
+
}
|
package/dist/core/warm-cache.js
CHANGED
|
@@ -4,13 +4,23 @@ import { VersionCache } from "../cache/cache.js";
|
|
|
4
4
|
import { NpmRegistryClient } from "../registry/npm.js";
|
|
5
5
|
import { detectPackageManager } from "../pm/detect.js";
|
|
6
6
|
import { discoverPackageDirs } from "../workspace/discover.js";
|
|
7
|
+
import { createSummary, finalizeSummary } from "./summary.js";
|
|
7
8
|
export async function warmCache(options) {
|
|
9
|
+
const startedAt = Date.now();
|
|
10
|
+
let discoveryMs = 0;
|
|
11
|
+
let cacheMs = 0;
|
|
12
|
+
let registryMs = 0;
|
|
13
|
+
const discoveryStartedAt = Date.now();
|
|
8
14
|
const packageManager = await detectPackageManager(options.cwd);
|
|
9
15
|
const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
|
|
16
|
+
discoveryMs += Date.now() - discoveryStartedAt;
|
|
10
17
|
const cache = await VersionCache.create();
|
|
11
18
|
const registryClient = new NpmRegistryClient(options.cwd);
|
|
12
19
|
const errors = [];
|
|
13
20
|
const warnings = [];
|
|
21
|
+
if (cache.degraded) {
|
|
22
|
+
warnings.push("SQLite cache backend unavailable in Bun runtime. Falling back to file cache backend.");
|
|
23
|
+
}
|
|
14
24
|
let totalDependencies = 0;
|
|
15
25
|
const packageNames = new Set();
|
|
16
26
|
for (const packageDir of packageDirs) {
|
|
@@ -30,16 +40,19 @@ export async function warmCache(options) {
|
|
|
30
40
|
errors.push(`Failed to read package.json in ${packageDir}: ${String(error)}`);
|
|
31
41
|
}
|
|
32
42
|
}
|
|
33
|
-
const names = Array.from(packageNames);
|
|
43
|
+
const names = Array.from(packageNames).sort((a, b) => a.localeCompare(b));
|
|
34
44
|
const needsFetch = [];
|
|
45
|
+
const cacheLookupStartedAt = Date.now();
|
|
35
46
|
for (const pkg of names) {
|
|
36
47
|
const valid = await cache.getValid(pkg, options.target);
|
|
37
48
|
if (!valid)
|
|
38
49
|
needsFetch.push(pkg);
|
|
39
50
|
}
|
|
51
|
+
cacheMs += Date.now() - cacheLookupStartedAt;
|
|
40
52
|
let warmed = 0;
|
|
41
53
|
if (needsFetch.length > 0) {
|
|
42
54
|
if (options.offline) {
|
|
55
|
+
const cacheFallbackStartedAt = Date.now();
|
|
43
56
|
for (const pkg of needsFetch) {
|
|
44
57
|
const stale = await cache.getAny(pkg, options.target);
|
|
45
58
|
if (stale) {
|
|
@@ -50,23 +63,30 @@ export async function warmCache(options) {
|
|
|
50
63
|
errors.push(`Offline cache miss for ${pkg}. Cannot warm cache in --offline mode.`);
|
|
51
64
|
}
|
|
52
65
|
}
|
|
66
|
+
cacheMs += Date.now() - cacheFallbackStartedAt;
|
|
53
67
|
}
|
|
54
68
|
else {
|
|
69
|
+
const registryStartedAt = Date.now();
|
|
55
70
|
const fetched = await registryClient.resolveManyPackageMetadata(needsFetch, {
|
|
56
71
|
concurrency: options.concurrency,
|
|
57
72
|
});
|
|
73
|
+
registryMs += Date.now() - registryStartedAt;
|
|
74
|
+
const cacheWriteStartedAt = Date.now();
|
|
58
75
|
for (const [pkg, metadata] of fetched.metadata) {
|
|
59
76
|
if (metadata.latestVersion) {
|
|
60
77
|
await cache.set(pkg, options.target, metadata.latestVersion, metadata.versions, options.cacheTtlSeconds);
|
|
61
78
|
warmed += 1;
|
|
62
79
|
}
|
|
63
80
|
}
|
|
81
|
+
cacheMs += Date.now() - cacheWriteStartedAt;
|
|
64
82
|
for (const [pkg, error] of fetched.errors) {
|
|
65
83
|
errors.push(`Unable to warm ${pkg}: ${error}`);
|
|
66
84
|
}
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
|
-
const
|
|
87
|
+
const sortedErrors = [...errors].sort((a, b) => a.localeCompare(b));
|
|
88
|
+
const sortedWarnings = [...warnings].sort((a, b) => a.localeCompare(b));
|
|
89
|
+
const summary = finalizeSummary(createSummary({
|
|
70
90
|
scannedPackages: packageDirs.length,
|
|
71
91
|
totalDependencies,
|
|
72
92
|
checkedDependencies: names.length,
|
|
@@ -74,7 +94,15 @@ export async function warmCache(options) {
|
|
|
74
94
|
upgraded: 0,
|
|
75
95
|
skipped: 0,
|
|
76
96
|
warmedPackages: warmed,
|
|
77
|
-
|
|
97
|
+
errors: sortedErrors,
|
|
98
|
+
warnings: sortedWarnings,
|
|
99
|
+
durations: {
|
|
100
|
+
totalMs: Date.now() - startedAt,
|
|
101
|
+
discoveryMs,
|
|
102
|
+
registryMs,
|
|
103
|
+
cacheMs,
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
78
106
|
return {
|
|
79
107
|
projectPath: options.cwd,
|
|
80
108
|
packagePaths: packageDirs,
|
|
@@ -83,7 +111,7 @@ export async function warmCache(options) {
|
|
|
83
111
|
timestamp: new Date().toISOString(),
|
|
84
112
|
summary,
|
|
85
113
|
updates: [],
|
|
86
|
-
errors,
|
|
87
|
-
warnings,
|
|
114
|
+
errors: sortedErrors,
|
|
115
|
+
warnings: sortedWarnings,
|
|
88
116
|
};
|
|
89
117
|
}
|
package/dist/output/format.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { renderGitHubAnnotations } from "./github.js";
|
|
2
|
+
import { stableStringify } from "../utils/stable-json.js";
|
|
2
3
|
export function renderResult(result, format) {
|
|
3
4
|
if (format === "json") {
|
|
4
|
-
return
|
|
5
|
+
return stableStringify(result, 2);
|
|
5
6
|
}
|
|
6
7
|
if (format === "minimal") {
|
|
7
8
|
if (result.updates.length === 0 && result.summary.warmedPackages > 0) {
|
|
@@ -16,6 +17,23 @@ export function renderResult(result, format) {
|
|
|
16
17
|
if (format === "github") {
|
|
17
18
|
return renderGitHubAnnotations(result);
|
|
18
19
|
}
|
|
20
|
+
if (format === "metrics") {
|
|
21
|
+
return [
|
|
22
|
+
`contract_version=${result.summary.contractVersion}`,
|
|
23
|
+
`updates_found=${result.summary.updatesFound}`,
|
|
24
|
+
`errors_count=${result.summary.errorCounts.total}`,
|
|
25
|
+
`warnings_count=${result.summary.warningCounts.total}`,
|
|
26
|
+
`checked_dependencies=${result.summary.checkedDependencies}`,
|
|
27
|
+
`scanned_packages=${result.summary.scannedPackages}`,
|
|
28
|
+
`warmed_packages=${result.summary.warmedPackages}`,
|
|
29
|
+
`fail_reason=${result.summary.failReason}`,
|
|
30
|
+
`duration_total_ms=${result.summary.durationMs.total}`,
|
|
31
|
+
`duration_discovery_ms=${result.summary.durationMs.discovery}`,
|
|
32
|
+
`duration_registry_ms=${result.summary.durationMs.registry}`,
|
|
33
|
+
`duration_cache_ms=${result.summary.durationMs.cache}`,
|
|
34
|
+
`duration_render_ms=${result.summary.durationMs.render}`,
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
19
37
|
const lines = [];
|
|
20
38
|
lines.push(`Project: ${result.projectPath}`);
|
|
21
39
|
lines.push(`Scanned packages: ${result.summary.scannedPackages}`);
|
|
@@ -52,6 +70,7 @@ export function renderResult(result, format) {
|
|
|
52
70
|
}
|
|
53
71
|
lines.push("");
|
|
54
72
|
lines.push(`Summary: ${result.summary.updatesFound} updates, ${result.summary.checkedDependencies}/${result.summary.totalDependencies} checked, ${result.summary.warmedPackages} warmed`);
|
|
73
|
+
lines.push(`Contract v${result.summary.contractVersion}, failReason=${result.summary.failReason}, duration=${result.summary.durationMs.total}ms`);
|
|
55
74
|
if (result.summary.fixPrApplied) {
|
|
56
75
|
lines.push(`Fix PR: applied on branch ${result.summary.fixBranchName ?? "unknown"} (${result.summary.fixCommitSha ?? "no-commit"})`);
|
|
57
76
|
}
|
package/dist/output/github.js
CHANGED
|
@@ -1,29 +1,40 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import path from "node:path";
|
|
1
|
+
import { writeFileAtomic } from "../utils/io.js";
|
|
3
2
|
export async function writeGitHubOutput(filePath, result) {
|
|
4
3
|
const lines = [
|
|
4
|
+
`contract_version=${result.summary.contractVersion}`,
|
|
5
5
|
`updates_found=${result.summary.updatesFound}`,
|
|
6
|
-
`errors_count=${result.
|
|
7
|
-
`warnings_count=${result.
|
|
6
|
+
`errors_count=${result.summary.errorCounts.total}`,
|
|
7
|
+
`warnings_count=${result.summary.warningCounts.total}`,
|
|
8
8
|
`checked_dependencies=${result.summary.checkedDependencies}`,
|
|
9
9
|
`scanned_packages=${result.summary.scannedPackages}`,
|
|
10
10
|
`warmed_packages=${result.summary.warmedPackages}`,
|
|
11
|
+
`fail_reason=${result.summary.failReason}`,
|
|
12
|
+
`duration_total_ms=${result.summary.durationMs.total}`,
|
|
13
|
+
`duration_discovery_ms=${result.summary.durationMs.discovery}`,
|
|
14
|
+
`duration_registry_ms=${result.summary.durationMs.registry}`,
|
|
15
|
+
`duration_cache_ms=${result.summary.durationMs.cache}`,
|
|
16
|
+
`duration_render_ms=${result.summary.durationMs.render}`,
|
|
11
17
|
`fix_pr_applied=${result.summary.fixPrApplied === true ? "1" : "0"}`,
|
|
12
18
|
`fix_pr_branch=${result.summary.fixBranchName ?? ""}`,
|
|
13
19
|
`fix_pr_commit=${result.summary.fixCommitSha ?? ""}`,
|
|
14
20
|
];
|
|
15
|
-
await
|
|
16
|
-
await fs.writeFile(filePath, lines.join("\n") + "\n", "utf8");
|
|
21
|
+
await writeFileAtomic(filePath, lines.join("\n") + "\n");
|
|
17
22
|
}
|
|
18
23
|
export function renderGitHubAnnotations(result) {
|
|
19
24
|
const lines = [];
|
|
20
|
-
|
|
25
|
+
const sortedUpdates = [...result.updates].sort((left, right) => {
|
|
26
|
+
const byName = left.name.localeCompare(right.name);
|
|
27
|
+
if (byName !== 0)
|
|
28
|
+
return byName;
|
|
29
|
+
return left.packagePath.localeCompare(right.packagePath);
|
|
30
|
+
});
|
|
31
|
+
for (const update of sortedUpdates) {
|
|
21
32
|
lines.push(`::notice title=Dependency Update::${update.name} ${update.fromRange} -> ${update.toRange} (${update.packagePath})`);
|
|
22
33
|
}
|
|
23
|
-
for (const warning of result.warnings) {
|
|
34
|
+
for (const warning of [...result.warnings].sort((a, b) => a.localeCompare(b))) {
|
|
24
35
|
lines.push(`::warning title=Rainy Updates::${warning}`);
|
|
25
36
|
}
|
|
26
|
-
for (const error of result.errors) {
|
|
37
|
+
for (const error of [...result.errors].sort((a, b) => a.localeCompare(b))) {
|
|
27
38
|
lines.push(`::error title=Rainy Updates::${error}`);
|
|
28
39
|
}
|
|
29
40
|
if (lines.length === 0) {
|
package/dist/output/sarif.js
CHANGED
|
@@ -4,7 +4,13 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
export function createSarifReport(result) {
|
|
5
5
|
const dependencyRuleId = "rainy-updates/dependency-update";
|
|
6
6
|
const runtimeRuleId = "rainy-updates/runtime-error";
|
|
7
|
-
const
|
|
7
|
+
const sortedUpdates = [...result.updates].sort((left, right) => {
|
|
8
|
+
const byName = left.name.localeCompare(right.name);
|
|
9
|
+
if (byName !== 0)
|
|
10
|
+
return byName;
|
|
11
|
+
return left.packagePath.localeCompare(right.packagePath);
|
|
12
|
+
});
|
|
13
|
+
const updateResults = sortedUpdates.map((update) => ({
|
|
8
14
|
ruleId: dependencyRuleId,
|
|
9
15
|
level: "warning",
|
|
10
16
|
message: {
|
|
@@ -26,7 +32,7 @@ export function createSarifReport(result) {
|
|
|
26
32
|
resolvedVersion: update.toVersionResolved,
|
|
27
33
|
},
|
|
28
34
|
}));
|
|
29
|
-
const errorResults = result.errors.map((error) => ({
|
|
35
|
+
const errorResults = [...result.errors].sort((a, b) => a.localeCompare(b)).map((error) => ({
|
|
30
36
|
ruleId: runtimeRuleId,
|
|
31
37
|
level: "error",
|
|
32
38
|
message: {
|
|
@@ -57,6 +63,14 @@ export function createSarifReport(result) {
|
|
|
57
63
|
},
|
|
58
64
|
},
|
|
59
65
|
results: [...updateResults, ...errorResults],
|
|
66
|
+
properties: {
|
|
67
|
+
contractVersion: result.summary.contractVersion,
|
|
68
|
+
failReason: result.summary.failReason,
|
|
69
|
+
updatesFound: result.summary.updatesFound,
|
|
70
|
+
errorsCount: result.summary.errorCounts.total,
|
|
71
|
+
warningsCount: result.summary.warningCounts.total,
|
|
72
|
+
durationMs: result.summary.durationMs,
|
|
73
|
+
},
|
|
60
74
|
},
|
|
61
75
|
],
|
|
62
76
|
};
|
package/dist/registry/npm.js
CHANGED
|
@@ -21,7 +21,7 @@ export class NpmRegistryClient {
|
|
|
21
21
|
return { latestVersion: null, versions: [] };
|
|
22
22
|
}
|
|
23
23
|
if (response.status === 429 || response.status >= 500) {
|
|
24
|
-
throw new
|
|
24
|
+
throw new RetryableRegistryError(`Registry temporary error: ${response.status}`, response.retryAfterMs ?? computeBackoffMs(attempt));
|
|
25
25
|
}
|
|
26
26
|
if (response.status < 200 || response.status >= 300) {
|
|
27
27
|
throw new Error(`Registry request failed: ${response.status}`);
|
|
@@ -32,7 +32,8 @@ export class NpmRegistryClient {
|
|
|
32
32
|
catch (error) {
|
|
33
33
|
lastError = String(error);
|
|
34
34
|
if (attempt < 3) {
|
|
35
|
-
|
|
35
|
+
const backoffMs = error instanceof RetryableRegistryError ? error.waitMs : computeBackoffMs(attempt);
|
|
36
|
+
await sleep(backoffMs);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
}
|
|
@@ -84,6 +85,18 @@ export class NpmRegistryClient {
|
|
|
84
85
|
function sleep(ms) {
|
|
85
86
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
86
87
|
}
|
|
88
|
+
function computeBackoffMs(attempt) {
|
|
89
|
+
const baseMs = Math.max(120, attempt * 180);
|
|
90
|
+
const jitterMs = Math.floor(Math.random() * 120);
|
|
91
|
+
return baseMs + jitterMs;
|
|
92
|
+
}
|
|
93
|
+
class RetryableRegistryError extends Error {
|
|
94
|
+
waitMs;
|
|
95
|
+
constructor(message, waitMs) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.waitMs = waitMs;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
87
100
|
async function createRequester(cwd) {
|
|
88
101
|
const registryConfig = await loadRegistryConfig(cwd ?? process.cwd());
|
|
89
102
|
const undiciRequester = await tryCreateUndiciRequester(registryConfig);
|
|
@@ -103,7 +116,11 @@ async function createRequester(cwd) {
|
|
|
103
116
|
signal: controller.signal,
|
|
104
117
|
});
|
|
105
118
|
const data = (await response.json().catch(() => null));
|
|
106
|
-
return {
|
|
119
|
+
return {
|
|
120
|
+
status: response.status,
|
|
121
|
+
data,
|
|
122
|
+
retryAfterMs: parseRetryAfterHeader(response.headers.get("retry-after")),
|
|
123
|
+
};
|
|
107
124
|
}
|
|
108
125
|
finally {
|
|
109
126
|
clearTimeout(timeout);
|
|
@@ -136,7 +153,19 @@ async function tryCreateUndiciRequester(registryConfig) {
|
|
|
136
153
|
catch {
|
|
137
154
|
data = null;
|
|
138
155
|
}
|
|
139
|
-
|
|
156
|
+
const retryAfter = (() => {
|
|
157
|
+
const header = res.headers["retry-after"];
|
|
158
|
+
if (Array.isArray(header))
|
|
159
|
+
return header[0] ?? null;
|
|
160
|
+
if (typeof header === "string")
|
|
161
|
+
return header;
|
|
162
|
+
return null;
|
|
163
|
+
})();
|
|
164
|
+
return {
|
|
165
|
+
status: res.statusCode,
|
|
166
|
+
data,
|
|
167
|
+
retryAfterMs: parseRetryAfterHeader(retryAfter),
|
|
168
|
+
};
|
|
140
169
|
}
|
|
141
170
|
finally {
|
|
142
171
|
clearTimeout(timeout);
|
|
@@ -218,3 +247,18 @@ function buildRegistryUrl(registry, packageName) {
|
|
|
218
247
|
const base = normalizeRegistryUrl(registry);
|
|
219
248
|
return new URL(encodeURIComponent(packageName), base).toString();
|
|
220
249
|
}
|
|
250
|
+
function parseRetryAfterHeader(value) {
|
|
251
|
+
if (!value)
|
|
252
|
+
return null;
|
|
253
|
+
const parsedSeconds = Number(value);
|
|
254
|
+
if (Number.isFinite(parsedSeconds) && parsedSeconds >= 0) {
|
|
255
|
+
return Math.round(parsedSeconds * 1000);
|
|
256
|
+
}
|
|
257
|
+
const untilMs = Date.parse(value);
|
|
258
|
+
if (!Number.isFinite(untilMs))
|
|
259
|
+
return null;
|
|
260
|
+
const delta = untilMs - Date.now();
|
|
261
|
+
if (delta <= 0)
|
|
262
|
+
return 0;
|
|
263
|
+
return delta;
|
|
264
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export type DependencyKind = "dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies";
|
|
2
2
|
export type TargetLevel = "patch" | "minor" | "major" | "latest";
|
|
3
|
-
export type OutputFormat = "table" | "json" | "minimal" | "github";
|
|
3
|
+
export type OutputFormat = "table" | "json" | "minimal" | "github" | "metrics";
|
|
4
4
|
export type FailOnLevel = "none" | "patch" | "minor" | "major" | "any";
|
|
5
|
+
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
6
|
+
export type FailReason = "none" | "updates-threshold" | "severity-threshold" | "registry-failure" | "offline-cache-miss" | "policy-blocked";
|
|
5
7
|
export interface RunOptions {
|
|
6
8
|
cwd: string;
|
|
7
9
|
target: TargetLevel;
|
|
@@ -25,7 +27,9 @@ export interface RunOptions {
|
|
|
25
27
|
fixBranch?: string;
|
|
26
28
|
fixCommitMessage?: string;
|
|
27
29
|
fixDryRun?: boolean;
|
|
30
|
+
fixPrNoCheckout?: boolean;
|
|
28
31
|
noPrReport?: boolean;
|
|
32
|
+
logLevel: LogLevel;
|
|
29
33
|
}
|
|
30
34
|
export interface CheckOptions extends RunOptions {
|
|
31
35
|
}
|
|
@@ -58,6 +62,7 @@ export interface PackageUpdate {
|
|
|
58
62
|
reason?: string;
|
|
59
63
|
}
|
|
60
64
|
export interface Summary {
|
|
65
|
+
contractVersion: "2";
|
|
61
66
|
scannedPackages: number;
|
|
62
67
|
totalDependencies: number;
|
|
63
68
|
checkedDependencies: number;
|
|
@@ -65,9 +70,28 @@ export interface Summary {
|
|
|
65
70
|
upgraded: number;
|
|
66
71
|
skipped: number;
|
|
67
72
|
warmedPackages: number;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
failReason: FailReason;
|
|
74
|
+
errorCounts: {
|
|
75
|
+
total: number;
|
|
76
|
+
offlineCacheMiss: number;
|
|
77
|
+
registryFailure: number;
|
|
78
|
+
other: number;
|
|
79
|
+
};
|
|
80
|
+
warningCounts: {
|
|
81
|
+
total: number;
|
|
82
|
+
staleCache: number;
|
|
83
|
+
other: number;
|
|
84
|
+
};
|
|
85
|
+
durationMs: {
|
|
86
|
+
total: number;
|
|
87
|
+
discovery: number;
|
|
88
|
+
registry: number;
|
|
89
|
+
cache: number;
|
|
90
|
+
render: number;
|
|
91
|
+
};
|
|
92
|
+
fixPrApplied: boolean;
|
|
93
|
+
fixBranchName: string;
|
|
94
|
+
fixCommitSha: string;
|
|
71
95
|
}
|
|
72
96
|
export interface CheckResult {
|
|
73
97
|
projectPath: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeFileAtomic(filePath: string, content: string): Promise<void>;
|
package/dist/utils/io.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
export async function writeFileAtomic(filePath, content) {
|
|
5
|
+
const dir = path.dirname(filePath);
|
|
6
|
+
await fs.mkdir(dir, { recursive: true });
|
|
7
|
+
const tempPath = path.join(dir, `.tmp-${path.basename(filePath)}-${crypto.randomUUID()}`);
|
|
8
|
+
await fs.writeFile(tempPath, content, "utf8");
|
|
9
|
+
await fs.rename(tempPath, filePath);
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stableStringify(value: unknown, indent?: number): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function sortValue(value) {
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
return value.map((item) => sortValue(item));
|
|
7
|
+
}
|
|
8
|
+
if (!isPlainObject(value)) {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
const sorted = {};
|
|
12
|
+
const keys = Object.keys(value).sort((a, b) => a.localeCompare(b));
|
|
13
|
+
for (const key of keys) {
|
|
14
|
+
sorted[key] = sortValue(value[key]);
|
|
15
|
+
}
|
|
16
|
+
return sorted;
|
|
17
|
+
}
|
|
18
|
+
export function stableStringify(value, indent = 2) {
|
|
19
|
+
return JSON.stringify(sortValue(value), null, indent);
|
|
20
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
const HARD_IGNORE_DIRS = new Set(["node_modules", ".git", ".turbo", ".next", "dist", "coverage"]);
|
|
4
|
+
const MAX_DISCOVERED_DIRS = 20000;
|
|
3
5
|
export async function discoverPackageDirs(cwd, workspaceMode) {
|
|
4
6
|
if (!workspaceMode) {
|
|
5
7
|
return [cwd];
|
|
@@ -84,6 +86,9 @@ async function expandWorkspacePattern(cwd, pattern) {
|
|
|
84
86
|
return Array.from(results);
|
|
85
87
|
}
|
|
86
88
|
async function collectMatches(baseDir, segments, index, out) {
|
|
89
|
+
if (out.size > MAX_DISCOVERED_DIRS) {
|
|
90
|
+
throw new Error(`Workspace discovery exceeded ${MAX_DISCOVERED_DIRS} directories. Refine workspace patterns.`);
|
|
91
|
+
}
|
|
87
92
|
if (index >= segments.length) {
|
|
88
93
|
out.add(baseDir);
|
|
89
94
|
return;
|
|
@@ -111,7 +116,8 @@ async function readChildDirs(dir) {
|
|
|
111
116
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
112
117
|
return entries
|
|
113
118
|
.filter((entry) => entry.isDirectory())
|
|
114
|
-
.filter((entry) =>
|
|
119
|
+
.filter((entry) => !HARD_IGNORE_DIRS.has(entry.name))
|
|
120
|
+
.filter((entry) => !entry.name.startsWith("."))
|
|
115
121
|
.map((entry) => path.join(dir, entry.name));
|
|
116
122
|
}
|
|
117
123
|
catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rainy-updates/cli",
|
|
3
|
-
"version": "0.5.0
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Agentic CLI to check and upgrade npm/pnpm dependencies for CI workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"test": "bun test",
|
|
48
48
|
"lint": "bunx biome check src tests",
|
|
49
49
|
"check": "bun run typecheck && bun test",
|
|
50
|
+
"perf:smoke": "node scripts/perf-smoke.mjs",
|
|
50
51
|
"test:prod": "node dist/bin/cli.js --help && node dist/bin/cli.js --version",
|
|
51
52
|
"prepublishOnly": "bun run check && bun run build && bun run test:prod"
|
|
52
53
|
},
|