flecto 1.0.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/index.js ADDED
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
5
+ import { resolve, relative } from 'path';
6
+ import { createHash } from 'crypto';
7
+ import { execSync } from 'child_process';
8
+ import chalk from 'chalk';
9
+
10
+ import { parseFile, isSupported, parseContent } from './src/parser.js';
11
+ import { diffTrees } from './src/differ.js';
12
+ import { startWatcher } from './src/watcher.js';
13
+ import { renderChanges, renderDiff, renderError, renderInfo, renderWarn, renderPolicyFindings } from './src/renderer.js';
14
+ import { fireAlerts } from './src/alerter.js';
15
+ import { createEnvelope } from './src/envelope.js';
16
+ import { evaluatePolicies, highestSeverity } from './src/policy.js';
17
+ import { loadRcConfig, resolveEffectiveOptions, resolveFiles, initRcFile } from './src/config.js';
18
+
19
+ const SNAPSHOT_DIR = '.flecto-snapshots';
20
+
21
+ function snapshotIdForPath(absPath) {
22
+ // Stable across platforms and avoids basename collisions
23
+ const normalized = absPath.replaceAll('\\', '/');
24
+ return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
25
+ }
26
+
27
+ function snapshotPathForFile(absPath) {
28
+ const id = snapshotIdForPath(absPath);
29
+ return resolve(`${SNAPSHOT_DIR}/${id}.json`);
30
+ }
31
+
32
+ function parseCsv(value) {
33
+ if (!value) return [];
34
+ if (Array.isArray(value)) return value;
35
+ return String(value).split(',').map((s) => s.trim()).filter(Boolean);
36
+ }
37
+
38
+ function parseHeaders(headerList) {
39
+ const webhookHeaders = {};
40
+ if (!Array.isArray(headerList)) return webhookHeaders;
41
+ for (const h of headerList) {
42
+ const idx = String(h).indexOf(':');
43
+ if (idx > 0) {
44
+ const k = String(h).slice(0, idx).trim();
45
+ const v = String(h).slice(idx + 1).trim();
46
+ if (k) webhookHeaders[k] = v;
47
+ }
48
+ }
49
+ return webhookHeaders;
50
+ }
51
+
52
+ function validateMode(mode) {
53
+ if (!['compact', 'verbose'].includes(mode)) {
54
+ throw new Error('--mode must be "compact" or "verbose"');
55
+ }
56
+ }
57
+
58
+ function validateInterval(interval) {
59
+ if (Number.isNaN(interval) || interval < 10) {
60
+ throw new Error('--interval must be a number >= 10');
61
+ }
62
+ }
63
+
64
+ async function resolveTargetFiles(cliFiles, rcConfig) {
65
+ if (cliFiles && cliFiles.length > 0) {
66
+ const direct = [];
67
+ const globPatterns = [];
68
+ for (const entry of cliFiles) {
69
+ if (/[*?[\]{}]/.test(entry)) {
70
+ globPatterns.push(entry);
71
+ } else {
72
+ direct.push(resolve(entry));
73
+ }
74
+ }
75
+ let expanded = [];
76
+ if (globPatterns.length > 0) {
77
+ expanded = await resolveFiles({
78
+ cwd: process.cwd(),
79
+ files: globPatterns,
80
+ exclude: rcConfig?.exclude ?? [],
81
+ });
82
+ }
83
+ return [...new Set([...direct, ...expanded])];
84
+ }
85
+
86
+ return resolveFiles({
87
+ cwd: process.cwd(),
88
+ files: rcConfig?.files ?? rcConfig?.include ?? [],
89
+ exclude: rcConfig?.exclude ?? [],
90
+ });
91
+ }
92
+
93
+ function readSnapshotStateFromFile(snapshotPath) {
94
+ const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
95
+ return snap?.state ?? snap;
96
+ }
97
+
98
+ function readSnapshotStateFromRef(filePath, snapshotRef) {
99
+ if (!snapshotRef) return readSnapshotStateFromFile(snapshotPathForFile(filePath));
100
+ const maybePath = resolve(snapshotRef);
101
+ if (existsSync(maybePath)) {
102
+ return readSnapshotStateFromFile(maybePath);
103
+ }
104
+
105
+ // git ref mode: flecto ci file --snapshot-ref HEAD~1
106
+ const rel = relative(process.cwd(), filePath).replaceAll('\\', '/');
107
+ const raw = execSync(`git show ${snapshotRef}:${rel}`, { encoding: 'utf8' });
108
+ return parseContent(filePath, raw);
109
+ }
110
+
111
+ function shouldFailFromPolicy(findings, failOn) {
112
+ if (failOn.has('policy') && findings.length > 0) return true;
113
+ if (failOn.has('error') && highestSeverity(findings) === 'error') return true;
114
+ if (failOn.has('warn') && (highestSeverity(findings) === 'warn' || highestSeverity(findings) === 'error')) return true;
115
+ return false;
116
+ }
117
+
118
+ function shouldFailFromChanges(events, failOn) {
119
+ if (events.length === 0) return false;
120
+ if (failOn.has('changed') && events.some((e) => e.type === 'changed')) return true;
121
+ if (failOn.has('added') && events.some((e) => e.type === 'added')) return true;
122
+ if (failOn.has('removed') && events.some((e) => e.type === 'removed')) return true;
123
+ return false;
124
+ }
125
+
126
+ function printCiOutput(results, format) {
127
+ if (format === 'json') {
128
+ console.log(JSON.stringify(results, null, 2));
129
+ return;
130
+ }
131
+ if (format === 'ndjson') {
132
+ for (const result of results) {
133
+ console.log(JSON.stringify(result));
134
+ }
135
+ return;
136
+ }
137
+ if (format === 'github-annotations') {
138
+ for (const result of results) {
139
+ for (const event of result.envelope.changes) {
140
+ const title = `flecto ${event.type}`;
141
+ console.log(`::warning file=${result.file},title=${title}::${event.path}`);
142
+ }
143
+ for (const finding of result.policies) {
144
+ const level = finding.severity === 'error' ? 'error' : 'warning';
145
+ console.log(`::${level} file=${result.file},title=policy::${finding.path} ${finding.message}`);
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ program
152
+ .name('flecto')
153
+ .description('Flecto — semantic config watcher for meaningful structured file changes')
154
+ .version('1.0.0');
155
+
156
+ program
157
+ .command('watch [files...]')
158
+ .description('Watch config files/globs for semantic changes')
159
+ .option('-p, --profile <name>', 'Use profile from .flectorc')
160
+ .option('-i, --interval <ms>', 'Polling fallback interval in ms', '100')
161
+ .option('--polling', 'Force polling mode (useful on network drives / some editors)', false)
162
+ .option('-m, --mode <mode>', 'Output mode: compact | verbose', 'compact')
163
+ .option('-c, --command <cmd>', 'Shell command to run on every change')
164
+ .option('-w, --webhook <url>', 'POST change payload to this URL on change')
165
+ .option('--webhook-header <header>', 'Extra webhook header (repeatable), e.g. "Authorization: Bearer TOKEN"', (v, acc) => {
166
+ acc.push(v);
167
+ return acc;
168
+ }, [])
169
+ .option('--delivery-mode <mode>', 'Alert delivery mode: best-effort | at-least-once', 'best-effort')
170
+ .option('--on-alert-failure <mode>', 'Alert failure behavior: warn | exit | retry', 'warn')
171
+ .option('--webhook-timeout <ms>', 'Webhook timeout in ms', '5000')
172
+ .option('--webhook-retries <n>', 'Webhook retries', '2')
173
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
174
+ .option('--snapshot', 'Save current state as baseline instead of watching')
175
+ .option('--diff', 'Diff current file against saved baseline and exit')
176
+ .action(async (files, opts) => {
177
+ try {
178
+ const { config } = loadRcConfig(process.cwd());
179
+ const effective = resolveEffectiveOptions(config, opts.profile, opts);
180
+ const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
181
+ if (targets.length === 0) {
182
+ throw new Error('No files matched. Provide files or configure .flectorc files/include.');
183
+ }
184
+
185
+ const ignorePaths = parseCsv(effective.ignore);
186
+ const webhookHeaders = parseHeaders(effective.webhookHeader);
187
+ const interval = parseInt(String(effective.interval ?? '100'), 10);
188
+ validateInterval(interval);
189
+ const mode = String(effective.mode ?? 'compact');
190
+ validateMode(mode);
191
+
192
+ if (effective.snapshot) {
193
+ mkdirSync(SNAPSHOT_DIR, { recursive: true });
194
+ for (const filepath of targets) {
195
+ if (!existsSync(filepath) || !isSupported(filepath)) continue;
196
+ const state = parseFile(filepath);
197
+ const snapshotPath = snapshotPathForFile(filepath);
198
+ writeFileSync(snapshotPath, JSON.stringify({ file: filepath, state }, null, 2), 'utf8');
199
+ console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
200
+ }
201
+ return;
202
+ }
203
+
204
+ if (effective.diff) {
205
+ let hasChanges = false;
206
+ for (const filepath of targets) {
207
+ const snapshotPath = snapshotPathForFile(filepath);
208
+ if (!existsSync(snapshotPath)) {
209
+ renderWarn(`No snapshot found for "${filepath}"`);
210
+ continue;
211
+ }
212
+ const before = readSnapshotStateFromFile(snapshotPath);
213
+ const after = parseFile(filepath);
214
+ const events = diffTrees(before, after, { ignorePaths });
215
+ renderDiff(filepath, events);
216
+ if (events.length > 0) hasChanges = true;
217
+ }
218
+ process.exit(hasChanges ? 1 : 0);
219
+ }
220
+
221
+ const watchers = [];
222
+ for (const filepath of targets) {
223
+ if (!existsSync(filepath)) {
224
+ renderWarn(`Skipping missing file: ${filepath}`);
225
+ continue;
226
+ }
227
+ if (!isSupported(filepath)) {
228
+ renderWarn(`Skipping unsupported file: ${filepath}`);
229
+ continue;
230
+ }
231
+
232
+ renderInfo(`flecto watching ${chalk.cyan(filepath)}`);
233
+ const watcher = startWatcher(
234
+ filepath,
235
+ { interval, mode, ignorePaths, polling: Boolean(effective.polling) },
236
+ async (event) => {
237
+ if (event.kind === 'changes') {
238
+ renderChanges(event.filepath, event.events, mode);
239
+ const policyFindings = evaluatePolicies(event.events);
240
+ renderPolicyFindings(policyFindings);
241
+ if (effective.command || effective.webhook) {
242
+ const envelope = createEnvelope({
243
+ source: 'watch',
244
+ file: event.filepath,
245
+ changes: event.events,
246
+ });
247
+ await fireAlerts({
248
+ command: effective.command,
249
+ webhook: effective.webhook,
250
+ webhookHeaders,
251
+ webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
252
+ webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
253
+ deliveryMode: effective.deliveryMode,
254
+ onAlertFailure: effective.onAlertFailure,
255
+ }, envelope);
256
+ }
257
+ } else {
258
+ renderInfo(`[lifecycle] ${event.filepath}: ${event.lifecycle.type} - ${event.lifecycle.message}`);
259
+ if (effective.command || effective.webhook) {
260
+ const envelope = createEnvelope({
261
+ source: 'watch',
262
+ file: event.filepath,
263
+ lifecycle: event.lifecycle,
264
+ });
265
+ await fireAlerts({
266
+ command: effective.command,
267
+ webhook: effective.webhook,
268
+ webhookHeaders,
269
+ webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
270
+ webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
271
+ deliveryMode: effective.deliveryMode,
272
+ onAlertFailure: effective.onAlertFailure,
273
+ }, envelope);
274
+ }
275
+ }
276
+ }
277
+ );
278
+ watchers.push(watcher);
279
+ }
280
+
281
+ if (watchers.length === 0) {
282
+ throw new Error('No valid files to watch.');
283
+ }
284
+ renderInfo('Press Ctrl+C to stop.\n');
285
+
286
+ const closeAll = async (exitCode) => {
287
+ await Promise.all(watchers.map((w) => w.close()));
288
+ if (exitCode === 0) {
289
+ console.log(chalk.dim('\nflecto stopped.'));
290
+ }
291
+ process.exit(exitCode);
292
+ };
293
+ process.on('SIGINT', () => void closeAll(0));
294
+ process.on('SIGTERM', () => void closeAll(0));
295
+ } catch (err) {
296
+ renderError(err.message);
297
+ process.exit(1);
298
+ }
299
+ });
300
+
301
+ program
302
+ .command('ci [files...]')
303
+ .description('Run semantic diff in CI mode')
304
+ .option('-p, --profile <name>', 'Use profile from .flectorc')
305
+ .option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
306
+ .option('--format <type>', 'Output format: json | ndjson | github-annotations', 'json')
307
+ .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
308
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore')
309
+ .action(async (files, opts) => {
310
+ try {
311
+ const { config } = loadRcConfig(process.cwd());
312
+ const effective = resolveEffectiveOptions(config, opts.profile, opts);
313
+ const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
314
+ if (targets.length === 0) {
315
+ throw new Error('No files matched. Provide files or configure .flectorc files/include.');
316
+ }
317
+
318
+ const ignorePaths = parseCsv(effective.ignore);
319
+ const failOn = new Set(parseCsv(effective.failOn));
320
+ const format = String(effective.format ?? 'json');
321
+ if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
322
+ throw new Error('--format must be json, ndjson, or github-annotations');
323
+ }
324
+
325
+ /** @type {any[]} */
326
+ const results = [];
327
+ let shouldFail = false;
328
+
329
+ for (const filepath of targets) {
330
+ if (!existsSync(filepath) || !isSupported(filepath)) continue;
331
+ const after = parseFile(filepath);
332
+ let before = {};
333
+ try {
334
+ before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
335
+ } catch {
336
+ before = {};
337
+ }
338
+ const events = diffTrees(before, after, { ignorePaths });
339
+ const policies = evaluatePolicies(events);
340
+ const envelope = createEnvelope({
341
+ source: 'ci',
342
+ file: filepath,
343
+ changes: events,
344
+ });
345
+ results.push({ file: filepath, envelope, policies });
346
+
347
+ if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policies, failOn)) {
348
+ shouldFail = true;
349
+ }
350
+ }
351
+
352
+ printCiOutput(results, format);
353
+ process.exit(shouldFail ? 1 : 0);
354
+ } catch (err) {
355
+ renderError(err.message);
356
+ process.exit(1);
357
+ }
358
+ });
359
+
360
+ program
361
+ .command('init')
362
+ .description('Create starter .flectorc configuration')
363
+ .action(() => {
364
+ const path = initRcFile(process.cwd());
365
+ renderInfo(`Initialized config: ${path}`);
366
+ });
367
+
368
+ program
369
+ .command('doctor')
370
+ .description('Check Flecto setup, config, and environment')
371
+ .action(async () => {
372
+ try {
373
+ const { path, config } = loadRcConfig(process.cwd());
374
+ if (path) {
375
+ renderInfo(`config: ${path}`);
376
+ } else {
377
+ renderWarn('No .flectorc found (optional). Run "flecto init" to scaffold.');
378
+ }
379
+
380
+ const files = await resolveFiles({
381
+ cwd: process.cwd(),
382
+ files: config?.files ?? [],
383
+ include: config?.include ?? [],
384
+ exclude: config?.exclude ?? [],
385
+ });
386
+ renderInfo(`resolved files: ${files.length}`);
387
+ if (typeof fetch !== 'function') {
388
+ throw new Error('Global fetch unavailable. Use Node.js >= 18.');
389
+ }
390
+ renderInfo('fetch: available');
391
+ renderInfo('doctor: OK');
392
+ } catch (err) {
393
+ renderError(`doctor failed: ${err.message}`);
394
+ process.exit(1);
395
+ }
396
+ });
397
+
398
+ program.parse(process.argv);
399
+
400
+ // Show help if no command given
401
+ if (!process.argv.slice(2).length) {
402
+ program.help();
403
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "flecto",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "1.0.0",
7
+ "description": "Flecto — semantic config watcher that reports meaningful changes in plain English",
8
+ "keywords": [
9
+ "flecto",
10
+ "cli",
11
+ "watcher",
12
+ "config",
13
+ "semantic-diff",
14
+ "devops",
15
+ "ci"
16
+ ],
17
+ "homepage": "https://github.com/siddharrth2005/sentinel#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/siddharrth2005/sentinel/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/siddharrth2005/sentinel.git"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "type": "module",
29
+ "main": "index.js",
30
+ "bin": {
31
+ "flecto": "index.js"
32
+ },
33
+ "files": [
34
+ "index.js",
35
+ "src/**/*",
36
+ "README.md",
37
+ "README_RECRUITERS.md"
38
+ ],
39
+ "scripts": {
40
+ "test": "node --test test/*.test.js",
41
+ "test:watch": "node --test --watch test/*.test.js",
42
+ "pack:check": "npm pack --dry-run"
43
+ },
44
+ "dependencies": {
45
+ "@iarna/toml": "^2.2.5",
46
+ "chalk": "^5.3.0",
47
+ "chokidar": "^3.6.0",
48
+ "commander": "^12.1.0",
49
+ "dotenv": "^16.4.5",
50
+ "fast-glob": "^3.3.3",
51
+ "js-yaml": "^4.1.0"
52
+ }
53
+ }
package/src/alerter.js ADDED
@@ -0,0 +1,252 @@
1
+ import { spawn } from 'child_process';
2
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { renderWarn } from './renderer.js';
5
+
6
+ const ALERT_TMP_DIR = '.flecto-tmp';
7
+ const ALERT_QUEUE_DIR = '.flecto-queue';
8
+ const MAX_ENV_CHANGES_CHARS = 16_000;
9
+
10
+ /** @type {Promise<void>} */
11
+ let alertQueue = Promise.resolve();
12
+
13
+ function enqueue(fn) {
14
+ alertQueue = alertQueue
15
+ .then(async () => { await fn(); })
16
+ .catch((err) => {
17
+ renderWarn(`Alert pipeline error: ${err?.message ?? String(err)}`);
18
+ });
19
+ return alertQueue;
20
+ }
21
+
22
+ /**
23
+ * @param {import('./envelope.js').SentinelEnvelope} envelope
24
+ */
25
+ function buildCommandEnv(envelope) {
26
+ const json = JSON.stringify(envelope.changes);
27
+ const env = {
28
+ ...process.env,
29
+ FLECTO_FILE: envelope.file,
30
+ FLECTO_EVENT_ID: envelope.event_id,
31
+ FLECTO_BATCH_ID: envelope.batch_id,
32
+ FLECTO_SCHEMA_VERSION: envelope.schema_version,
33
+ };
34
+
35
+ if (json.length <= MAX_ENV_CHANGES_CHARS) {
36
+ env.FLECTO_CHANGES = json;
37
+ return env;
38
+ }
39
+
40
+ try {
41
+ mkdirSync(ALERT_TMP_DIR, { recursive: true });
42
+ const outPath = resolve(`${ALERT_TMP_DIR}/changes-${Date.now()}-${envelope.event_id}.json`);
43
+ writeFileSync(outPath, json, 'utf8');
44
+ env.FLECTO_CHANGES_FILE = outPath;
45
+ env.FLECTO_CHANGES = '[]';
46
+ return env;
47
+ } catch (err) {
48
+ env.FLECTO_CHANGES = '[]';
49
+ env.FLECTO_CHANGES_TRUNCATED = '1';
50
+ renderWarn(`Could not write changes payload to temp file: ${err.message}`);
51
+ return env;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {string} command
57
+ * @param {import('./envelope.js').SentinelEnvelope} envelope
58
+ * @returns {Promise<boolean>}
59
+ */
60
+ export function runCommand(command, envelope) {
61
+ return new Promise((resolveDone) => {
62
+ const env = buildCommandEnv(envelope);
63
+ try {
64
+ const child = spawn(command, {
65
+ shell: true,
66
+ env,
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ });
69
+
70
+ child.stdout?.on('data', (d) => process.stdout.write(d));
71
+ child.stderr?.on('data', (d) => process.stderr.write(d));
72
+ child.on('error', (err) => {
73
+ renderWarn(`Command failed to start: ${err.message}`);
74
+ resolveDone(false);
75
+ });
76
+ child.on('close', (code) => {
77
+ if (code && code !== 0) {
78
+ renderWarn(`Command failed (exit ${code}): ${command}`);
79
+ resolveDone(false);
80
+ return;
81
+ }
82
+ resolveDone(true);
83
+ });
84
+ } catch (err) {
85
+ renderWarn(`Command execution error: ${err.message}`);
86
+ resolveDone(false);
87
+ }
88
+ });
89
+ }
90
+
91
+ function sleep(ms) {
92
+ return new Promise((r) => setTimeout(r, ms));
93
+ }
94
+
95
+ /**
96
+ * @param {import('./envelope.js').SentinelEnvelope} envelope
97
+ */
98
+ function enqueuePersistent(envelope) {
99
+ mkdirSync(ALERT_QUEUE_DIR, { recursive: true });
100
+ const path = resolve(`${ALERT_QUEUE_DIR}/${Date.now()}-${envelope.event_id}.json`);
101
+ writeFileSync(path, JSON.stringify(envelope, null, 2), 'utf8');
102
+ }
103
+
104
+ /**
105
+ * @param {(envelope: import('./envelope.js').SentinelEnvelope) => Promise<boolean>} deliver
106
+ */
107
+ async function flushPersistentQueue(deliver) {
108
+ try {
109
+ mkdirSync(ALERT_QUEUE_DIR, { recursive: true });
110
+ const files = readdirSync(ALERT_QUEUE_DIR).filter((f) => f.endsWith('.json')).sort();
111
+ for (const file of files) {
112
+ const fullPath = resolve(`${ALERT_QUEUE_DIR}/${file}`);
113
+ let envelope;
114
+ try {
115
+ envelope = JSON.parse(readFileSync(fullPath, 'utf8'));
116
+ } catch {
117
+ unlinkSync(fullPath);
118
+ continue;
119
+ }
120
+ const ok = await deliver(envelope);
121
+ if (ok) {
122
+ unlinkSync(fullPath);
123
+ } else {
124
+ return false;
125
+ }
126
+ }
127
+ return true;
128
+ } catch (err) {
129
+ renderWarn(`Could not flush persistent queue: ${err.message}`);
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * @param {string} url
136
+ * @param {import('./envelope.js').SentinelEnvelope} envelope
137
+ * @param {{ headers?: Record<string, string>, timeoutMs?: number, retries?: number }} [options]
138
+ * @returns {Promise<boolean>}
139
+ */
140
+ export async function postWebhook(url, envelope, options = {}) {
141
+ const body = JSON.stringify(envelope);
142
+ const timeoutMs = options.timeoutMs ?? 5_000;
143
+ const retries = options.retries ?? 2;
144
+ const headers = {
145
+ 'Content-Type': 'application/json',
146
+ 'X-Flecto-Event-Id': envelope.event_id,
147
+ 'X-Flecto-Batch-Id': envelope.batch_id,
148
+ 'X-Flecto-Schema': envelope.schema_version,
149
+ ...(options.headers ?? {}),
150
+ };
151
+
152
+ for (let attempt = 0; attempt <= retries; attempt++) {
153
+ const controller = new AbortController();
154
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
155
+ try {
156
+ const response = await fetch(url, {
157
+ method: 'POST',
158
+ headers,
159
+ body,
160
+ signal: controller.signal,
161
+ });
162
+ if (!response.ok) {
163
+ renderWarn(`Webhook returned HTTP ${response.status}: ${url}`);
164
+ if (response.status >= 500 && attempt < retries) {
165
+ const backoff = Math.min(2_000, 200 * Math.pow(2, attempt));
166
+ await sleep(backoff + Math.floor(Math.random() * 150));
167
+ continue;
168
+ }
169
+ return false;
170
+ }
171
+ return true;
172
+ } catch (err) {
173
+ const msg = err?.name === 'AbortError' ? `Webhook timed out after ${timeoutMs}ms` : 'Webhook failed';
174
+ if (attempt >= retries) {
175
+ renderWarn(`${msg}: ${url} (${err.message})`);
176
+ return false;
177
+ }
178
+ const backoff = Math.min(2_000, 200 * Math.pow(2, attempt));
179
+ await sleep(backoff + Math.floor(Math.random() * 150));
180
+ } finally {
181
+ clearTimeout(timer);
182
+ }
183
+ }
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookTimeoutMs?: number, webhookRetries?: number }} options
189
+ * @param {import('./envelope.js').SentinelEnvelope} envelope
190
+ */
191
+ async function deliverWebhook(options, envelope) {
192
+ if (!options.webhook) return true;
193
+ return postWebhook(options.webhook, envelope, {
194
+ headers: options.webhookHeaders,
195
+ timeoutMs: options.webhookTimeoutMs,
196
+ retries: options.webhookRetries,
197
+ });
198
+ }
199
+
200
+ /**
201
+ * @param {{ onAlertFailure?: 'warn' | 'exit' | 'retry' }} options
202
+ * @param {boolean} ok
203
+ */
204
+ function applyFailurePolicy(options, ok) {
205
+ if (ok) return;
206
+ const policy = options.onAlertFailure ?? 'warn';
207
+ if (policy === 'exit') {
208
+ process.exitCode = 1;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * @param {{
214
+ * command?: string,
215
+ * webhook?: string,
216
+ * webhookHeaders?: Record<string, string>,
217
+ * webhookTimeoutMs?: number,
218
+ * webhookRetries?: number,
219
+ * deliveryMode?: 'best-effort' | 'at-least-once',
220
+ * onAlertFailure?: 'warn' | 'exit' | 'retry'
221
+ * }} options
222
+ * @param {import('./envelope.js').SentinelEnvelope} envelope
223
+ * @returns {Promise<{ ok: boolean }>}
224
+ */
225
+ export async function fireAlerts(options, envelope) {
226
+ return enqueue(async () => {
227
+ let ok = true;
228
+
229
+ if (options.command) {
230
+ const cmdOk = await runCommand(options.command, envelope);
231
+ ok = ok && cmdOk;
232
+ }
233
+
234
+ if (options.webhook) {
235
+ if (options.deliveryMode === 'at-least-once') {
236
+ await flushPersistentQueue((queued) => deliverWebhook(options, queued));
237
+ }
238
+
239
+ let webhookOk = await deliverWebhook(options, envelope);
240
+ if (!webhookOk && options.onAlertFailure === 'retry') {
241
+ webhookOk = await deliverWebhook({ ...options, webhookRetries: 5 }, envelope);
242
+ }
243
+ if (!webhookOk && options.deliveryMode === 'at-least-once') {
244
+ enqueuePersistent(envelope);
245
+ }
246
+ ok = ok && webhookOk;
247
+ }
248
+
249
+ applyFailurePolicy(options, ok);
250
+ return { ok };
251
+ });
252
+ }