flecto 1.0.1 → 2.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/README.md +52 -2
- package/index.js +128 -27
- package/package.json +4 -2
- package/schemas/flecto-envelope-2.0.json +65 -0
- package/src/alerter.js +7 -7
- package/src/config.js +38 -2
- package/src/differ.js +100 -13
- package/src/envelope.js +6 -4
- package/src/packs/default.json +37 -0
- package/src/packs/strict-prod.json +37 -0
- package/src/parser.js +80 -14
- package/src/policy.js +244 -33
- package/src/renderer.js +59 -13
- package/src/watcher.js +11 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Flecto watches your config files and tells you exactly what changed — in plain English.**
|
|
4
4
|
|
|
5
|
-
No more staring at raw line diffs. When your `.env`, `YAML`, `JSON`, or `
|
|
5
|
+
No more staring at raw line diffs. When your `.env`, `YAML`, `JSON`, `TOML`, or `INI` file changes, Flecto shows you what actually happened:
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
[10:42:31] config/prod.yaml — 3 changes
|
|
@@ -99,17 +99,67 @@ Each webhook payload includes a full event envelope:
|
|
|
99
99
|
|
|
100
100
|
```json
|
|
101
101
|
{
|
|
102
|
-
"schema_version": "
|
|
102
|
+
"schema_version": "2.0",
|
|
103
103
|
"event_id": "uuid",
|
|
104
104
|
"event_type": "changes",
|
|
105
105
|
"emitted_at": "2026-04-14T10:42:31.000Z",
|
|
106
106
|
"file": "/absolute/path/to/config/prod.yaml",
|
|
107
107
|
"changes": [
|
|
108
108
|
{ "type": "changed", "path": "database.pool_size", "before": 5, "after": 20 }
|
|
109
|
+
],
|
|
110
|
+
"policies": [
|
|
111
|
+
{
|
|
112
|
+
"id": "pool-size-jump",
|
|
113
|
+
"severity": "warn",
|
|
114
|
+
"path": "database.pool_size",
|
|
115
|
+
"message": "Pool size increased from 5 to 20 (>=2x).",
|
|
116
|
+
"pack": "default"
|
|
117
|
+
}
|
|
109
118
|
]
|
|
110
119
|
}
|
|
111
120
|
```
|
|
112
121
|
|
|
122
|
+
Envelope JSON Schema: [`schemas/flecto-envelope-2.0.json`](schemas/flecto-envelope-2.0.json).
|
|
123
|
+
|
|
124
|
+
### Policy packs and profiles
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
flecto ci config/prod.yaml --profile prod --snapshot-ref HEAD~1
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`.flectorc.json` example:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"defaults": {
|
|
135
|
+
"policies": ["default"],
|
|
136
|
+
"maskSecrets": false
|
|
137
|
+
},
|
|
138
|
+
"profiles": {
|
|
139
|
+
"prod": {
|
|
140
|
+
"policies": ["default", "strict-prod"],
|
|
141
|
+
"maskSecrets": true
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Profile selection: `--profile` > `FLECTO_PROFILE` > defaults. Custom packs live in `policies/<id>.json`. Local ESM plugins export `evaluate(changes, ctx)`.
|
|
148
|
+
|
|
149
|
+
### Opt-in array identity matching
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
flecto watch config/services.yaml --array-id-key id
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Without the flag, arrays still diff by index (1.x behavior).
|
|
156
|
+
|
|
157
|
+
### Migrating from envelope 1.1
|
|
158
|
+
|
|
159
|
+
- `schema_version` is now `"2.0"`
|
|
160
|
+
- Envelope type name is `FlectoEnvelope` (was `SentinelEnvelope` in docs/types only)
|
|
161
|
+
- New `policies` array on change envelopes
|
|
162
|
+
- Webhook headers are unchanged (`X-Flecto-*`)
|
|
113
163
|
### Use both command and webhook together
|
|
114
164
|
|
|
115
165
|
```bash
|
package/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
4
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
5
|
-
import { resolve, relative } from 'path';
|
|
5
|
+
import { resolve, relative, dirname, join } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
6
7
|
import { createHash } from 'crypto';
|
|
7
8
|
import { execFileSync } from 'child_process';
|
|
8
9
|
import chalk from 'chalk';
|
|
@@ -10,16 +11,34 @@ import chalk from 'chalk';
|
|
|
10
11
|
import { parseFile, isSupported, parseContent } from './src/parser.js';
|
|
11
12
|
import { diffTrees } from './src/differ.js';
|
|
12
13
|
import { startWatcher } from './src/watcher.js';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
renderChanges,
|
|
16
|
+
renderDiff,
|
|
17
|
+
renderError,
|
|
18
|
+
renderInfo,
|
|
19
|
+
renderWarn,
|
|
20
|
+
renderPolicyFindings,
|
|
21
|
+
maskChangeEvent,
|
|
22
|
+
} from './src/renderer.js';
|
|
14
23
|
import { fireAlerts } from './src/alerter.js';
|
|
15
24
|
import { createEnvelope } from './src/envelope.js';
|
|
16
25
|
import { evaluatePolicies, highestSeverity } from './src/policy.js';
|
|
17
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
loadRcConfig,
|
|
28
|
+
resolveEffectiveOptions,
|
|
29
|
+
resolveFiles,
|
|
30
|
+
initRcFile,
|
|
31
|
+
resolveProfileName,
|
|
32
|
+
resolvePolicyOptions,
|
|
33
|
+
} from './src/config.js';
|
|
34
|
+
|
|
35
|
+
const PKG = JSON.parse(
|
|
36
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
|
|
37
|
+
);
|
|
18
38
|
|
|
19
39
|
const SNAPSHOT_DIR = '.flecto-snapshots';
|
|
20
40
|
|
|
21
41
|
function snapshotIdForPath(absPath) {
|
|
22
|
-
// Stable across platforms and avoids basename collisions
|
|
23
42
|
const normalized = absPath.replaceAll('\\', '/');
|
|
24
43
|
return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
25
44
|
}
|
|
@@ -61,6 +80,39 @@ function validateInterval(interval) {
|
|
|
61
80
|
}
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
function stripUnsetCliOverrides(opts) {
|
|
84
|
+
const out = { ...opts };
|
|
85
|
+
// Don't let Commander defaults wipe .flectorc values for optional features
|
|
86
|
+
for (const key of [
|
|
87
|
+
'policies',
|
|
88
|
+
'plugins',
|
|
89
|
+
'arrayIdKey',
|
|
90
|
+
'maskSecrets',
|
|
91
|
+
'maskSecretsWebhooks',
|
|
92
|
+
'arrayIgnoreOrder',
|
|
93
|
+
'snapshotRef',
|
|
94
|
+
'ignore',
|
|
95
|
+
]) {
|
|
96
|
+
if (out[key] === undefined || out[key] === false || out[key] === null || out[key] === '') {
|
|
97
|
+
delete out[key];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function diffOptionsFromEffective(effective, ignorePaths) {
|
|
104
|
+
return {
|
|
105
|
+
ignorePaths,
|
|
106
|
+
arrayIdKey: effective.arrayIdKey || null,
|
|
107
|
+
arrayIgnoreOrder: Boolean(effective.arrayIgnoreOrder),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function maybeMaskChanges(events, maskSecrets) {
|
|
112
|
+
if (!maskSecrets) return events;
|
|
113
|
+
return events.map(maskChangeEvent);
|
|
114
|
+
}
|
|
115
|
+
|
|
64
116
|
async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
65
117
|
if (cliFiles && cliFiles.length > 0) {
|
|
66
118
|
const direct = [];
|
|
@@ -102,7 +154,6 @@ function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
|
102
154
|
return readSnapshotStateFromFile(maybePath);
|
|
103
155
|
}
|
|
104
156
|
|
|
105
|
-
// git ref mode: flecto ci file --snapshot-ref HEAD~1
|
|
106
157
|
const rel = relative(process.cwd(), filePath).replaceAll('\\', '/');
|
|
107
158
|
const raw = execFileSync('git', ['show', `${snapshotRef}:${rel}`], { encoding: 'utf8' });
|
|
108
159
|
return parseContent(filePath, raw);
|
|
@@ -138,11 +189,14 @@ function printCiOutput(results, format) {
|
|
|
138
189
|
for (const result of results) {
|
|
139
190
|
for (const event of result.envelope.changes) {
|
|
140
191
|
const title = `flecto ${event.type}`;
|
|
141
|
-
|
|
192
|
+
const detail = event.note ? `${event.path} (${event.note})` : event.path;
|
|
193
|
+
console.log(`::warning file=${result.file},title=${title}::${detail}`);
|
|
142
194
|
}
|
|
143
195
|
for (const finding of result.policies) {
|
|
144
196
|
const level = finding.severity === 'error' ? 'error' : 'warning';
|
|
145
|
-
|
|
197
|
+
const pack = finding.pack ? ` [${finding.pack}]` : '';
|
|
198
|
+
const title = `flecto policy ${finding.id}${pack}`;
|
|
199
|
+
console.log(`::${level} file=${result.file},title=${title}::${finding.path}: ${finding.message}`);
|
|
146
200
|
}
|
|
147
201
|
}
|
|
148
202
|
}
|
|
@@ -151,12 +205,12 @@ function printCiOutput(results, format) {
|
|
|
151
205
|
program
|
|
152
206
|
.name('flecto')
|
|
153
207
|
.description('Flecto — semantic config watcher for meaningful structured file changes')
|
|
154
|
-
.version(
|
|
208
|
+
.version(PKG.version);
|
|
155
209
|
|
|
156
210
|
program
|
|
157
211
|
.command('watch [files...]')
|
|
158
212
|
.description('Watch config files/globs for semantic changes')
|
|
159
|
-
.option('-p, --profile <name>', 'Use profile from .flectorc')
|
|
213
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
160
214
|
.option('-i, --interval <ms>', 'Polling fallback interval in ms', '100')
|
|
161
215
|
.option('--polling', 'Force polling mode (useful on network drives / some editors)', false)
|
|
162
216
|
.option('-m, --mode <mode>', 'Output mode: compact | verbose', 'compact')
|
|
@@ -171,12 +225,20 @@ program
|
|
|
171
225
|
.option('--webhook-timeout <ms>', 'Webhook timeout in ms', '5000')
|
|
172
226
|
.option('--webhook-retries <n>', 'Webhook retries', '2')
|
|
173
227
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
228
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
|
|
229
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
230
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
|
|
231
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
232
|
+
.option('--mask-secrets', 'Mask secret-like values in human output', false)
|
|
233
|
+
.option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
|
|
174
234
|
.option('--snapshot', 'Save current state as baseline instead of watching')
|
|
175
235
|
.option('--diff', 'Diff current file against saved baseline and exit')
|
|
176
236
|
.action(async (files, opts) => {
|
|
177
237
|
try {
|
|
178
238
|
const { config } = loadRcConfig(process.cwd());
|
|
179
|
-
const
|
|
239
|
+
const profile = resolveProfileName(opts.profile);
|
|
240
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
|
|
241
|
+
const { policies, plugins } = resolvePolicyOptions(effective);
|
|
180
242
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
181
243
|
if (targets.length === 0) {
|
|
182
244
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -188,6 +250,9 @@ program
|
|
|
188
250
|
validateInterval(interval);
|
|
189
251
|
const mode = String(effective.mode ?? 'compact');
|
|
190
252
|
validateMode(mode);
|
|
253
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
254
|
+
const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
|
|
255
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
191
256
|
|
|
192
257
|
if (effective.snapshot) {
|
|
193
258
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
@@ -211,8 +276,8 @@ program
|
|
|
211
276
|
}
|
|
212
277
|
const before = readSnapshotStateFromFile(snapshotPath);
|
|
213
278
|
const after = parseFile(filepath);
|
|
214
|
-
const events = diffTrees(before, after,
|
|
215
|
-
renderDiff(filepath, events);
|
|
279
|
+
const events = diffTrees(before, after, dOpts);
|
|
280
|
+
renderDiff(filepath, events, { maskSecrets });
|
|
216
281
|
if (events.length > 0) hasChanges = true;
|
|
217
282
|
}
|
|
218
283
|
process.exit(hasChanges ? 1 : 0);
|
|
@@ -232,17 +297,32 @@ program
|
|
|
232
297
|
renderInfo(`flecto watching ${chalk.cyan(filepath)}`);
|
|
233
298
|
const watcher = startWatcher(
|
|
234
299
|
filepath,
|
|
235
|
-
{ interval, mode, ignorePaths, polling: Boolean(effective.polling) },
|
|
300
|
+
{ interval, mode, ignorePaths, polling: Boolean(effective.polling), ...dOpts },
|
|
236
301
|
async (event) => {
|
|
237
302
|
if (event.kind === 'changes') {
|
|
238
|
-
renderChanges(event.filepath, event.events, mode);
|
|
239
|
-
|
|
303
|
+
renderChanges(event.filepath, event.events, mode, { maskSecrets });
|
|
304
|
+
let policyFindings = [];
|
|
305
|
+
try {
|
|
306
|
+
policyFindings = await evaluatePolicies(event.events, {
|
|
307
|
+
cwd: process.cwd(),
|
|
308
|
+
file: event.filepath,
|
|
309
|
+
profile: profile ?? null,
|
|
310
|
+
source: 'watch',
|
|
311
|
+
policies,
|
|
312
|
+
plugins,
|
|
313
|
+
});
|
|
314
|
+
} catch (err) {
|
|
315
|
+
renderError(`policy evaluation failed: ${err.message}`);
|
|
316
|
+
if (String(effective.onAlertFailure) === 'exit') process.exitCode = 1;
|
|
317
|
+
}
|
|
240
318
|
renderPolicyFindings(policyFindings);
|
|
241
319
|
if (effective.command || effective.webhook) {
|
|
320
|
+
const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
|
|
242
321
|
const envelope = createEnvelope({
|
|
243
322
|
source: 'watch',
|
|
244
323
|
file: event.filepath,
|
|
245
|
-
changes:
|
|
324
|
+
changes: outboundChanges,
|
|
325
|
+
policies: policyFindings,
|
|
246
326
|
});
|
|
247
327
|
await fireAlerts({
|
|
248
328
|
command: effective.command,
|
|
@@ -301,15 +381,22 @@ program
|
|
|
301
381
|
program
|
|
302
382
|
.command('ci [files...]')
|
|
303
383
|
.description('Run semantic diff in CI mode')
|
|
304
|
-
.option('-p, --profile <name>', 'Use profile from .flectorc')
|
|
384
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
305
385
|
.option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
|
|
306
386
|
.option('--format <type>', 'Output format: json | ndjson | github-annotations', 'json')
|
|
307
387
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
|
|
308
388
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
389
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
390
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
391
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
|
|
392
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
393
|
+
.option('--mask-secrets', 'Mask secret-like values in CI output', false)
|
|
309
394
|
.action(async (files, opts) => {
|
|
310
395
|
try {
|
|
311
396
|
const { config } = loadRcConfig(process.cwd());
|
|
312
|
-
const
|
|
397
|
+
const profile = resolveProfileName(opts.profile);
|
|
398
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
|
|
399
|
+
const { policies: packIds, plugins } = resolvePolicyOptions(effective);
|
|
313
400
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
314
401
|
if (targets.length === 0) {
|
|
315
402
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -321,6 +408,8 @@ program
|
|
|
321
408
|
if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
322
409
|
throw new Error('--format must be json, ndjson, or github-annotations');
|
|
323
410
|
}
|
|
411
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
412
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
324
413
|
|
|
325
414
|
/** @type {any[]} */
|
|
326
415
|
const results = [];
|
|
@@ -329,22 +418,34 @@ program
|
|
|
329
418
|
for (const filepath of targets) {
|
|
330
419
|
if (!existsSync(filepath) || !isSupported(filepath)) continue;
|
|
331
420
|
const after = parseFile(filepath);
|
|
332
|
-
let before
|
|
421
|
+
let before;
|
|
333
422
|
try {
|
|
334
423
|
before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
|
|
335
|
-
} catch {
|
|
336
|
-
|
|
424
|
+
} catch (err) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`Failed to resolve snapshot baseline for "${filepath}"` +
|
|
427
|
+
`${effective.snapshotRef ? ` (ref: ${effective.snapshotRef})` : ''}: ${err.message}`
|
|
428
|
+
);
|
|
337
429
|
}
|
|
338
|
-
const events = diffTrees(before, after,
|
|
339
|
-
const
|
|
430
|
+
const events = diffTrees(before, after, dOpts);
|
|
431
|
+
const policyFindings = await evaluatePolicies(events, {
|
|
432
|
+
cwd: process.cwd(),
|
|
433
|
+
file: filepath,
|
|
434
|
+
profile: profile ?? null,
|
|
435
|
+
source: 'ci',
|
|
436
|
+
policies: packIds,
|
|
437
|
+
plugins,
|
|
438
|
+
});
|
|
439
|
+
const outboundChanges = maybeMaskChanges(events, maskSecrets);
|
|
340
440
|
const envelope = createEnvelope({
|
|
341
441
|
source: 'ci',
|
|
342
442
|
file: filepath,
|
|
343
|
-
changes:
|
|
443
|
+
changes: outboundChanges,
|
|
444
|
+
policies: policyFindings,
|
|
344
445
|
});
|
|
345
|
-
results.push({ file: filepath, envelope, policies });
|
|
446
|
+
results.push({ file: filepath, envelope, policies: policyFindings });
|
|
346
447
|
|
|
347
|
-
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(
|
|
448
|
+
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
348
449
|
shouldFail = true;
|
|
349
450
|
}
|
|
350
451
|
}
|
|
@@ -388,6 +489,7 @@ program
|
|
|
388
489
|
throw new Error('Global fetch unavailable. Use Node.js >= 18.');
|
|
389
490
|
}
|
|
390
491
|
renderInfo('fetch: available');
|
|
492
|
+
renderInfo(`version: ${PKG.version}`);
|
|
391
493
|
renderInfo('doctor: OK');
|
|
392
494
|
} catch (err) {
|
|
393
495
|
renderError(`doctor failed: ${err.message}`);
|
|
@@ -397,7 +499,6 @@ program
|
|
|
397
499
|
|
|
398
500
|
program.parse(process.argv);
|
|
399
501
|
|
|
400
|
-
// Show help if no command given
|
|
401
502
|
if (!process.argv.slice(2).length) {
|
|
402
503
|
program.help();
|
|
403
504
|
}
|
package/package.json
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"provenance": true
|
|
6
6
|
},
|
|
7
|
-
"version": "
|
|
7
|
+
"version": "2.0.0",
|
|
8
8
|
"description": "Flecto — semantic config watcher that reports meaningful changes in plain English",
|
|
9
|
+
"license": "MIT",
|
|
9
10
|
"keywords": [
|
|
10
11
|
"flecto",
|
|
11
12
|
"cli",
|
|
@@ -34,8 +35,9 @@
|
|
|
34
35
|
"files": [
|
|
35
36
|
"index.js",
|
|
36
37
|
"src/**/*",
|
|
38
|
+
"schemas/**/*",
|
|
37
39
|
"README.md",
|
|
38
|
-
"
|
|
40
|
+
"LICENSE"
|
|
39
41
|
],
|
|
40
42
|
"scripts": {
|
|
41
43
|
"test": "node --test test/*.test.js",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/myselfsiddharth/Flecto/schemas/flecto-envelope-2.0.json",
|
|
4
|
+
"title": "FlectoEnvelope",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"event_id",
|
|
10
|
+
"batch_id",
|
|
11
|
+
"event_type",
|
|
12
|
+
"source",
|
|
13
|
+
"emitted_at",
|
|
14
|
+
"file",
|
|
15
|
+
"changes"
|
|
16
|
+
],
|
|
17
|
+
"properties": {
|
|
18
|
+
"schema_version": { "const": "2.0" },
|
|
19
|
+
"event_id": { "type": "string", "minLength": 1 },
|
|
20
|
+
"batch_id": { "type": "string", "minLength": 1 },
|
|
21
|
+
"event_type": { "enum": ["changes", "lifecycle"] },
|
|
22
|
+
"source": { "enum": ["watch", "ci", "diff"] },
|
|
23
|
+
"emitted_at": { "type": "string", "format": "date-time" },
|
|
24
|
+
"file": { "type": "string" },
|
|
25
|
+
"changes": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"items": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"required": ["type", "path"],
|
|
30
|
+
"properties": {
|
|
31
|
+
"type": { "enum": ["added", "removed", "changed"] },
|
|
32
|
+
"path": { "type": "string" },
|
|
33
|
+
"before": true,
|
|
34
|
+
"after": true,
|
|
35
|
+
"note": { "type": "string" }
|
|
36
|
+
},
|
|
37
|
+
"additionalProperties": false
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"policies": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"items": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"required": ["id", "severity", "path", "message"],
|
|
45
|
+
"properties": {
|
|
46
|
+
"id": { "type": "string" },
|
|
47
|
+
"severity": { "enum": ["info", "warn", "error"] },
|
|
48
|
+
"path": { "type": "string" },
|
|
49
|
+
"message": { "type": "string" },
|
|
50
|
+
"pack": { "type": "string" }
|
|
51
|
+
},
|
|
52
|
+
"additionalProperties": false
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"lifecycle": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["type", "message"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"type": { "type": "string" },
|
|
60
|
+
"message": { "type": "string" }
|
|
61
|
+
},
|
|
62
|
+
"additionalProperties": false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/alerter.js
CHANGED
|
@@ -20,7 +20,7 @@ function enqueue(fn) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* @param {import('./envelope.js').
|
|
23
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
24
24
|
*/
|
|
25
25
|
function buildCommandEnv(envelope) {
|
|
26
26
|
const json = JSON.stringify(envelope.changes);
|
|
@@ -54,7 +54,7 @@ function buildCommandEnv(envelope) {
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* @param {string} command
|
|
57
|
-
* @param {import('./envelope.js').
|
|
57
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
58
58
|
* @returns {Promise<boolean>}
|
|
59
59
|
*/
|
|
60
60
|
export function runCommand(command, envelope) {
|
|
@@ -93,7 +93,7 @@ function sleep(ms) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* @param {import('./envelope.js').
|
|
96
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
97
97
|
*/
|
|
98
98
|
function enqueuePersistent(envelope) {
|
|
99
99
|
mkdirSync(ALERT_QUEUE_DIR, { recursive: true });
|
|
@@ -102,7 +102,7 @@ function enqueuePersistent(envelope) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* @param {(envelope: import('./envelope.js').
|
|
105
|
+
* @param {(envelope: import('./envelope.js').FlectoEnvelope) => Promise<boolean>} deliver
|
|
106
106
|
*/
|
|
107
107
|
async function flushPersistentQueue(deliver) {
|
|
108
108
|
try {
|
|
@@ -133,7 +133,7 @@ async function flushPersistentQueue(deliver) {
|
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
135
|
* @param {string} url
|
|
136
|
-
* @param {import('./envelope.js').
|
|
136
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
137
137
|
* @param {{ headers?: Record<string, string>, timeoutMs?: number, retries?: number }} [options]
|
|
138
138
|
* @returns {Promise<boolean>}
|
|
139
139
|
*/
|
|
@@ -186,7 +186,7 @@ export async function postWebhook(url, envelope, options = {}) {
|
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
188
|
* @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookTimeoutMs?: number, webhookRetries?: number }} options
|
|
189
|
-
* @param {import('./envelope.js').
|
|
189
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
190
190
|
*/
|
|
191
191
|
async function deliverWebhook(options, envelope) {
|
|
192
192
|
if (!options.webhook) return true;
|
|
@@ -219,7 +219,7 @@ function applyFailurePolicy(options, ok) {
|
|
|
219
219
|
* deliveryMode?: 'best-effort' | 'at-least-once',
|
|
220
220
|
* onAlertFailure?: 'warn' | 'exit' | 'retry'
|
|
221
221
|
* }} options
|
|
222
|
-
* @param {import('./envelope.js').
|
|
222
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
223
223
|
* @returns {Promise<{ ok: boolean }>}
|
|
224
224
|
*/
|
|
225
225
|
export async function fireAlerts(options, envelope) {
|
package/src/config.js
CHANGED
|
@@ -43,6 +43,17 @@ export function loadRcConfig(cwd = process.cwd()) {
|
|
|
43
43
|
return { path: null, config: null };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Resolve profile name: CLI > FLECTO_PROFILE > none.
|
|
48
|
+
* @param {string | undefined} cliProfile
|
|
49
|
+
* @returns {string | undefined}
|
|
50
|
+
*/
|
|
51
|
+
export function resolveProfileName(cliProfile) {
|
|
52
|
+
if (cliProfile) return String(cliProfile);
|
|
53
|
+
if (process.env.FLECTO_PROFILE) return String(process.env.FLECTO_PROFILE);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
/**
|
|
47
58
|
* Resolve effective options with optional profile and CLI overrides.
|
|
48
59
|
* @param {FlectoRc | null} config
|
|
@@ -55,6 +66,26 @@ export function resolveEffectiveOptions(config, profile, cliOverrides = {}) {
|
|
|
55
66
|
return { ...defaults, ...profileOptions, ...cliOverrides };
|
|
56
67
|
}
|
|
57
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Normalize policy-related effective options.
|
|
71
|
+
* @param {Record<string, unknown>} effective
|
|
72
|
+
*/
|
|
73
|
+
export function resolvePolicyOptions(effective) {
|
|
74
|
+
const policiesRaw = effective.policies;
|
|
75
|
+
const pluginsRaw = effective.plugins;
|
|
76
|
+
const policies = Array.isArray(policiesRaw)
|
|
77
|
+
? policiesRaw.map(String)
|
|
78
|
+
: typeof policiesRaw === 'string'
|
|
79
|
+
? String(policiesRaw).split(',').map((s) => s.trim()).filter(Boolean)
|
|
80
|
+
: ['default'];
|
|
81
|
+
const plugins = Array.isArray(pluginsRaw)
|
|
82
|
+
? pluginsRaw.map(String)
|
|
83
|
+
: typeof pluginsRaw === 'string'
|
|
84
|
+
? String(pluginsRaw).split(',').map((s) => s.trim()).filter(Boolean)
|
|
85
|
+
: [];
|
|
86
|
+
return { policies, plugins };
|
|
87
|
+
}
|
|
88
|
+
|
|
58
89
|
/**
|
|
59
90
|
* Expand file patterns from rc include/files and direct CLI inputs.
|
|
60
91
|
* @param {{ cwd?: string, files?: string[], include?: string[], exclude?: string[] }} input
|
|
@@ -93,15 +124,20 @@ export function initRcFile(cwd = process.cwd()) {
|
|
|
93
124
|
ignore: ['**.updated_at'],
|
|
94
125
|
deliveryMode: 'best-effort',
|
|
95
126
|
onAlertFailure: 'warn',
|
|
127
|
+
policies: ['default'],
|
|
128
|
+
plugins: [],
|
|
129
|
+
arrayIdKey: null,
|
|
130
|
+
arrayIgnoreOrder: false,
|
|
131
|
+
maskSecrets: false,
|
|
96
132
|
},
|
|
97
133
|
profiles: {
|
|
98
134
|
dev: { mode: 'verbose' },
|
|
99
135
|
ci: { failOn: 'policy,error' },
|
|
136
|
+
prod: { policies: ['default', 'strict-prod'], maskSecrets: true },
|
|
100
137
|
},
|
|
101
|
-
files: ['config/**/*.yaml', '.env'],
|
|
138
|
+
files: ['config/**/*.{yaml,yml,json,toml,ini}', '.env', '.env.*', '*.env'],
|
|
102
139
|
exclude: ['**/node_modules/**'],
|
|
103
140
|
};
|
|
104
141
|
writeFileSync(path, JSON.stringify(starter, null, 2), 'utf8');
|
|
105
142
|
return path;
|
|
106
143
|
}
|
|
107
|
-
|