@nerviq/cli 1.10.0 → 1.11.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/README.md +80 -29
- package/bin/cli.js +229 -110
- package/package.json +1 -1
- package/src/activity.js +185 -59
- package/src/aider/freshness.js +28 -25
- package/src/analyze.js +3 -1
- package/src/anti-patterns.js +4 -2
- package/src/audit.js +100 -74
- package/src/benchmark.js +15 -10
- package/src/governance.js +13 -7
- package/src/index.js +2 -1
- package/src/integrations.js +102 -55
- package/src/permission-rules.js +218 -0
- package/src/secret-patterns.js +9 -0
- package/src/server.js +398 -3
- package/src/setup.js +2 -2
- package/src/techniques.js +41 -45
- package/src/terminology.js +73 -0
- package/src/token-estimate.js +35 -0
- package/src/workspace.js +105 -8
package/src/audit.js
CHANGED
|
@@ -24,16 +24,18 @@ const { AiderProjectContext } = require('./aider/context');
|
|
|
24
24
|
const { OPENCODE_TECHNIQUES } = require('./opencode/techniques');
|
|
25
25
|
const { OpenCodeProjectContext } = require('./opencode/context');
|
|
26
26
|
const { getBadgeMarkdown } = require('./badge');
|
|
27
|
-
const { sendInsights, getLocalInsights } = require('./insights');
|
|
28
|
-
const { getRecommendationOutcomeSummary, getRecommendationAdjustment } = require('./activity');
|
|
29
|
-
const { getFeedbackSummary } = require('./feedback');
|
|
30
|
-
const { formatSarif } = require('./formatters/sarif');
|
|
31
|
-
const { formatOtelMetrics } = require('./formatters/otel');
|
|
32
|
-
const {
|
|
33
|
-
const {
|
|
34
|
-
const {
|
|
35
|
-
const {
|
|
36
|
-
const {
|
|
27
|
+
const { sendInsights, getLocalInsights } = require('./insights');
|
|
28
|
+
const { getRecommendationOutcomeSummary, getRecommendationAdjustment } = require('./activity');
|
|
29
|
+
const { getFeedbackSummary } = require('./feedback');
|
|
30
|
+
const { formatSarif } = require('./formatters/sarif');
|
|
31
|
+
const { formatOtelMetrics } = require('./formatters/otel');
|
|
32
|
+
const { collectAuditTerminology, formatTerminologyLines } = require('./terminology');
|
|
33
|
+
const { loadPlugins, mergePluginChecks } = require('./plugins');
|
|
34
|
+
const { hasWorkspaceConfig, detectWorkspaceGlobs, detectWorkspaces } = require('./workspace');
|
|
35
|
+
const { detectDeprecationWarnings } = require('./deprecation');
|
|
36
|
+
const { estimateTokenCount } = require('./token-estimate');
|
|
37
|
+
const { version: packageVersion } = require('../package.json');
|
|
38
|
+
const { t } = require('./i18n');
|
|
37
39
|
|
|
38
40
|
const COLORS = {
|
|
39
41
|
reset: '\x1b[0m',
|
|
@@ -64,8 +66,8 @@ function formatLocation(file, line) {
|
|
|
64
66
|
|
|
65
67
|
const IMPACT_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
66
68
|
const WEIGHTS = { critical: 15, high: 10, medium: 5, low: 2 };
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
+
const LARGE_INSTRUCTION_WARN_TOKENS = 12000;
|
|
70
|
+
const LARGE_INSTRUCTION_SKIP_TOKENS = 240000;
|
|
69
71
|
const CATEGORY_MODULES = {
|
|
70
72
|
memory: 'CLAUDE.md',
|
|
71
73
|
quality: 'verification',
|
|
@@ -358,9 +360,13 @@ function getAuditSpec(platform = 'claude') {
|
|
|
358
360
|
};
|
|
359
361
|
}
|
|
360
362
|
|
|
361
|
-
function normalizeRelativePath(filePath) {
|
|
362
|
-
return String(filePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
363
|
-
}
|
|
363
|
+
function normalizeRelativePath(filePath) {
|
|
364
|
+
return String(filePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function formatCount(value) {
|
|
368
|
+
return Number(value || 0).toLocaleString('en-US');
|
|
369
|
+
}
|
|
364
370
|
|
|
365
371
|
function addPath(target, filePath) {
|
|
366
372
|
if (!filePath || typeof filePath !== 'string') return;
|
|
@@ -454,25 +460,27 @@ function instructionFileCandidates(spec, ctx) {
|
|
|
454
460
|
return [...candidates];
|
|
455
461
|
}
|
|
456
462
|
|
|
457
|
-
function inspectInstructionFiles(spec, ctx) {
|
|
458
|
-
const warnings = [];
|
|
459
|
-
|
|
460
|
-
for (const filePath of instructionFileCandidates(spec, ctx)) {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
463
|
+
function inspectInstructionFiles(spec, ctx) {
|
|
464
|
+
const warnings = [];
|
|
465
|
+
|
|
466
|
+
for (const filePath of instructionFileCandidates(spec, ctx)) {
|
|
467
|
+
const content = typeof ctx.fileContent === 'function' ? ctx.fileContent(filePath) : null;
|
|
468
|
+
const byteCount = typeof ctx.fileSizeBytes === 'function' ? ctx.fileSizeBytes(filePath) : null;
|
|
469
|
+
const tokenCount = typeof content === 'string' ? estimateTokenCount(content) : null;
|
|
470
|
+
if (!Number.isFinite(tokenCount) || tokenCount <= LARGE_INSTRUCTION_WARN_TOKENS) continue;
|
|
471
|
+
|
|
472
|
+
warnings.push({
|
|
473
|
+
file: normalizeRelativePath(filePath),
|
|
474
|
+
byteCount,
|
|
475
|
+
tokenCount,
|
|
476
|
+
lineCount: typeof content === 'string' ? content.split(/\r?\n/).length : null,
|
|
477
|
+
skipped: tokenCount > LARGE_INSTRUCTION_SKIP_TOKENS,
|
|
478
|
+
severity: tokenCount > LARGE_INSTRUCTION_SKIP_TOKENS ? 'critical' : 'warning',
|
|
479
|
+
message: tokenCount > LARGE_INSTRUCTION_SKIP_TOKENS
|
|
480
|
+
? 'Instruction file exceeds ~240,000 tokens and will be skipped during audit.'
|
|
481
|
+
: 'Instruction file exceeds ~12,000 tokens. Audit will continue, but this file may reduce runtime clarity.',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
476
484
|
|
|
477
485
|
return warnings;
|
|
478
486
|
}
|
|
@@ -921,11 +929,11 @@ function printLiteAudit(result, dir) {
|
|
|
921
929
|
console.log(colorize(` - ${item.title}: ${item.message}`, 'dim'));
|
|
922
930
|
});
|
|
923
931
|
}
|
|
924
|
-
if (result.largeInstructionFiles && result.largeInstructionFiles.length > 0) {
|
|
925
|
-
result.largeInstructionFiles.slice(0, 2).forEach((item) => {
|
|
926
|
-
console.log(colorize(` Large file: ${item.file} (
|
|
927
|
-
});
|
|
928
|
-
}
|
|
932
|
+
if (result.largeInstructionFiles && result.largeInstructionFiles.length > 0) {
|
|
933
|
+
result.largeInstructionFiles.slice(0, 2).forEach((item) => {
|
|
934
|
+
console.log(colorize(` Large file: ${item.file} (~${formatCount(item.tokenCount)} tokens)`, 'yellow'));
|
|
935
|
+
});
|
|
936
|
+
}
|
|
929
937
|
console.log('');
|
|
930
938
|
|
|
931
939
|
if (result.failed === 0) {
|
|
@@ -957,15 +965,23 @@ function printLiteAudit(result, dir) {
|
|
|
957
965
|
console.log('');
|
|
958
966
|
let usagePatterns;
|
|
959
967
|
try { usagePatterns = require('./usage-patterns'); } catch { usagePatterns = null; }
|
|
960
|
-
result.liteSummary.topNextActions.forEach((item, index) => {
|
|
961
|
-
const tier = item.impact === 'critical' ? '🔴' : item.impact === 'high' ? '🟡' : '🔵';
|
|
962
|
-
const suppressed = usagePatterns && usagePatterns.getPriorityAdjustment(dir, item.key) === 'suppress';
|
|
963
|
-
const suffix = suppressed ? colorize(' (suppressed)', 'dim') : '';
|
|
964
|
-
console.log(` ${index + 1}. ${tier} ${colorize(item.name, 'bold')}${suffix}`);
|
|
965
|
-
console.log(colorize(` ${item.fix}`, 'dim'));
|
|
966
|
-
});
|
|
967
|
-
console.log('');
|
|
968
|
-
|
|
968
|
+
result.liteSummary.topNextActions.forEach((item, index) => {
|
|
969
|
+
const tier = item.impact === 'critical' ? '🔴' : item.impact === 'high' ? '🟡' : '🔵';
|
|
970
|
+
const suppressed = usagePatterns && usagePatterns.getPriorityAdjustment(dir, item.key) === 'suppress';
|
|
971
|
+
const suffix = suppressed ? colorize(' (suppressed)', 'dim') : '';
|
|
972
|
+
console.log(` ${index + 1}. ${tier} ${colorize(item.name, 'bold')}${suffix}`);
|
|
973
|
+
console.log(colorize(` ${item.fix}`, 'dim'));
|
|
974
|
+
});
|
|
975
|
+
console.log('');
|
|
976
|
+
const liteTerminology = formatTerminologyLines(collectAuditTerminology(result));
|
|
977
|
+
if (liteTerminology.length > 0) {
|
|
978
|
+
liteTerminology.forEach((line) => {
|
|
979
|
+
const color = line.startsWith(' Terms used here:') ? 'blue' : 'dim';
|
|
980
|
+
console.log(colorize(line, color));
|
|
981
|
+
});
|
|
982
|
+
console.log('');
|
|
983
|
+
}
|
|
984
|
+
console.log(` Ready? Run: ${colorize(result.suggestedNextCommand, 'bold')}`);
|
|
969
985
|
if (result.platform === 'codex') {
|
|
970
986
|
console.log(colorize(' Note: Codex now supports no-write advisory flows via augment and suggest-only before setup/apply.', 'dim'));
|
|
971
987
|
}
|
|
@@ -1054,12 +1070,12 @@ async function audit(options) {
|
|
|
1054
1070
|
key: 'largeInstructionFile',
|
|
1055
1071
|
id: null,
|
|
1056
1072
|
name: 'Large instruction file warning',
|
|
1057
|
-
category: 'performance',
|
|
1058
|
-
impact: 'medium',
|
|
1059
|
-
rating: null,
|
|
1060
|
-
fix: 'Split oversized instruction files so they stay under
|
|
1061
|
-
sourceUrl: null,
|
|
1062
|
-
confidence: 'high',
|
|
1073
|
+
category: 'performance',
|
|
1074
|
+
impact: 'medium',
|
|
1075
|
+
rating: null,
|
|
1076
|
+
fix: 'Split oversized instruction files so they stay under ~12,000 tokens, and keep any single instruction file below ~240,000 tokens.',
|
|
1077
|
+
sourceUrl: null,
|
|
1078
|
+
confidence: 'high',
|
|
1063
1079
|
file: largeInstructionFiles[0].file,
|
|
1064
1080
|
line: null,
|
|
1065
1081
|
passed: null,
|
|
@@ -1127,12 +1143,13 @@ async function audit(options) {
|
|
|
1127
1143
|
...largeInstructionFiles.map((item) => ({
|
|
1128
1144
|
kind: 'large-instruction-file',
|
|
1129
1145
|
severity: item.severity,
|
|
1130
|
-
message: item.message,
|
|
1131
|
-
file: item.file,
|
|
1132
|
-
lineCount: item.lineCount,
|
|
1133
|
-
byteCount: item.byteCount,
|
|
1134
|
-
|
|
1135
|
-
|
|
1146
|
+
message: item.message,
|
|
1147
|
+
file: item.file,
|
|
1148
|
+
lineCount: item.lineCount,
|
|
1149
|
+
byteCount: item.byteCount,
|
|
1150
|
+
tokenCount: item.tokenCount,
|
|
1151
|
+
skipped: item.skipped,
|
|
1152
|
+
})),
|
|
1136
1153
|
...deprecationWarnings.map((item) => ({
|
|
1137
1154
|
kind: 'deprecated-feature',
|
|
1138
1155
|
severity: 'warning',
|
|
@@ -1260,13 +1277,13 @@ async function audit(options) {
|
|
|
1260
1277
|
console.log('');
|
|
1261
1278
|
}
|
|
1262
1279
|
|
|
1263
|
-
if (largeInstructionFiles.length > 0) {
|
|
1264
|
-
console.log(colorize(' Large instruction files', 'yellow'));
|
|
1265
|
-
for (const item of largeInstructionFiles) {
|
|
1266
|
-
const sizeKb = Math.round(item.byteCount / 1024);
|
|
1267
|
-
console.log(colorize(` ${item.file} (
|
|
1268
|
-
console.log(colorize(` → ${item.message}`, 'dim'));
|
|
1269
|
-
}
|
|
1280
|
+
if (largeInstructionFiles.length > 0) {
|
|
1281
|
+
console.log(colorize(' Large instruction files', 'yellow'));
|
|
1282
|
+
for (const item of largeInstructionFiles) {
|
|
1283
|
+
const sizeKb = Number.isFinite(item.byteCount) ? Math.round(item.byteCount / 1024) : '?';
|
|
1284
|
+
console.log(colorize(` ${item.file} (~${formatCount(item.tokenCount)} tokens, ${item.lineCount || '?'} lines, ${sizeKb}KB)`, 'bold'));
|
|
1285
|
+
console.log(colorize(` → ${item.message}`, 'dim'));
|
|
1286
|
+
}
|
|
1270
1287
|
console.log('');
|
|
1271
1288
|
}
|
|
1272
1289
|
|
|
@@ -1366,8 +1383,8 @@ async function audit(options) {
|
|
|
1366
1383
|
}
|
|
1367
1384
|
|
|
1368
1385
|
// Top next actions
|
|
1369
|
-
if (topNextActions.length > 0) {
|
|
1370
|
-
console.log(colorize(' ⚡ Top 5 Next Actions', 'magenta'));
|
|
1386
|
+
if (topNextActions.length > 0) {
|
|
1387
|
+
console.log(colorize(' ⚡ Top 5 Next Actions', 'magenta'));
|
|
1371
1388
|
for (let i = 0; i < topNextActions.length; i++) {
|
|
1372
1389
|
const item = topNextActions[i];
|
|
1373
1390
|
console.log(` ${i + 1}. ${colorize(item.name, 'bold')}`);
|
|
@@ -1383,11 +1400,20 @@ async function audit(options) {
|
|
|
1383
1400
|
console.log(colorize(` Feedback: accepted ${item.feedback.accepted}, rejected ${item.feedback.rejected}, positive ${item.feedback.positive}, negative ${item.feedback.negative}${avgDelta}`, 'dim'));
|
|
1384
1401
|
}
|
|
1385
1402
|
console.log(colorize(` Fix: ${item.fix}`, 'dim'));
|
|
1386
|
-
}
|
|
1387
|
-
console.log('');
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1403
|
+
}
|
|
1404
|
+
console.log('');
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const terminology = formatTerminologyLines(collectAuditTerminology(result));
|
|
1408
|
+
if (terminology.length > 0) {
|
|
1409
|
+
terminology.forEach((line) => {
|
|
1410
|
+
const color = line.startsWith(' Terms used here:') ? 'blue' : 'dim';
|
|
1411
|
+
console.log(colorize(line, color));
|
|
1412
|
+
});
|
|
1413
|
+
console.log('');
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Summary
|
|
1391
1417
|
console.log(colorize(' ─────────────────────────────────────', 'dim'));
|
|
1392
1418
|
const deprecatedNote = deprecated.length > 0 ? colorize(`, ${deprecated.length} deprecated`, 'dim') : '';
|
|
1393
1419
|
console.log(` ${colorize(`${passed.length}/${applicable.length}`, 'bold')} checks passing${skipped.length > 0 ? colorize(` (${skipped.length} not applicable${deprecatedNote})`, 'dim') : (deprecatedNote ? colorize(` (${deprecatedNote})`, 'dim') : '')}`);
|
package/src/benchmark.js
CHANGED
|
@@ -2,11 +2,12 @@ const fs = require('fs');
|
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
|
-
const { version } = require('../package.json');
|
|
6
|
-
const { audit } = require('./audit');
|
|
7
|
-
const { setup } = require('./setup');
|
|
8
|
-
const { analyzeProject } = require('./analyze');
|
|
9
|
-
const { getGovernanceSummary } = require('./governance');
|
|
5
|
+
const { version } = require('../package.json');
|
|
6
|
+
const { audit } = require('./audit');
|
|
7
|
+
const { setup } = require('./setup');
|
|
8
|
+
const { analyzeProject } = require('./analyze');
|
|
9
|
+
const { getGovernanceSummary } = require('./governance');
|
|
10
|
+
const { formatTerminologyLines } = require('./terminology');
|
|
10
11
|
|
|
11
12
|
function copyProject(sourceDir, targetDir) {
|
|
12
13
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
@@ -340,11 +341,15 @@ function printBenchmark(report, options = {}) {
|
|
|
340
341
|
console.log(` Baseline live audit: organic ${report.before.organicScore}/100, total ${report.before.score}/100`);
|
|
341
342
|
console.log(` Projected after setup: organic ${report.after.organicScore}/100, total ${report.after.score}/100`);
|
|
342
343
|
console.log('');
|
|
343
|
-
console.log(` ${report.executiveSummary.headline}`);
|
|
344
|
-
console.log(` Recommendation: ${report.executiveSummary.decisionGuidance}`);
|
|
345
|
-
console.log(` Workflow evidence: ${report.workflowEvidence.summary.passed}/${report.workflowEvidence.summary.total} tasks (${report.workflowEvidence.summary.coverageScore}%)`);
|
|
346
|
-
console.log('');
|
|
347
|
-
|
|
344
|
+
console.log(` ${report.executiveSummary.headline}`);
|
|
345
|
+
console.log(` Recommendation: ${report.executiveSummary.decisionGuidance}`);
|
|
346
|
+
console.log(` Workflow evidence: ${report.workflowEvidence.summary.passed}/${report.workflowEvidence.summary.total} tasks (${report.workflowEvidence.summary.coverageScore}%)`);
|
|
347
|
+
console.log('');
|
|
348
|
+
for (const line of formatTerminologyLines(['governance', 'hooks', 'mcp'])) {
|
|
349
|
+
console.log(line);
|
|
350
|
+
}
|
|
351
|
+
console.log('');
|
|
352
|
+
}
|
|
348
353
|
|
|
349
354
|
function writeBenchmarkReport(report, outFile) {
|
|
350
355
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
package/src/governance.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
const { DOMAIN_PACKS } = require('./domain-packs');
|
|
2
|
-
const { MCP_PACKS, mergeMcpServers, normalizeMcpPackKeys } = require('./mcp-packs');
|
|
3
|
-
const { getCodexGovernanceSummary } = require('./codex/governance');
|
|
1
|
+
const { DOMAIN_PACKS } = require('./domain-packs');
|
|
2
|
+
const { MCP_PACKS, mergeMcpServers, normalizeMcpPackKeys } = require('./mcp-packs');
|
|
3
|
+
const { getCodexGovernanceSummary } = require('./codex/governance');
|
|
4
|
+
const { formatTerminologyLines } = require('./terminology');
|
|
4
5
|
|
|
5
6
|
const PERMISSION_PROFILES = [
|
|
6
7
|
{
|
|
@@ -406,10 +407,15 @@ function printGovernanceSummary(summary, options = {}) {
|
|
|
406
407
|
console.log('');
|
|
407
408
|
console.log(` nerviq ${summary.platformLabel.toLowerCase()} governance`);
|
|
408
409
|
console.log(' ═══════════════════════════════════════');
|
|
409
|
-
console.log(` Safe defaults, hook transparency, and pilot guidance for ${summary.platformLabel}.`);
|
|
410
|
-
console.log('');
|
|
411
|
-
|
|
412
|
-
|
|
410
|
+
console.log(` Safe defaults, hook transparency, and pilot guidance for ${summary.platformLabel}.`);
|
|
411
|
+
console.log('');
|
|
412
|
+
|
|
413
|
+
for (const line of formatTerminologyLines(['governance', 'hooks', 'denyRules', 'mcp'])) {
|
|
414
|
+
console.log(line);
|
|
415
|
+
}
|
|
416
|
+
console.log('');
|
|
417
|
+
|
|
418
|
+
console.log(' Permission Profiles');
|
|
413
419
|
for (const profile of summary.permissionProfiles) {
|
|
414
420
|
console.log(` - ${profile.label} [${profile.risk}]`);
|
|
415
421
|
console.log(` ${profile.useWhen}`);
|
package/src/index.js
CHANGED
|
@@ -88,7 +88,7 @@ const { getOpenCodeGovernanceSummary } = require('./opencode/governance');
|
|
|
88
88
|
const { runOpenCodeDeepReview } = require('./opencode/deep-review');
|
|
89
89
|
const { opencodeInteractive } = require('./opencode/interactive');
|
|
90
90
|
const { detectPlatforms, getCatalog, synergyReport } = require('./public-api');
|
|
91
|
-
const { createServer, startServer } = require('./server');
|
|
91
|
+
const { buildServeOpenApiSpec, createServer, startServer } = require('./server');
|
|
92
92
|
|
|
93
93
|
module.exports = {
|
|
94
94
|
audit,
|
|
@@ -101,6 +101,7 @@ module.exports = {
|
|
|
101
101
|
detectPlatforms,
|
|
102
102
|
getCatalog,
|
|
103
103
|
synergyReport,
|
|
104
|
+
buildServeOpenApiSpec,
|
|
104
105
|
createServer,
|
|
105
106
|
startServer,
|
|
106
107
|
DOMAIN_PACKS,
|
package/src/integrations.js
CHANGED
|
@@ -10,66 +10,113 @@
|
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
-
const https = require('https');
|
|
14
|
-
const http = require('http');
|
|
15
|
-
const { URL } = require('url');
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const { URL } = require('url');
|
|
16
|
+
|
|
17
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
18
|
+
|
|
19
|
+
function wait(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sendWebhookOnce(parsed, body, opts = {}) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
26
|
+
const customHeaders = opts.headers || {};
|
|
27
|
+
const headers = {
|
|
28
|
+
'User-Agent': `nerviq/${require('../package.json').version}`,
|
|
29
|
+
...customHeaders,
|
|
30
|
+
'Content-Length': Buffer.byteLength(body),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const hasContentTypeHeader = Object.keys(headers).some((name) => name.toLowerCase() === 'content-type');
|
|
34
|
+
if (!hasContentTypeHeader) {
|
|
35
|
+
headers['Content-Type'] = 'application/json';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const options = {
|
|
39
|
+
hostname: parsed.hostname,
|
|
40
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
41
|
+
path: parsed.pathname + (parsed.search || ''),
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
47
|
+
|
|
48
|
+
const req = transport.request(options, (res) => {
|
|
49
|
+
const chunks = [];
|
|
50
|
+
res.on('data', (c) => chunks.push(c));
|
|
51
|
+
res.on('end', () => {
|
|
52
|
+
const respBody = Buffer.concat(chunks).toString('utf8');
|
|
53
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: respBody });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
req.setTimeout(timeoutMs, () => {
|
|
58
|
+
req.destroy(new Error(`Webhook request timed out after ${timeoutMs}ms`));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
req.on('error', reject);
|
|
62
|
+
req.write(body);
|
|
63
|
+
req.end();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Webhook delivery ────────────────────────────────────────────────────────
|
|
18
68
|
|
|
19
69
|
/**
|
|
20
70
|
* POST JSON payload to a webhook URL.
|
|
21
71
|
* @param {string} url - Destination URL (http or https)
|
|
22
72
|
* @param {object} payload - JSON-serialisable object
|
|
23
|
-
* @param {object} [opts]
|
|
24
|
-
* @param {number} [opts.timeoutMs=10000]
|
|
25
|
-
* @param {object} [opts.headers]
|
|
26
|
-
* @
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
req.end();
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
+
* @param {object} [opts]
|
|
74
|
+
* @param {number} [opts.timeoutMs=10000]
|
|
75
|
+
* @param {object} [opts.headers]
|
|
76
|
+
* @param {number} [opts.retries=2]
|
|
77
|
+
* @param {number} [opts.retryDelayMs=400]
|
|
78
|
+
* @returns {Promise<{ ok: boolean, status: number, body: string, attempts: number }>}
|
|
79
|
+
*/
|
|
80
|
+
async function sendWebhook(url, payload, opts = {}) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(url);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error(`Invalid webhook URL: ${url}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
89
|
+
throw new Error(`Unsupported webhook protocol: ${parsed.protocol}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = JSON.stringify(payload);
|
|
93
|
+
const retries = Number.isInteger(opts.retries) && opts.retries >= 0 ? opts.retries : 2;
|
|
94
|
+
const retryDelayMs = Number.isFinite(opts.retryDelayMs) && opts.retryDelayMs >= 0 ? opts.retryDelayMs : 400;
|
|
95
|
+
const maxAttempts = retries + 1;
|
|
96
|
+
|
|
97
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
98
|
+
try {
|
|
99
|
+
const response = await sendWebhookOnce(parsed, body, opts);
|
|
100
|
+
const enriched = { ...response, attempts: attempt };
|
|
101
|
+
const shouldRetry = RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxAttempts;
|
|
102
|
+
if (!shouldRetry) {
|
|
103
|
+
return enriched;
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
error.attempts = attempt;
|
|
107
|
+
if (attempt >= maxAttempts) {
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const delayMs = retryDelayMs * attempt;
|
|
113
|
+
if (delayMs > 0) {
|
|
114
|
+
await wait(delayMs);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { ok: false, status: 0, body: '', attempts: maxAttempts };
|
|
119
|
+
}
|
|
73
120
|
|
|
74
121
|
// ─── Slack formatting ─────────────────────────────────────────────────────────
|
|
75
122
|
|