@rainy-updates/cli 0.5.0-rc.1 → 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.
@@ -1,10 +1,15 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
1
5
  import { asyncPool } from "../utils/async-pool.js";
2
6
  const DEFAULT_TIMEOUT_MS = 8000;
3
7
  const USER_AGENT = "@rainy-updates/cli";
8
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
4
9
  export class NpmRegistryClient {
5
10
  requesterPromise;
6
- constructor() {
7
- this.requesterPromise = createRequester();
11
+ constructor(cwd) {
12
+ this.requesterPromise = createRequester(cwd);
8
13
  }
9
14
  async resolvePackageMetadata(packageName, timeoutMs = DEFAULT_TIMEOUT_MS) {
10
15
  const requester = await this.requesterPromise;
@@ -16,7 +21,7 @@ export class NpmRegistryClient {
16
21
  return { latestVersion: null, versions: [] };
17
22
  }
18
23
  if (response.status === 429 || response.status >= 500) {
19
- throw new Error(`Registry temporary error: ${response.status}`);
24
+ throw new RetryableRegistryError(`Registry temporary error: ${response.status}`, response.retryAfterMs ?? computeBackoffMs(attempt));
20
25
  }
21
26
  if (response.status < 200 || response.status >= 300) {
22
27
  throw new Error(`Registry request failed: ${response.status}`);
@@ -27,7 +32,8 @@ export class NpmRegistryClient {
27
32
  catch (error) {
28
33
  lastError = String(error);
29
34
  if (attempt < 3) {
30
- await sleep(120 * attempt);
35
+ const backoffMs = error instanceof RetryableRegistryError ? error.waitMs : computeBackoffMs(attempt);
36
+ await sleep(backoffMs);
31
37
  }
32
38
  }
33
39
  }
@@ -79,15 +85,30 @@ export class NpmRegistryClient {
79
85
  function sleep(ms) {
80
86
  return new Promise((resolve) => setTimeout(resolve, ms));
81
87
  }
82
- async function createRequester() {
83
- const undiciRequester = await tryCreateUndiciRequester();
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
+ }
100
+ async function createRequester(cwd) {
101
+ const registryConfig = await loadRegistryConfig(cwd ?? process.cwd());
102
+ const undiciRequester = await tryCreateUndiciRequester(registryConfig);
84
103
  if (undiciRequester)
85
104
  return undiciRequester;
86
105
  return async (packageName, timeoutMs) => {
87
106
  const controller = new AbortController();
88
107
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
108
+ const registry = resolveRegistryForPackage(packageName, registryConfig);
109
+ const url = buildRegistryUrl(registry, packageName);
89
110
  try {
90
- const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
111
+ const response = await fetch(url, {
91
112
  headers: {
92
113
  accept: "application/json",
93
114
  "user-agent": USER_AGENT,
@@ -95,28 +116,28 @@ async function createRequester() {
95
116
  signal: controller.signal,
96
117
  });
97
118
  const data = (await response.json().catch(() => null));
98
- return { status: response.status, data };
119
+ return {
120
+ status: response.status,
121
+ data,
122
+ retryAfterMs: parseRetryAfterHeader(response.headers.get("retry-after")),
123
+ };
99
124
  }
100
125
  finally {
101
126
  clearTimeout(timeout);
102
127
  }
103
128
  };
104
129
  }
105
- async function tryCreateUndiciRequester() {
130
+ async function tryCreateUndiciRequester(registryConfig) {
106
131
  try {
107
132
  const dynamicImport = Function("specifier", "return import(specifier)");
108
133
  const undici = await dynamicImport("undici");
109
- const pool = new undici.Pool("https://registry.npmjs.org", {
110
- connections: 20,
111
- pipelining: 10,
112
- allowH2: true,
113
- });
114
134
  return async (packageName, timeoutMs) => {
115
135
  const controller = new AbortController();
116
136
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
137
+ const registry = resolveRegistryForPackage(packageName, registryConfig);
138
+ const url = buildRegistryUrl(registry, packageName);
117
139
  try {
118
- const res = await pool.request({
119
- path: `/${encodeURIComponent(packageName)}`,
140
+ const res = await undici.request(url, {
120
141
  method: "GET",
121
142
  headers: {
122
143
  accept: "application/json",
@@ -132,7 +153,19 @@ async function tryCreateUndiciRequester() {
132
153
  catch {
133
154
  data = null;
134
155
  }
135
- return { status: res.statusCode, data };
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
+ };
136
169
  }
137
170
  finally {
138
171
  clearTimeout(timeout);
@@ -143,3 +176,89 @@ async function tryCreateUndiciRequester() {
143
176
  return null;
144
177
  }
145
178
  }
179
+ async function loadRegistryConfig(cwd) {
180
+ const homeNpmrc = path.join(os.homedir(), ".npmrc");
181
+ const projectNpmrc = path.join(cwd, ".npmrc");
182
+ const merged = new Map();
183
+ for (const filePath of [homeNpmrc, projectNpmrc]) {
184
+ try {
185
+ const content = await fs.readFile(filePath, "utf8");
186
+ const parsed = parseNpmrc(content);
187
+ for (const [key, value] of parsed) {
188
+ merged.set(key, value);
189
+ }
190
+ }
191
+ catch {
192
+ // ignore missing/unreadable file
193
+ }
194
+ }
195
+ const defaultRegistry = normalizeRegistryUrl(merged.get("registry") ?? DEFAULT_REGISTRY);
196
+ const scopedRegistries = new Map();
197
+ for (const [key, value] of merged) {
198
+ if (!key.startsWith("@") || !key.endsWith(":registry"))
199
+ continue;
200
+ const scope = key.slice(0, key.indexOf(":registry"));
201
+ if (scope.length > 1) {
202
+ scopedRegistries.set(scope, normalizeRegistryUrl(value));
203
+ }
204
+ }
205
+ return { defaultRegistry, scopedRegistries };
206
+ }
207
+ function parseNpmrc(content) {
208
+ const values = new Map();
209
+ const lines = content.split(/\r?\n/);
210
+ for (const line of lines) {
211
+ const trimmed = line.trim();
212
+ if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith(";"))
213
+ continue;
214
+ const separator = trimmed.indexOf("=");
215
+ if (separator <= 0)
216
+ continue;
217
+ const key = trimmed.slice(0, separator).trim();
218
+ const value = trimmed.slice(separator + 1).trim();
219
+ if (key.length > 0 && value.length > 0) {
220
+ values.set(key, value);
221
+ }
222
+ }
223
+ return values;
224
+ }
225
+ function normalizeRegistryUrl(value) {
226
+ const normalized = value.endsWith("/") ? value : `${value}/`;
227
+ return normalized;
228
+ }
229
+ function resolveRegistryForPackage(packageName, config) {
230
+ const scope = extractScope(packageName);
231
+ if (scope) {
232
+ const scoped = config.scopedRegistries.get(scope);
233
+ if (scoped)
234
+ return scoped;
235
+ }
236
+ return config.defaultRegistry;
237
+ }
238
+ function extractScope(packageName) {
239
+ if (!packageName.startsWith("@"))
240
+ return null;
241
+ const firstSlash = packageName.indexOf("/");
242
+ if (firstSlash <= 1)
243
+ return null;
244
+ return packageName.slice(0, firstSlash);
245
+ }
246
+ function buildRegistryUrl(registry, packageName) {
247
+ const base = normalizeRegistryUrl(registry);
248
+ return new URL(encodeURIComponent(packageName), base).toString();
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
+ }
@@ -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;
@@ -21,6 +23,13 @@ export interface RunOptions {
21
23
  prReportFile?: string;
22
24
  failOn?: FailOnLevel;
23
25
  maxUpdates?: number;
26
+ fixPr?: boolean;
27
+ fixBranch?: string;
28
+ fixCommitMessage?: string;
29
+ fixDryRun?: boolean;
30
+ fixPrNoCheckout?: boolean;
31
+ noPrReport?: boolean;
32
+ logLevel: LogLevel;
24
33
  }
25
34
  export interface CheckOptions extends RunOptions {
26
35
  }
@@ -53,6 +62,7 @@ export interface PackageUpdate {
53
62
  reason?: string;
54
63
  }
55
64
  export interface Summary {
65
+ contractVersion: "2";
56
66
  scannedPackages: number;
57
67
  totalDependencies: number;
58
68
  checkedDependencies: number;
@@ -60,6 +70,28 @@ export interface Summary {
60
70
  upgraded: number;
61
71
  skipped: number;
62
72
  warmedPackages: number;
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;
63
95
  }
64
96
  export interface CheckResult {
65
97
  projectPath: string;
@@ -0,0 +1 @@
1
+ export declare function writeFileAtomic(filePath: string, content: string): Promise<void>;
@@ -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,18 +1,27 @@
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];
6
8
  }
7
9
  const roots = new Set([cwd]);
8
- const packagePatterns = await readPackageJsonWorkspacePatterns(cwd);
9
- const pnpmPatterns = await readPnpmWorkspacePatterns(cwd);
10
- for (const pattern of [...packagePatterns, ...pnpmPatterns]) {
11
- const dirs = await expandSingleLevelPattern(cwd, pattern);
10
+ const patterns = [...(await readPackageJsonWorkspacePatterns(cwd)), ...(await readPnpmWorkspacePatterns(cwd))];
11
+ const include = patterns.filter((item) => !item.startsWith("!"));
12
+ const exclude = patterns.filter((item) => item.startsWith("!")).map((item) => item.slice(1));
13
+ for (const pattern of include) {
14
+ const dirs = await expandWorkspacePattern(cwd, pattern);
12
15
  for (const dir of dirs) {
13
16
  roots.add(dir);
14
17
  }
15
18
  }
19
+ for (const pattern of exclude) {
20
+ const dirs = await expandWorkspacePattern(cwd, pattern);
21
+ for (const dir of dirs) {
22
+ roots.delete(dir);
23
+ }
24
+ }
16
25
  const existing = [];
17
26
  for (const dir of roots) {
18
27
  const packageJsonPath = path.join(dir, "package.json");
@@ -21,7 +30,7 @@ export async function discoverPackageDirs(cwd, workspaceMode) {
21
30
  existing.push(dir);
22
31
  }
23
32
  catch {
24
- // ignore
33
+ // ignore missing package.json
25
34
  }
26
35
  }
27
36
  return existing.sort();
@@ -53,7 +62,7 @@ async function readPnpmWorkspacePatterns(cwd) {
53
62
  const trimmed = line.trim();
54
63
  if (!trimmed.startsWith("-"))
55
64
  continue;
56
- const value = trimmed.replace(/^-\s*/, "").replace(/^['\"]|['\"]$/g, "");
65
+ const value = trimmed.replace(/^-\s*/, "").replace(/^['"]|['"]$/g, "");
57
66
  if (value.length > 0) {
58
67
  patterns.push(value);
59
68
  }
@@ -64,23 +73,52 @@ async function readPnpmWorkspacePatterns(cwd) {
64
73
  return [];
65
74
  }
66
75
  }
67
- async function expandSingleLevelPattern(cwd, pattern) {
68
- if (!pattern.includes("*")) {
69
- return [path.resolve(cwd, pattern)];
70
- }
71
- const normalized = pattern.replace(/\\/g, "/");
72
- const starIndex = normalized.indexOf("*");
73
- const basePart = normalized.slice(0, starIndex).replace(/\/$/, "");
74
- const suffix = normalized.slice(starIndex + 1);
75
- if (suffix.length > 0 && suffix !== "/") {
76
+ async function expandWorkspacePattern(cwd, pattern) {
77
+ const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
78
+ if (normalized.length === 0)
76
79
  return [];
80
+ if (!normalized.includes("*")) {
81
+ return [path.resolve(cwd, normalized)];
82
+ }
83
+ const segments = normalized.split("/").filter(Boolean);
84
+ const results = new Set();
85
+ await collectMatches(path.resolve(cwd), segments, 0, results);
86
+ return Array.from(results);
87
+ }
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.`);
77
91
  }
78
- const baseDir = path.resolve(cwd, basePart);
92
+ if (index >= segments.length) {
93
+ out.add(baseDir);
94
+ return;
95
+ }
96
+ const segment = segments[index];
97
+ if (segment === "**") {
98
+ await collectMatches(baseDir, segments, index + 1, out);
99
+ const children = await readChildDirs(baseDir);
100
+ for (const child of children) {
101
+ await collectMatches(child, segments, index, out);
102
+ }
103
+ return;
104
+ }
105
+ if (segment === "*") {
106
+ const children = await readChildDirs(baseDir);
107
+ for (const child of children) {
108
+ await collectMatches(child, segments, index + 1, out);
109
+ }
110
+ return;
111
+ }
112
+ await collectMatches(path.join(baseDir, segment), segments, index + 1, out);
113
+ }
114
+ async function readChildDirs(dir) {
79
115
  try {
80
- const entries = await fs.readdir(baseDir, { withFileTypes: true });
116
+ const entries = await fs.readdir(dir, { withFileTypes: true });
81
117
  return entries
82
118
  .filter((entry) => entry.isDirectory())
83
- .map((entry) => path.join(baseDir, entry.name));
119
+ .filter((entry) => !HARD_IGNORE_DIRS.has(entry.name))
120
+ .filter((entry) => !entry.name.startsWith("."))
121
+ .map((entry) => path.join(dir, entry.name));
84
122
  }
85
123
  catch {
86
124
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainy-updates/cli",
3
- "version": "0.5.0-rc.1",
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
  },