hermex 2.0.2 ā 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +97 -54
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +6 -5
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command, Option } from "commander";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import Table from "cli-table3";
|
|
5
|
-
import fs, { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import fs, { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
import path, { dirname, join, resolve } from "node:path";
|
|
8
8
|
import { z } from "zod";
|
|
@@ -158,7 +158,7 @@ function printComponentsChart(components) {
|
|
|
158
158
|
//#endregion
|
|
159
159
|
//#region src/utils/print-patterns.ts
|
|
160
160
|
function printHeader$1() {
|
|
161
|
-
console.log(chalk.blue.bold("\n
|
|
161
|
+
console.log(chalk.blue.bold("\nš§© Code Patterns\n"));
|
|
162
162
|
}
|
|
163
163
|
function printPatterns(aggregated, mode) {
|
|
164
164
|
const patterns = aggregated.patternCounts.filter((p) => p.count > 0);
|
|
@@ -233,10 +233,14 @@ function resolveColorLevel(opts) {
|
|
|
233
233
|
if (opts.noColorEnv !== void 0) return 0;
|
|
234
234
|
}
|
|
235
235
|
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
236
|
+
/** Removes ANSI color escapes ā for output destinations (files, PR comments) that can't render them. */
|
|
237
|
+
function stripAnsi(text) {
|
|
238
|
+
return text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
239
|
+
}
|
|
236
240
|
function stripAnsiWrites(stream) {
|
|
237
241
|
const originalWrite = stream.write.bind(stream);
|
|
238
242
|
stream.write = ((chunk, ...rest) => {
|
|
239
|
-
return originalWrite(typeof chunk === "string" ? chunk
|
|
243
|
+
return originalWrite(typeof chunk === "string" ? stripAnsi(chunk) : chunk, ...rest);
|
|
240
244
|
});
|
|
241
245
|
}
|
|
242
246
|
/**
|
|
@@ -266,6 +270,10 @@ function formatPackageName(pkg, banned) {
|
|
|
266
270
|
else if (pkg.internal) prefix += severityColor("warn")("[int] ");
|
|
267
271
|
return prefix + pkg.packageName;
|
|
268
272
|
}
|
|
273
|
+
function describeUpgradeTarget(top) {
|
|
274
|
+
const overdue = top.releasedDaysAgo > top.thresholdDays ? "no compliant release available" : formatDaysOverdue(top.breachReleasedDaysAgo, top.thresholdDays);
|
|
275
|
+
return `${top.semverBump} ${top.version} (${overdue})`;
|
|
276
|
+
}
|
|
269
277
|
function formatUpgradeCell(releaseAge) {
|
|
270
278
|
if (!releaseAge) return "";
|
|
271
279
|
const { worstLevel, upgrades, severity, pendingUpgrade } = releaseAge;
|
|
@@ -276,9 +284,9 @@ function formatUpgradeCell(releaseAge) {
|
|
|
276
284
|
const top = upgrades[0];
|
|
277
285
|
if (!top) return severityIcon("success");
|
|
278
286
|
const suffix = severity === "warn" ? chalk.gray(" [not enforced]") : "";
|
|
279
|
-
const
|
|
280
|
-
if (worstLevel === "
|
|
281
|
-
return `${severityIcon("warn")} ${
|
|
287
|
+
const description = describeUpgradeTarget(top);
|
|
288
|
+
if (worstLevel === "major_overdue") return `${severityIcon(severity === "warn" ? "warn" : "error")} ${description}${suffix}`;
|
|
289
|
+
return `${severityIcon("warn")} ${description}${suffix}`;
|
|
282
290
|
}
|
|
283
291
|
function getBannedViolation(pkg, violations) {
|
|
284
292
|
return violations.find((v) => v.packageName === pkg.packageName);
|
|
@@ -296,13 +304,7 @@ function printPackagesTable(packages, violations) {
|
|
|
296
304
|
return;
|
|
297
305
|
}
|
|
298
306
|
const hasReleaseAge = packages.some((p) => p.releaseAge !== void 0);
|
|
299
|
-
const head = [
|
|
300
|
-
"Package",
|
|
301
|
-
"Version",
|
|
302
|
-
"Components",
|
|
303
|
-
"Usage",
|
|
304
|
-
"Percentage"
|
|
305
|
-
];
|
|
307
|
+
const head = ["Package", "Version"];
|
|
306
308
|
if (hasReleaseAge) head.push("Upgrades");
|
|
307
309
|
const table = new Table({
|
|
308
310
|
head,
|
|
@@ -313,20 +315,12 @@ function printPackagesTable(packages, violations) {
|
|
|
313
315
|
});
|
|
314
316
|
packages.forEach((pkg) => {
|
|
315
317
|
const versionCell = pkg.hasVersionConflict ? chalk.yellow(`ā ${pkg.allVersions.join(", ")} (${pkg.allVersions.length} versions ā bundle impact)`) : pkg.version || "N/A";
|
|
316
|
-
const row = [
|
|
317
|
-
formatPackageName(pkg, getBannedViolation(pkg, violations)),
|
|
318
|
-
versionCell,
|
|
319
|
-
formatCount(pkg.componentCount),
|
|
320
|
-
formatCount(pkg.usageCount),
|
|
321
|
-
`${pkg.percentage.toFixed(1)}%`
|
|
322
|
-
];
|
|
318
|
+
const row = [formatPackageName(pkg, getBannedViolation(pkg, violations)), versionCell];
|
|
323
319
|
if (hasReleaseAge) row.push(formatUpgradeCell(pkg.releaseAge));
|
|
324
320
|
table.push(row);
|
|
325
321
|
});
|
|
326
322
|
console.log(table.toString());
|
|
327
|
-
|
|
328
|
-
const totalExternalUsage = packages.reduce((sum, p) => sum + p.usageCount, 0);
|
|
329
|
-
console.log(chalk.gray(`\nTotal: ${formatCount(packages.length)} packages | ${formatCount(totalComponents)} unique components | ${formatCount(totalExternalUsage)} total usages`));
|
|
323
|
+
console.log(chalk.gray(`\nTotal: ${formatCount(packages.length)} packages`));
|
|
330
324
|
}
|
|
331
325
|
function printPackagesChart(packages, violations) {
|
|
332
326
|
printHeader();
|
|
@@ -362,15 +356,14 @@ function printVersusResult(result) {
|
|
|
362
356
|
const bar = renderBar(entry.percentage);
|
|
363
357
|
const pct = chalk.bold(`${entry.percentage.toFixed(1)}%`);
|
|
364
358
|
const usage = chalk.gray(`(${entry.count} usages)`);
|
|
365
|
-
|
|
366
|
-
console.log(` ${name} ${bar} ${pct} ${usage}${components}`);
|
|
359
|
+
console.log(` ${name} ${bar} ${pct} ${usage}`);
|
|
367
360
|
}
|
|
368
361
|
if (result.totalCount === 0) console.log(chalk.gray(" No usage detected for any package in this group."));
|
|
369
362
|
console.log();
|
|
370
363
|
}
|
|
371
364
|
function printVersus(aggregated) {
|
|
372
365
|
if (aggregated.versusResults.length === 0) return;
|
|
373
|
-
console.log(chalk.magentaBright.bold("\nāļø
|
|
366
|
+
console.log(chalk.magentaBright.bold("\nāļø Versus\n"));
|
|
374
367
|
for (const result of aggregated.versusResults) printVersusResult(result);
|
|
375
368
|
}
|
|
376
369
|
//#endregion
|
|
@@ -417,11 +410,11 @@ function printRules(aggregated) {
|
|
|
417
410
|
const hasRuleViolations = ruleViolations.length > 0;
|
|
418
411
|
const hasBannedViolations = bannedPackageViolations.length > 0;
|
|
419
412
|
if (!hasRuleViolations && !hasBannedViolations) {
|
|
420
|
-
console.log(chalk.greenBright.bold(`\n${severityIcon("success")}
|
|
421
|
-
console.log(chalk.gray(" All
|
|
413
|
+
console.log(chalk.greenBright.bold(`\n${severityIcon("success")} Rules\n`));
|
|
414
|
+
console.log(chalk.gray(" All rule checks passed"));
|
|
422
415
|
return;
|
|
423
416
|
}
|
|
424
|
-
console.log(chalk.blueBright.bold("\nš
|
|
417
|
+
console.log(chalk.blueBright.bold("\nš Rules\n"));
|
|
425
418
|
if (hasRuleViolations) for (const v of ruleViolations) console.log(formatViolationLine({
|
|
426
419
|
icon: severityIcon(v.severity),
|
|
427
420
|
label: formatRuleType(v.type),
|
|
@@ -2099,7 +2092,7 @@ function pickNewest(versions) {
|
|
|
2099
2092
|
function upgradeLevel(daysAgo, bump, thresholds) {
|
|
2100
2093
|
const threshold = thresholds[bump];
|
|
2101
2094
|
if (threshold === false || threshold === void 0) return null;
|
|
2102
|
-
if (daysAgo > threshold) return bump === "major" ? "
|
|
2095
|
+
if (daysAgo > threshold) return bump === "major" ? "major_overdue" : "minor_overdue";
|
|
2103
2096
|
return null;
|
|
2104
2097
|
}
|
|
2105
2098
|
function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, distTags, severity) {
|
|
@@ -2145,14 +2138,12 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
|
|
|
2145
2138
|
const latestVersion = distTags?.["latest"];
|
|
2146
2139
|
const latestEntry = latestVersion ? timeMap[latestVersion] : void 0;
|
|
2147
2140
|
const latestReleasedDaysAgo = latestEntry ? daysSince(latestEntry) : void 0;
|
|
2148
|
-
|
|
2149
|
-
if (!hadInWindowCandidate && latestVersion && latestReleasedDaysAgo !== void 0 && !semver.prerelease(latestVersion) && semver.gt(latestVersion, installedVersion)) {
|
|
2141
|
+
if (!(minCompliantVersion !== void 0) && latestVersion && latestReleasedDaysAgo !== void 0 && !semver.prerelease(latestVersion) && semver.gt(latestVersion, installedVersion)) {
|
|
2150
2142
|
minCompliantVersion = latestVersion;
|
|
2151
2143
|
minCompliantReleasedDaysAgo = latestReleasedDaysAgo;
|
|
2152
2144
|
}
|
|
2153
|
-
const compliantByLatestFallback = !hadInWindowCandidate && minCompliantVersion !== void 0;
|
|
2154
2145
|
for (const upgrade of finalUpgrades) if (latestVersion && upgrade.version === latestVersion) upgrade.isLatest = true;
|
|
2155
|
-
const worstLevel =
|
|
2146
|
+
const worstLevel = finalUpgrades.some((u) => u.level === "major_overdue") ? "major_overdue" : finalUpgrades.length > 0 ? "minor_overdue" : null;
|
|
2156
2147
|
let pendingUpgrade;
|
|
2157
2148
|
if (worstLevel === null) for (const [bump, versions] of byBump.entries()) {
|
|
2158
2149
|
const threshold = thresholds[bump];
|
|
@@ -2239,20 +2230,20 @@ function isDeclarationFile(filePath) {
|
|
|
2239
2230
|
*/
|
|
2240
2231
|
async function runPipeline(config, spinner, isJson) {
|
|
2241
2232
|
const lockfileResult = findAndParseLockfile(process.cwd());
|
|
2242
|
-
spinner.succeed(chalk.blue(
|
|
2243
|
-
spinner.start("Finding files...");
|
|
2233
|
+
spinner.succeed(chalk.blue(`Found ${lockfileResult.lockfileType} lockfile (supports: ${lockfileResult.supportedVersions.join(", ")}) - ${Object.keys(lockfileResult.versions).length} packages`));
|
|
2234
|
+
if (spinner.isEnabled) spinner.start("Finding files...");
|
|
2244
2235
|
const files = (await findFiles(config.includes, config.excludes)).filter((f) => !isDeclarationFile(f));
|
|
2245
2236
|
if (files.length === 0) {
|
|
2246
2237
|
spinner.fail(chalk.red(`No files found matching includes: ${config.includes.join(", ")}`));
|
|
2247
2238
|
return null;
|
|
2248
2239
|
}
|
|
2249
|
-
spinner.succeed(chalk.green(`
|
|
2250
|
-
spinner.start("Analyzing files...");
|
|
2240
|
+
spinner.succeed(chalk.green(`Found ${files.length} files`));
|
|
2241
|
+
if (spinner.isEnabled) spinner.start("Analyzing files...");
|
|
2251
2242
|
const reports = [];
|
|
2252
2243
|
const parseErrors = [];
|
|
2253
2244
|
for (let i = 0; i < files.length; i++) {
|
|
2254
2245
|
const file = files[i];
|
|
2255
|
-
spinner.text = `Analyzing files... (${i + 1}/${files.length})`;
|
|
2246
|
+
if (spinner.isEnabled) spinner.text = `Analyzing files... (${i + 1}/${files.length})`;
|
|
2256
2247
|
try {
|
|
2257
2248
|
const report = parseFile(file);
|
|
2258
2249
|
if (report) reports.push(report);
|
|
@@ -2270,10 +2261,10 @@ async function runPipeline(config, spinner, isJson) {
|
|
|
2270
2261
|
const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes, files);
|
|
2271
2262
|
aggregated.ruleViolations = [...aggregated.ruleViolations, ...evaluatorViolations];
|
|
2272
2263
|
if (config.releaseAge.enabled) {
|
|
2273
|
-
spinner.start("Fetching release age from registry...");
|
|
2264
|
+
if (spinner.isEnabled) spinner.start("Fetching release age from registry...");
|
|
2274
2265
|
const { enriched, skipped } = await enrichWithReleaseAge(aggregated.packageDistribution, config.releaseAge);
|
|
2275
2266
|
aggregated.packageDistribution = enriched;
|
|
2276
|
-
spinner.succeed(chalk.blue(
|
|
2267
|
+
spinner.succeed(chalk.blue(`Release age fetched${skipped > 0 ? chalk.gray(` (${skipped} packages skipped ā registry unreachable or not found)`) : ""}`));
|
|
2277
2268
|
}
|
|
2278
2269
|
return aggregated;
|
|
2279
2270
|
}
|
|
@@ -2334,12 +2325,12 @@ function printScanResults(aggregated, config) {
|
|
|
2334
2325
|
//#endregion
|
|
2335
2326
|
//#region src/utils/print-compliance.ts
|
|
2336
2327
|
/**
|
|
2337
|
-
* Prints only the bottom-line verdict ā the
|
|
2328
|
+
* Prints only the bottom-line verdict ā the Rules and Packages sections
|
|
2338
2329
|
* printed above this already itemize every violation (rule and release-age
|
|
2339
2330
|
* alike), so repeating them here would just restate the same š“ rows.
|
|
2340
2331
|
*/
|
|
2341
2332
|
function printComplianceVerdict(result) {
|
|
2342
|
-
const mandatoryCount = result.errorRuleViolations.length + result.errorBannedPackageViolations.length + result.
|
|
2333
|
+
const mandatoryCount = result.errorRuleViolations.length + result.errorBannedPackageViolations.length + result.releaseAgeViolations.length;
|
|
2343
2334
|
if (result.compliant) {
|
|
2344
2335
|
console.log(chalk.greenBright.bold(`\n${severityIcon("success")} COMPLIANT\n`));
|
|
2345
2336
|
return;
|
|
@@ -2351,34 +2342,85 @@ function printComplianceVerdict(result) {
|
|
|
2351
2342
|
//#endregion
|
|
2352
2343
|
//#region src/utils/compliance.ts
|
|
2353
2344
|
/**
|
|
2354
|
-
* A package is a
|
|
2355
|
-
*
|
|
2356
|
-
*
|
|
2357
|
-
*
|
|
2358
|
-
*
|
|
2345
|
+
* A package is a compliance failure when its releaseAge severity is 'error'
|
|
2346
|
+
* (i.e. it's in scope per `releaseAge.enforceOn`) AND it has any breached
|
|
2347
|
+
* threshold at all (worstLevel is non-null) ā both 'minor_overdue' and
|
|
2348
|
+
* 'major_overdue' fail comply for an enforced package; only severity
|
|
2349
|
+
* decides mandatory vs advisory, not which tier breached (#28).
|
|
2359
2350
|
*/
|
|
2360
2351
|
function computeCompliance(aggregated) {
|
|
2361
2352
|
const errorRuleViolations = aggregated.ruleViolations.filter((v) => v.severity === "error");
|
|
2362
2353
|
const errorBannedPackageViolations = aggregated.bannedPackageViolations.filter((v) => v.severity === "error");
|
|
2363
|
-
const
|
|
2354
|
+
const releaseAgeViolations = aggregated.packageDistribution.filter((p) => p.releaseAge?.severity === "error" && p.releaseAge?.worstLevel !== null);
|
|
2364
2355
|
return {
|
|
2365
|
-
compliant: errorRuleViolations.length === 0 && errorBannedPackageViolations.length === 0 &&
|
|
2356
|
+
compliant: errorRuleViolations.length === 0 && errorBannedPackageViolations.length === 0 && releaseAgeViolations.length === 0,
|
|
2366
2357
|
errorRuleViolations,
|
|
2367
2358
|
errorBannedPackageViolations,
|
|
2368
|
-
|
|
2359
|
+
releaseAgeViolations
|
|
2369
2360
|
};
|
|
2370
2361
|
}
|
|
2371
2362
|
//#endregion
|
|
2363
|
+
//#region src/utils/write-summary-file.ts
|
|
2364
|
+
function buildRulesSection(aggregated) {
|
|
2365
|
+
const ruleViolations = aggregated.ruleViolations.filter((v) => v.severity !== "info");
|
|
2366
|
+
const bannedPackageViolations = aggregated.bannedPackageViolations.filter((v) => v.severity !== "info");
|
|
2367
|
+
if (ruleViolations.length === 0 && bannedPackageViolations.length === 0) return "### Rules\n\nAll rule checks passed\n";
|
|
2368
|
+
const lines = ["### Rules", ""];
|
|
2369
|
+
for (const v of ruleViolations) lines.push(`- ${severityIcon(v.severity)} ${formatRuleType(v.type)} ā ${describeViolation(v)}`);
|
|
2370
|
+
for (const v of bannedPackageViolations) {
|
|
2371
|
+
const msg = v.message ? ` ā ${v.message}` : "";
|
|
2372
|
+
lines.push(`- ${severityIcon(v.severity)} forbid_packages ā ${v.packageName} is forbidden${msg}`);
|
|
2373
|
+
}
|
|
2374
|
+
const allViolations = [...ruleViolations, ...bannedPackageViolations];
|
|
2375
|
+
const errorCount = allViolations.filter((v) => v.severity === "error").length;
|
|
2376
|
+
const warnCount = allViolations.filter((v) => v.severity === "warn").length;
|
|
2377
|
+
const parts = [];
|
|
2378
|
+
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
2379
|
+
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
2380
|
+
lines.push("", parts.join(", "));
|
|
2381
|
+
return lines.join("\n") + "\n";
|
|
2382
|
+
}
|
|
2383
|
+
function buildPackagesSection(compliance) {
|
|
2384
|
+
if (compliance.releaseAgeViolations.length === 0) return "";
|
|
2385
|
+
const lines = ["### Packages", ""];
|
|
2386
|
+
for (const pkg of compliance.releaseAgeViolations) {
|
|
2387
|
+
const top = pkg.releaseAge?.upgrades[0];
|
|
2388
|
+
const reasons = [];
|
|
2389
|
+
if (top) reasons.push(describeUpgradeTarget(top));
|
|
2390
|
+
if (pkg.releaseAge?.deprecated) reasons.push("deprecated");
|
|
2391
|
+
lines.push(`- ${severityIcon("error")} ${pkg.packageName} ā ${reasons.join(", ")}`);
|
|
2392
|
+
}
|
|
2393
|
+
return lines.join("\n") + "\n";
|
|
2394
|
+
}
|
|
2395
|
+
function buildVerdictSection(compliance) {
|
|
2396
|
+
if (compliance.compliant) return `### ${severityIcon("success")} COMPLIANT\n`;
|
|
2397
|
+
const mandatoryCount = compliance.errorRuleViolations.length + compliance.errorBannedPackageViolations.length + compliance.releaseAgeViolations.length;
|
|
2398
|
+
return `### ${severityIcon("error")} NOT COMPLIANT\n\n${mandatoryCount} mandatory violation${mandatoryCount > 1 ? "s" : ""} found\n`;
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Writes a concise, ANSI-free markdown summary (rules, mandatory package
|
|
2402
|
+
* violations, verdict) for CI surfaces that can't render the full human
|
|
2403
|
+
* report ā a sticky PR comment or job summary (#31). Omits Versus and
|
|
2404
|
+
* progress chrome by construction; never touches ora or the Versus renderer.
|
|
2405
|
+
*/
|
|
2406
|
+
function writeSummaryFile(path, aggregated, compliance) {
|
|
2407
|
+
writeFileSync(path, stripAnsi([
|
|
2408
|
+
buildRulesSection(aggregated),
|
|
2409
|
+
buildPackagesSection(compliance),
|
|
2410
|
+
buildVerdictSection(compliance)
|
|
2411
|
+
].filter((section) => section.length > 0).join("\n")));
|
|
2412
|
+
}
|
|
2413
|
+
//#endregion
|
|
2372
2414
|
//#region src/commands/comply.ts
|
|
2373
2415
|
function registerComplyCommand(program) {
|
|
2374
|
-
program.command("comply").description("Check compliance with hermex.config.ts rules and release-age policy (exits non-zero if not compliant)").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").addOption(new Option("--format <format>", "Output format, overrides output.format in the config file").choices(["human", "json"])).option("--no-color", "Disable colored output (see also NO_COLOR env var)").action(async (options) => {
|
|
2416
|
+
program.command("comply").description("Check compliance with hermex.config.ts rules and release-age policy (exits non-zero if not compliant)").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").addOption(new Option("--format <format>", "Output format, overrides output.format in the config file").choices(["human", "json"])).option("--no-color", "Disable colored output (see also NO_COLOR env var)").option("--summary-file <path>", "Write a concise, ANSI-free markdown summary (rules, flagged packages, verdict) to this path, for a CI job summary or PR comment").action(async (options) => {
|
|
2375
2417
|
await executeComply(await loadConfig(process.cwd(), options.config), {
|
|
2376
2418
|
format: options.format,
|
|
2377
2419
|
color: options.color
|
|
2378
|
-
});
|
|
2420
|
+
}, options.summaryFile);
|
|
2379
2421
|
});
|
|
2380
2422
|
}
|
|
2381
|
-
async function executeComply(config, contextOptions = {}) {
|
|
2423
|
+
async function executeComply(config, contextOptions = {}, summaryFile) {
|
|
2382
2424
|
const { isJson, spinner } = createCommandContext(config, contextOptions);
|
|
2383
2425
|
try {
|
|
2384
2426
|
const aggregated = await runPipeline(config, spinner, isJson);
|
|
@@ -2394,6 +2436,7 @@ async function executeComply(config, contextOptions = {}) {
|
|
|
2394
2436
|
if (config.output.versus) printVersus(aggregated);
|
|
2395
2437
|
printComplianceVerdict(compliance);
|
|
2396
2438
|
}
|
|
2439
|
+
if (summaryFile) writeSummaryFile(summaryFile, aggregated, compliance);
|
|
2397
2440
|
process.exitCode = compliance.compliant ? 0 : 1;
|
|
2398
2441
|
} catch (error) {
|
|
2399
2442
|
const message = error instanceof Error ? error.message : String(error);
|