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 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 `TOML` file changes, Flecto shows you what actually happened:
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": "1.1",
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 { renderChanges, renderDiff, renderError, renderInfo, renderWarn, renderPolicyFindings } from './src/renderer.js';
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 { loadRcConfig, resolveEffectiveOptions, resolveFiles, initRcFile } from './src/config.js';
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
- console.log(`::warning file=${result.file},title=${title}::${event.path}`);
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
- console.log(`::${level} file=${result.file},title=policy::${finding.path} ${finding.message}`);
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('1.0.0');
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 effective = resolveEffectiveOptions(config, opts.profile, opts);
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, { ignorePaths });
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
- const policyFindings = evaluatePolicies(event.events);
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: event.events,
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 effective = resolveEffectiveOptions(config, opts.profile, opts);
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
- before = {};
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, { ignorePaths });
339
- const policies = evaluatePolicies(events);
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: events,
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(policies, failOn)) {
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": "1.0.1",
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
- "README_RECRUITERS.md"
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').SentinelEnvelope} envelope
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').SentinelEnvelope} envelope
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').SentinelEnvelope} envelope
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').SentinelEnvelope) => Promise<boolean>} deliver
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').SentinelEnvelope} envelope
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').SentinelEnvelope} envelope
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').SentinelEnvelope} envelope
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
-