flecto 1.0.2 → 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 +121 -25
- package/package.json +2 -1
- 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 -35
- 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
|
@@ -11,11 +11,26 @@ import chalk from 'chalk';
|
|
|
11
11
|
import { parseFile, isSupported, parseContent } from './src/parser.js';
|
|
12
12
|
import { diffTrees } from './src/differ.js';
|
|
13
13
|
import { startWatcher } from './src/watcher.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
renderChanges,
|
|
16
|
+
renderDiff,
|
|
17
|
+
renderError,
|
|
18
|
+
renderInfo,
|
|
19
|
+
renderWarn,
|
|
20
|
+
renderPolicyFindings,
|
|
21
|
+
maskChangeEvent,
|
|
22
|
+
} from './src/renderer.js';
|
|
15
23
|
import { fireAlerts } from './src/alerter.js';
|
|
16
24
|
import { createEnvelope } from './src/envelope.js';
|
|
17
25
|
import { evaluatePolicies, highestSeverity } from './src/policy.js';
|
|
18
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
loadRcConfig,
|
|
28
|
+
resolveEffectiveOptions,
|
|
29
|
+
resolveFiles,
|
|
30
|
+
initRcFile,
|
|
31
|
+
resolveProfileName,
|
|
32
|
+
resolvePolicyOptions,
|
|
33
|
+
} from './src/config.js';
|
|
19
34
|
|
|
20
35
|
const PKG = JSON.parse(
|
|
21
36
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
|
|
@@ -24,7 +39,6 @@ const PKG = JSON.parse(
|
|
|
24
39
|
const SNAPSHOT_DIR = '.flecto-snapshots';
|
|
25
40
|
|
|
26
41
|
function snapshotIdForPath(absPath) {
|
|
27
|
-
// Stable across platforms and avoids basename collisions
|
|
28
42
|
const normalized = absPath.replaceAll('\\', '/');
|
|
29
43
|
return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
30
44
|
}
|
|
@@ -66,6 +80,39 @@ function validateInterval(interval) {
|
|
|
66
80
|
}
|
|
67
81
|
}
|
|
68
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
|
+
|
|
69
116
|
async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
70
117
|
if (cliFiles && cliFiles.length > 0) {
|
|
71
118
|
const direct = [];
|
|
@@ -107,7 +154,6 @@ function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
|
107
154
|
return readSnapshotStateFromFile(maybePath);
|
|
108
155
|
}
|
|
109
156
|
|
|
110
|
-
// git ref mode: flecto ci file --snapshot-ref HEAD~1
|
|
111
157
|
const rel = relative(process.cwd(), filePath).replaceAll('\\', '/');
|
|
112
158
|
const raw = execFileSync('git', ['show', `${snapshotRef}:${rel}`], { encoding: 'utf8' });
|
|
113
159
|
return parseContent(filePath, raw);
|
|
@@ -143,11 +189,14 @@ function printCiOutput(results, format) {
|
|
|
143
189
|
for (const result of results) {
|
|
144
190
|
for (const event of result.envelope.changes) {
|
|
145
191
|
const title = `flecto ${event.type}`;
|
|
146
|
-
|
|
192
|
+
const detail = event.note ? `${event.path} (${event.note})` : event.path;
|
|
193
|
+
console.log(`::warning file=${result.file},title=${title}::${detail}`);
|
|
147
194
|
}
|
|
148
195
|
for (const finding of result.policies) {
|
|
149
196
|
const level = finding.severity === 'error' ? 'error' : 'warning';
|
|
150
|
-
|
|
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}`);
|
|
151
200
|
}
|
|
152
201
|
}
|
|
153
202
|
}
|
|
@@ -161,7 +210,7 @@ program
|
|
|
161
210
|
program
|
|
162
211
|
.command('watch [files...]')
|
|
163
212
|
.description('Watch config files/globs for semantic changes')
|
|
164
|
-
.option('-p, --profile <name>', 'Use profile from .flectorc')
|
|
213
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
165
214
|
.option('-i, --interval <ms>', 'Polling fallback interval in ms', '100')
|
|
166
215
|
.option('--polling', 'Force polling mode (useful on network drives / some editors)', false)
|
|
167
216
|
.option('-m, --mode <mode>', 'Output mode: compact | verbose', 'compact')
|
|
@@ -176,12 +225,20 @@ program
|
|
|
176
225
|
.option('--webhook-timeout <ms>', 'Webhook timeout in ms', '5000')
|
|
177
226
|
.option('--webhook-retries <n>', 'Webhook retries', '2')
|
|
178
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)
|
|
179
234
|
.option('--snapshot', 'Save current state as baseline instead of watching')
|
|
180
235
|
.option('--diff', 'Diff current file against saved baseline and exit')
|
|
181
236
|
.action(async (files, opts) => {
|
|
182
237
|
try {
|
|
183
238
|
const { config } = loadRcConfig(process.cwd());
|
|
184
|
-
const
|
|
239
|
+
const profile = resolveProfileName(opts.profile);
|
|
240
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
|
|
241
|
+
const { policies, plugins } = resolvePolicyOptions(effective);
|
|
185
242
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
186
243
|
if (targets.length === 0) {
|
|
187
244
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -193,6 +250,9 @@ program
|
|
|
193
250
|
validateInterval(interval);
|
|
194
251
|
const mode = String(effective.mode ?? 'compact');
|
|
195
252
|
validateMode(mode);
|
|
253
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
254
|
+
const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
|
|
255
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
196
256
|
|
|
197
257
|
if (effective.snapshot) {
|
|
198
258
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
@@ -216,8 +276,8 @@ program
|
|
|
216
276
|
}
|
|
217
277
|
const before = readSnapshotStateFromFile(snapshotPath);
|
|
218
278
|
const after = parseFile(filepath);
|
|
219
|
-
const events = diffTrees(before, after,
|
|
220
|
-
renderDiff(filepath, events);
|
|
279
|
+
const events = diffTrees(before, after, dOpts);
|
|
280
|
+
renderDiff(filepath, events, { maskSecrets });
|
|
221
281
|
if (events.length > 0) hasChanges = true;
|
|
222
282
|
}
|
|
223
283
|
process.exit(hasChanges ? 1 : 0);
|
|
@@ -237,17 +297,32 @@ program
|
|
|
237
297
|
renderInfo(`flecto watching ${chalk.cyan(filepath)}`);
|
|
238
298
|
const watcher = startWatcher(
|
|
239
299
|
filepath,
|
|
240
|
-
{ interval, mode, ignorePaths, polling: Boolean(effective.polling) },
|
|
300
|
+
{ interval, mode, ignorePaths, polling: Boolean(effective.polling), ...dOpts },
|
|
241
301
|
async (event) => {
|
|
242
302
|
if (event.kind === 'changes') {
|
|
243
|
-
renderChanges(event.filepath, event.events, mode);
|
|
244
|
-
|
|
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
|
+
}
|
|
245
318
|
renderPolicyFindings(policyFindings);
|
|
246
319
|
if (effective.command || effective.webhook) {
|
|
320
|
+
const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
|
|
247
321
|
const envelope = createEnvelope({
|
|
248
322
|
source: 'watch',
|
|
249
323
|
file: event.filepath,
|
|
250
|
-
changes:
|
|
324
|
+
changes: outboundChanges,
|
|
325
|
+
policies: policyFindings,
|
|
251
326
|
});
|
|
252
327
|
await fireAlerts({
|
|
253
328
|
command: effective.command,
|
|
@@ -306,15 +381,22 @@ program
|
|
|
306
381
|
program
|
|
307
382
|
.command('ci [files...]')
|
|
308
383
|
.description('Run semantic diff in CI mode')
|
|
309
|
-
.option('-p, --profile <name>', 'Use profile from .flectorc')
|
|
384
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
310
385
|
.option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
|
|
311
386
|
.option('--format <type>', 'Output format: json | ndjson | github-annotations', 'json')
|
|
312
387
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
|
|
313
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)
|
|
314
394
|
.action(async (files, opts) => {
|
|
315
395
|
try {
|
|
316
396
|
const { config } = loadRcConfig(process.cwd());
|
|
317
|
-
const
|
|
397
|
+
const profile = resolveProfileName(opts.profile);
|
|
398
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
|
|
399
|
+
const { policies: packIds, plugins } = resolvePolicyOptions(effective);
|
|
318
400
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
319
401
|
if (targets.length === 0) {
|
|
320
402
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -326,6 +408,8 @@ program
|
|
|
326
408
|
if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
327
409
|
throw new Error('--format must be json, ndjson, or github-annotations');
|
|
328
410
|
}
|
|
411
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
412
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
329
413
|
|
|
330
414
|
/** @type {any[]} */
|
|
331
415
|
const results = [];
|
|
@@ -334,22 +418,34 @@ program
|
|
|
334
418
|
for (const filepath of targets) {
|
|
335
419
|
if (!existsSync(filepath) || !isSupported(filepath)) continue;
|
|
336
420
|
const after = parseFile(filepath);
|
|
337
|
-
let before
|
|
421
|
+
let before;
|
|
338
422
|
try {
|
|
339
423
|
before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
|
|
340
|
-
} catch {
|
|
341
|
-
|
|
424
|
+
} catch (err) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`Failed to resolve snapshot baseline for "${filepath}"` +
|
|
427
|
+
`${effective.snapshotRef ? ` (ref: ${effective.snapshotRef})` : ''}: ${err.message}`
|
|
428
|
+
);
|
|
342
429
|
}
|
|
343
|
-
const events = diffTrees(before, after,
|
|
344
|
-
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);
|
|
345
440
|
const envelope = createEnvelope({
|
|
346
441
|
source: 'ci',
|
|
347
442
|
file: filepath,
|
|
348
|
-
changes:
|
|
443
|
+
changes: outboundChanges,
|
|
444
|
+
policies: policyFindings,
|
|
349
445
|
});
|
|
350
|
-
results.push({ file: filepath, envelope, policies });
|
|
446
|
+
results.push({ file: filepath, envelope, policies: policyFindings });
|
|
351
447
|
|
|
352
|
-
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(
|
|
448
|
+
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
353
449
|
shouldFail = true;
|
|
354
450
|
}
|
|
355
451
|
}
|
|
@@ -393,6 +489,7 @@ program
|
|
|
393
489
|
throw new Error('Global fetch unavailable. Use Node.js >= 18.');
|
|
394
490
|
}
|
|
395
491
|
renderInfo('fetch: available');
|
|
492
|
+
renderInfo(`version: ${PKG.version}`);
|
|
396
493
|
renderInfo('doctor: OK');
|
|
397
494
|
} catch (err) {
|
|
398
495
|
renderError(`doctor failed: ${err.message}`);
|
|
@@ -402,7 +499,6 @@ program
|
|
|
402
499
|
|
|
403
500
|
program.parse(process.argv);
|
|
404
501
|
|
|
405
|
-
// Show help if no command given
|
|
406
502
|
if (!process.argv.slice(2).length) {
|
|
407
503
|
program.help();
|
|
408
504
|
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
9
|
"license": "MIT",
|
|
10
10
|
"keywords": [
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"files": [
|
|
36
36
|
"index.js",
|
|
37
37
|
"src/**/*",
|
|
38
|
+
"schemas/**/*",
|
|
38
39
|
"README.md",
|
|
39
40
|
"LICENSE"
|
|
40
41
|
],
|
|
@@ -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
|
-
|