flecto 3.1.0 → 4.1.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/CHANGELOG.md +262 -1
- package/README.md +46 -3
- package/drift.js +226 -0
- package/index.js +527 -31
- package/package.json +24 -14
- package/src/alerter.js +272 -24
- package/src/config.js +208 -2
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1063 -0
- package/src/pr-comment.js +33 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +24 -0
- package/src/snapshot-store.js +6 -2
package/index.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
4
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
|
|
5
|
-
import { resolve, relative, dirname, join } from 'path';
|
|
5
|
+
import { resolve, relative, dirname, basename, join, isAbsolute } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { execFileSync } from 'child_process';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
|
|
10
10
|
import { parseFile, isSupported, parseContent } from './src/parser.js';
|
|
11
|
-
import { diffTrees
|
|
11
|
+
import { diffTrees } from './src/differ.js';
|
|
12
12
|
import { documentKeysOf, withDocumentKeys } from './src/documents.js';
|
|
13
13
|
import { startWatcher } from './src/watcher.js';
|
|
14
14
|
import {
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
renderWarn,
|
|
21
21
|
renderPolicyFindings,
|
|
22
22
|
maskChangeEvent,
|
|
23
|
-
|
|
23
|
+
maskFindings,
|
|
24
24
|
} from './src/renderer.js';
|
|
25
25
|
import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
|
|
26
26
|
import { PR_PROVIDER_IDS } from './src/pr-providers.js';
|
|
@@ -30,10 +30,10 @@ import {
|
|
|
30
30
|
readTerraformPlanFile,
|
|
31
31
|
} from './src/terraform.js';
|
|
32
32
|
import { renderReportHtml } from './src/report.js';
|
|
33
|
-
import { redactSecretString } from './src/secrets.js';
|
|
34
33
|
import { fireAlerts } from './src/alerter.js';
|
|
35
34
|
import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
|
|
36
35
|
import { createEnvelope } from './src/envelope.js';
|
|
36
|
+
import { startLanguageServer } from './src/lsp.js';
|
|
37
37
|
import { buildSarif } from './src/sarif.js';
|
|
38
38
|
import {
|
|
39
39
|
maskState,
|
|
@@ -60,6 +60,18 @@ import {
|
|
|
60
60
|
addPolicyPackFromPackage,
|
|
61
61
|
} from './src/policy.js';
|
|
62
62
|
import { testPolicyFixture } from './src/policy-test.js';
|
|
63
|
+
import { makeCliRunner, runStdioServer } from './src/mcp.js';
|
|
64
|
+
import {
|
|
65
|
+
assertExplainNotFromRc,
|
|
66
|
+
buildExplainPayload,
|
|
67
|
+
buildExplainRequest,
|
|
68
|
+
describeRequest,
|
|
69
|
+
estimateInputTokens,
|
|
70
|
+
EXPLAIN_PROVIDERS,
|
|
71
|
+
formatNarration,
|
|
72
|
+
narrate,
|
|
73
|
+
resolveExplainConfig,
|
|
74
|
+
} from './src/explain.js';
|
|
63
75
|
import {
|
|
64
76
|
loadRcConfig,
|
|
65
77
|
resolveEffectiveOptions,
|
|
@@ -69,6 +81,9 @@ import {
|
|
|
69
81
|
resolvePolicyOptions,
|
|
70
82
|
assertTargetContained,
|
|
71
83
|
assertWriteDestinationContained,
|
|
84
|
+
assertAlertActionsFromCli,
|
|
85
|
+
assertSnapshotRefFromCli,
|
|
86
|
+
assertSafeGitRef,
|
|
72
87
|
} from './src/config.js';
|
|
73
88
|
|
|
74
89
|
const PKG = JSON.parse(
|
|
@@ -227,11 +242,6 @@ function maybeMaskChanges(events, maskSecrets) {
|
|
|
227
242
|
}
|
|
228
243
|
|
|
229
244
|
/**
|
|
230
|
-
* Redact secret-shaped text from policy messages. A rule using
|
|
231
|
-
* `messageTemplate` can interpolate `{before}` / `{after}`, so a finding can
|
|
232
|
-
* carry a credential even when the change events beside it are masked. Replace
|
|
233
|
-
* exact interpolated values using the same path-aware masking as change events,
|
|
234
|
-
* then catch any other recognizable secret fragments in free-form messages.
|
|
235
245
|
* @param {import('./src/policy.js').PolicyFinding[]} findings
|
|
236
246
|
* @param {import('./src/differ.js').ChangeEvent[]} changes
|
|
237
247
|
* @param {boolean} maskSecrets
|
|
@@ -239,17 +249,7 @@ function maybeMaskChanges(events, maskSecrets) {
|
|
|
239
249
|
*/
|
|
240
250
|
function maybeMaskFindings(findings, changes, maskSecrets) {
|
|
241
251
|
if (!maskSecrets) return findings;
|
|
242
|
-
return findings
|
|
243
|
-
let message = String(finding.message ?? '');
|
|
244
|
-
for (const change of changes.filter((event) => event.path === finding.path)) {
|
|
245
|
-
for (const value of [change.before, change.after]) {
|
|
246
|
-
const original = String(value);
|
|
247
|
-
const masked = String(maskSensitiveValue(value, secretMatchPath(change)));
|
|
248
|
-
if (original && original !== masked) message = message.replaceAll(original, masked);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return { ...finding, message: redactSecretString(message) };
|
|
252
|
-
});
|
|
252
|
+
return maskFindings(findings, changes);
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
/**
|
|
@@ -309,6 +309,37 @@ function restoreSnapshotDocumentKeys(state, snapshot) {
|
|
|
309
309
|
return withDocumentKeys(state, documents.map(String));
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Read a snapshot named as a path, with the containment every other read in
|
|
314
|
+
* Flecto already has.
|
|
315
|
+
*
|
|
316
|
+
* A baseline path is attacker-reachable in the shape that matters: the
|
|
317
|
+
* operator names an in-repo snapshot such as `.flecto/baseline.json`, and the
|
|
318
|
+
* pull request replaces that file with a symlink. Without this check the
|
|
319
|
+
* contents of any JSON file on the runner became the "baseline" and were
|
|
320
|
+
* printed as `removed` changes -- the same read-outside-the-repo leak that
|
|
321
|
+
* `assertTargetContained` was added for, and that `src/mcp.js` already refuses
|
|
322
|
+
* for a ref naming a file. This was the one remaining surface without the rule.
|
|
323
|
+
*
|
|
324
|
+
* `assertTargetContained` is about escape, not location, so an operator's
|
|
325
|
+
* deliberate absolute path to a snapshot outside the project still works.
|
|
326
|
+
* @param {string} given
|
|
327
|
+
* @param {string} option the flag to name in an error
|
|
328
|
+
* @returns {unknown}
|
|
329
|
+
*/
|
|
330
|
+
function readSnapshotFile(given, option) {
|
|
331
|
+
if (given === '') {
|
|
332
|
+
// An unset CI variable expands to this. Everything else here refuses
|
|
333
|
+
// loudly; silently falling back to the local store would be the one path
|
|
334
|
+
// that quietly compares against something the operator did not choose.
|
|
335
|
+
throw new Error(`${option} was given an empty value`);
|
|
336
|
+
}
|
|
337
|
+
const resolved = resolve(given);
|
|
338
|
+
assertTargetContained(resolved, process.cwd());
|
|
339
|
+
if (!existsSync(resolved)) throw new Error(`no snapshot file at "${given}"`);
|
|
340
|
+
return readSnapshotStateFromFile(resolved);
|
|
341
|
+
}
|
|
342
|
+
|
|
312
343
|
function readSnapshotStateFromFile(snapshotPath) {
|
|
313
344
|
const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
314
345
|
return restoreSnapshotDocumentKeys(snap?.state ?? snap, snap);
|
|
@@ -329,7 +360,41 @@ function gitRepoRelativePath(filePath) {
|
|
|
329
360
|
const top = execFileSync('git', ['-C', dirname(filePath), 'rev-parse', '--show-toplevel'], {
|
|
330
361
|
encoding: 'utf8',
|
|
331
362
|
}).trim();
|
|
332
|
-
|
|
363
|
+
// Canonicalize the *directory* and keep the name as written. Canonicalizing
|
|
364
|
+
// the file itself resolved its final symlink, so the path handed to
|
|
365
|
+
// `git show` was the link's destination rather than the path the operator
|
|
366
|
+
// gated -- and a pull request that replaced the gated file with a link to any
|
|
367
|
+
// other file unchanged in the baseline got `before == after`, a genuinely
|
|
368
|
+
// empty diff that no --fail-on value catches. Every reason the comment above
|
|
369
|
+
// gives for canonicalizing (Windows 8.3 and case, the macOS /tmp and /var
|
|
370
|
+
// links) is a property of directories, so none of it is lost.
|
|
371
|
+
const nominal = join(canonicalPath(dirname(filePath)), basename(filePath));
|
|
372
|
+
return relative(canonicalPath(top), nominal).replaceAll('\\', '/');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Is the entry at this path, in this commit, a symbolic link?
|
|
377
|
+
*
|
|
378
|
+
* Git stores a link as mode 120000 whose blob is the *target path*, so reading
|
|
379
|
+
* one as a config file yields the target string rather than any configuration.
|
|
380
|
+
* That is a baseline Flecto cannot honestly compare against, and saying so
|
|
381
|
+
* beats emitting a diff between a filename and a document.
|
|
382
|
+
* @param {string} commit
|
|
383
|
+
* @param {string} rel
|
|
384
|
+
* @param {string} dir
|
|
385
|
+
* @returns {boolean}
|
|
386
|
+
*/
|
|
387
|
+
function baselineEntryIsSymlink(commit, rel, dir) {
|
|
388
|
+
try {
|
|
389
|
+
const entry = execFileSync(
|
|
390
|
+
'git',
|
|
391
|
+
['-C', dir, 'ls-tree', '--format=%(objectmode)', '--end-of-options', commit, '--', rel],
|
|
392
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
393
|
+
).trim();
|
|
394
|
+
return entry === '120000';
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
333
398
|
}
|
|
334
399
|
|
|
335
400
|
/**
|
|
@@ -364,7 +429,109 @@ function canonicalPath(path) {
|
|
|
364
429
|
}
|
|
365
430
|
}
|
|
366
431
|
|
|
367
|
-
function
|
|
432
|
+
function gitFailureReason(err) {
|
|
433
|
+
if (err?.code === 'ENOENT') return 'git is not installed or not on PATH';
|
|
434
|
+
if (err?.status === 128) return 'this is not a git repository';
|
|
435
|
+
// The most likely remaining cause by far: `--end-of-options` arrived in git
|
|
436
|
+
// 2.24 (2019), and an older git rejects it as an unknown option. Naming a
|
|
437
|
+
// version someone can check beats printing the raw spawn failure.
|
|
438
|
+
return 'git rejected the command -- Flecto needs git 2.24 or newer'
|
|
439
|
+
+ ` (${String(err?.message ?? 'unknown error').split('\n')[0]})`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Resolve a ref to the single commit it names, or null when it names none.
|
|
444
|
+
*
|
|
445
|
+
* `git show` accepts far more than a commit: `A..B` is a *range*, and asking
|
|
446
|
+
* it to show one succeeds while printing nothing. An empty read then parses as
|
|
447
|
+
* an empty document, every key looks `added` rather than `changed`, and the
|
|
448
|
+
* default `--fail-on changed,policy,error` never fires -- the same silent-pass
|
|
449
|
+
* chain as the `--output=` injection, reached without a single dash.
|
|
450
|
+
* `rev-parse --verify <ref>^{commit}` refuses anything that is not exactly one
|
|
451
|
+
* commit, which closes the whole shape rather than the one spelling of it.
|
|
452
|
+
*
|
|
453
|
+
* Resolving also means the value handed to `git show` afterwards is a hex SHA
|
|
454
|
+
* this function produced, not a string the attacker wrote.
|
|
455
|
+
* @param {string} ref
|
|
456
|
+
* @param {string} dir a directory inside the repository
|
|
457
|
+
* @returns {string | null} the commit SHA, or null when `ref` is not a revision
|
|
458
|
+
*/
|
|
459
|
+
function resolveGitCommit(ref, dir) {
|
|
460
|
+
try {
|
|
461
|
+
const out = execFileSync(
|
|
462
|
+
'git',
|
|
463
|
+
['-C', dir, 'rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`],
|
|
464
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
465
|
+
).trim();
|
|
466
|
+
return { usable: true, commit: out || null };
|
|
467
|
+
} catch (err) {
|
|
468
|
+
// Exactly one failure means "git works, that revision is not here": exit 1
|
|
469
|
+
// from `--verify --quiet`. Everything else -- git missing (ENOENT), not a
|
|
470
|
+
// repository (128), an option this git does not know (pre-2.24) -- means
|
|
471
|
+
// Flecto cannot tell whether the ref exists. Collapsing those into "not a
|
|
472
|
+
// revision" is what let a missing git turn a baseline into whatever file
|
|
473
|
+
// happened to share the ref's name.
|
|
474
|
+
if (err?.status === 1) return { usable: true, commit: null };
|
|
475
|
+
return { usable: false, commit: null, reason: gitFailureReason(err) };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Is this value written the way a *path* is written, rather than a revision?
|
|
481
|
+
*
|
|
482
|
+
* `--snapshot-ref` has always accepted either a git revision or a snapshot
|
|
483
|
+
* file, and that ambiguity is the vulnerability: the file is resolved against
|
|
484
|
+
* the checkout root, whose file names an untrusted pull request controls. A
|
|
485
|
+
* pull request that commits a file named after the operator's ref replaces the
|
|
486
|
+
* baseline with one the attacker wrote, and since they can make it match their
|
|
487
|
+
* own tip exactly, the diff is genuinely empty -- no `--fail-on` setting
|
|
488
|
+
* catches it.
|
|
489
|
+
*
|
|
490
|
+
* The first attempt at this asked "does the value look like a revision?" and
|
|
491
|
+
* refused the file branch when it did. That is a denylist over a value space
|
|
492
|
+
* identical to the one it is trying to exclude: branch and tag names are
|
|
493
|
+
* ordinary words, so `origin/main`, `main`, and `v1.2.3` all fell through it
|
|
494
|
+
* and stayed exploitable -- and `origin/main` is the form this project's own
|
|
495
|
+
* documentation puts in a workflow.
|
|
496
|
+
*
|
|
497
|
+
* So the polarity is inverted. The *file* branch must prove itself, and the
|
|
498
|
+
* proof is a shape the git ref grammar cannot produce: an absolute path, or an
|
|
499
|
+
* explicit `./` or `../`. Everything else must resolve as a revision or the run
|
|
500
|
+
* fails, pointing at `--snapshot-file`. The failure mode is now "a snapshot
|
|
501
|
+
* file named like a ref is refused, and says which flag to use" rather than
|
|
502
|
+
* "an attacker's file is read as the baseline".
|
|
503
|
+
* @param {string} value
|
|
504
|
+
* @returns {boolean}
|
|
505
|
+
*/
|
|
506
|
+
function isExplicitPath(value) {
|
|
507
|
+
// git-check-ref-format forbids a ref whose component begins with `.` and a
|
|
508
|
+
// ref beginning with `/`, so none of these can name a revision. An earlier
|
|
509
|
+
// version of this also accepted a `.json`/`.yaml` suffix, which was the same
|
|
510
|
+
// mistake a third time: those ARE legal in a ref name, so `release/v1.json`
|
|
511
|
+
// skipped git entirely and read an attacker's committed file in preference to
|
|
512
|
+
// a real, resolvable tag of that name. A suffix is a convention; these are a
|
|
513
|
+
// grammar.
|
|
514
|
+
return isAbsolute(value)
|
|
515
|
+
|| value.startsWith('./')
|
|
516
|
+
|| value.startsWith('../')
|
|
517
|
+
|| value.startsWith('.\\')
|
|
518
|
+
|| value.startsWith('..\\');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile) {
|
|
522
|
+
if (snapshotRef === '') {
|
|
523
|
+
// The same unset-CI-variable case readSnapshotFile refuses. Falling back to
|
|
524
|
+
// the store here would compare against something the operator did not
|
|
525
|
+
// choose -- and on an untrusted pull request a committed shared store is
|
|
526
|
+
// something the change under review wrote.
|
|
527
|
+
throw new Error('--snapshot-ref was given an empty value');
|
|
528
|
+
}
|
|
529
|
+
// `--snapshot-file` is the unambiguous form: a path, never a revision. It
|
|
530
|
+
// exists because overloading one flag with both is what made an
|
|
531
|
+
// attacker-committed file able to stand in for the operator's baseline.
|
|
532
|
+
if (snapshotFile !== undefined) {
|
|
533
|
+
return readSnapshotFile(String(snapshotFile), '--snapshot-file');
|
|
534
|
+
}
|
|
368
535
|
if (!snapshotRef) {
|
|
369
536
|
// Failing closed here is right — a diff with no baseline is not a clean
|
|
370
537
|
// diff — but an ENOENT on a hashed filename explains nothing. The default
|
|
@@ -377,13 +544,54 @@ function readSnapshotStateFromRef(filePath, snapshotRef, store) {
|
|
|
377
544
|
}
|
|
378
545
|
return record.state;
|
|
379
546
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
547
|
+
// The path decision comes before assertSafeGitRef, which refuses `..` -- a
|
|
548
|
+
// legitimate `../snapshots/base.json` is a path, not a commit range.
|
|
549
|
+
if (isExplicitPath(String(snapshotRef))) {
|
|
550
|
+
return readSnapshotFile(String(snapshotRef), '--snapshot-ref');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const ref = assertSafeGitRef(snapshotRef);
|
|
554
|
+
const repoDir = dirname(resolve(filePath));
|
|
555
|
+
const { usable, commit, reason } = resolveGitCommit(ref, repoDir);
|
|
556
|
+
if (!usable) {
|
|
557
|
+
// Never fall through to a file here. Not knowing whether the revision
|
|
558
|
+
// exists is exactly when reading a same-named file is most dangerous.
|
|
559
|
+
throw new Error(
|
|
560
|
+
`cannot resolve "${ref}" as a git revision: ${reason}.`
|
|
561
|
+
+ ' Pass --snapshot-file <path> if you meant a snapshot file.',
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (!commit) {
|
|
565
|
+
const shadow = resolve(ref);
|
|
566
|
+
throw new Error(
|
|
567
|
+
`"${ref}" does not resolve to a git revision here`
|
|
568
|
+
+ ' (a shallow clone is the usual reason -- on a pull request, actions/checkout does not'
|
|
569
|
+
+ ' create origin/<base> unless you fetch it; try fetch-depth: 0).'
|
|
570
|
+
+ (existsSync(shadow)
|
|
571
|
+
? ` A file named "${ref}" exists and was NOT read as the baseline: a pull request can`
|
|
572
|
+
+ ' commit such a file, and reading it would let the change under review choose what it'
|
|
573
|
+
+ ' is compared against. Pass --snapshot-file if you genuinely meant that file.'
|
|
574
|
+
: ' Pass --snapshot-file <path> if you meant a snapshot file.'),
|
|
575
|
+
);
|
|
383
576
|
}
|
|
384
577
|
|
|
385
578
|
const rel = gitRepoRelativePath(filePath);
|
|
386
|
-
|
|
579
|
+
if (baselineEntryIsSymlink(commit, rel, repoDir)) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`"${rel}" is a symbolic link in ${ref}, so the baseline there is a path, not a configuration.`
|
|
582
|
+
+ ' Point --snapshot-ref at a revision where it is a regular file, or gate the file the link'
|
|
583
|
+
+ ' resolves to.',
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
// `-C repoDir` matches where the revision was resolved, and the operand is a
|
|
587
|
+
// SHA this process produced rather than any string the attacker wrote.
|
|
588
|
+
// maxBuffer matches the LSP's reader: a megabyte-scale baseline blob is
|
|
589
|
+
// ordinary, and the default 1 MB dies with a raw ENOBUFS.
|
|
590
|
+
const raw = execFileSync(
|
|
591
|
+
'git',
|
|
592
|
+
['-C', repoDir, 'show', '--end-of-options', `${commit}:${rel}`],
|
|
593
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 },
|
|
594
|
+
);
|
|
387
595
|
return parseContent(filePath, raw);
|
|
388
596
|
}
|
|
389
597
|
|
|
@@ -610,6 +818,50 @@ async function deliverPrCommentSafely(body, enabled, provider) {
|
|
|
610
818
|
}
|
|
611
819
|
}
|
|
612
820
|
|
|
821
|
+
/**
|
|
822
|
+
* `ci --explain` (#143): narration for the run, or null.
|
|
823
|
+
*
|
|
824
|
+
* Called only after the gate is decided, and it cannot change it: every failure
|
|
825
|
+
* here — no provider configured, over budget, network, refusal — is a warning
|
|
826
|
+
* and a null, never a thrown error. Status goes to stderr, so the machine
|
|
827
|
+
* output on stdout is byte-for-byte what it is without the flag.
|
|
828
|
+
* @param {import('./src/explain.js').ExplainInputFile[]} files unmasked; the payload builder masks
|
|
829
|
+
* @param {{ dryRun: boolean, cwd: string }} options
|
|
830
|
+
* @returns {Promise<{ text: string, provider: string, model: string, cached: boolean, truncated: boolean } | null>}
|
|
831
|
+
*/
|
|
832
|
+
async function narrateForCi(files, { dryRun, cwd }) {
|
|
833
|
+
try {
|
|
834
|
+
const payload = buildExplainPayload(files, { cwd });
|
|
835
|
+
if (payload.files.length === 0) {
|
|
836
|
+
renderNote('flecto explain: nothing to narrate — no changes and no findings.');
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
const resolved = resolveExplainConfig({}, process.env, { dryRun });
|
|
840
|
+
if (!resolved.ok) {
|
|
841
|
+
renderWarn(`No narration: ${resolved.reason}.`);
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
if (dryRun) {
|
|
845
|
+
const request = describeRequest(buildExplainRequest(payload, resolved.config), resolved.config);
|
|
846
|
+
renderNote(
|
|
847
|
+
`flecto explain --explain-dry-run: nothing was sent. ~${estimateInputTokens(request.body)} input`
|
|
848
|
+
+ ` tokens (estimated), output capped at ${resolved.config.maxTokens}. The request:`,
|
|
849
|
+
);
|
|
850
|
+
process.stderr.write(`${JSON.stringify(request, null, 2)}\n`);
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
const result = await narrate(payload, resolved.config, { onNote: renderNote });
|
|
854
|
+
if (!result.ok) {
|
|
855
|
+
renderWarn(`No narration: ${result.reason}.`);
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
return result;
|
|
859
|
+
} catch (err) {
|
|
860
|
+
renderWarn(`No narration: ${err.message}.`);
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
613
865
|
program
|
|
614
866
|
.name('flecto')
|
|
615
867
|
.description('Flecto — semantic config watcher for meaningful structured file changes')
|
|
@@ -654,6 +906,7 @@ program
|
|
|
654
906
|
const profile = resolveProfileName(opts.profile);
|
|
655
907
|
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
656
908
|
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
909
|
+
assertAlertActionsFromCli(effective, cliOverrides);
|
|
657
910
|
const { policies, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
658
911
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
659
912
|
if (targets.length === 0) {
|
|
@@ -1054,7 +1307,8 @@ program
|
|
|
1054
1307
|
.command('ci [files...]')
|
|
1055
1308
|
.description('Run semantic diff in CI mode')
|
|
1056
1309
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
1057
|
-
.option('--snapshot-ref <ref>', '
|
|
1310
|
+
.option('--snapshot-ref <ref>', 'Baseline git revision (a snapshot path also works if it is path-shaped)')
|
|
1311
|
+
.option('--snapshot-file <path>', 'Baseline snapshot file, never consulted as a git revision')
|
|
1058
1312
|
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
1059
1313
|
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
1060
1314
|
.option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
|
|
@@ -1073,12 +1327,18 @@ program
|
|
|
1073
1327
|
.option('--show-suppressed', 'List inline-suppressed findings instead of only counting them', false)
|
|
1074
1328
|
.option('--changed-only', 'With --format json|ndjson, replace envelopes for unchanged files with one scanned manifest', false)
|
|
1075
1329
|
.option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
|
|
1330
|
+
.option('--explain', 'Add model-generated narration of the masked diff (advisory; provider set by FLECTO_EXPLAIN_* env)', false)
|
|
1331
|
+
.option('--explain-dry-run', 'Print the narration request that --explain would send, and send nothing', false)
|
|
1076
1332
|
.action(async (files, opts, command) => {
|
|
1077
1333
|
try {
|
|
1078
1334
|
const { config } = loadRcConfig(process.cwd());
|
|
1079
1335
|
const profile = resolveProfileName(opts.profile);
|
|
1080
1336
|
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
1081
1337
|
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
1338
|
+
assertExplainNotFromRc(effective, cliOverrides);
|
|
1339
|
+
assertSnapshotRefFromCli(effective, cliOverrides);
|
|
1340
|
+
const explainDryRun = Boolean(cliOverrides.explainDryRun);
|
|
1341
|
+
const explainRequested = Boolean(cliOverrides.explain) || explainDryRun;
|
|
1082
1342
|
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
1083
1343
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
1084
1344
|
if (targets.length === 0) {
|
|
@@ -1139,7 +1399,7 @@ program
|
|
|
1139
1399
|
throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
|
|
1140
1400
|
}
|
|
1141
1401
|
|
|
1142
|
-
/** @type {Array<{ filepath: string, relFile: string, outboundChanges: any[], outboundFindings: any[], changesFail: boolean }>} */
|
|
1402
|
+
/** @type {Array<{ filepath: string, relFile: string, events: any[], outboundChanges: any[], outboundFindings: any[], changesFail: boolean }>} */
|
|
1143
1403
|
const perFile = [];
|
|
1144
1404
|
/** @type {Array<{ file: string, finding: any, reason: string }>} */
|
|
1145
1405
|
const allSuppressed = [];
|
|
@@ -1156,12 +1416,12 @@ program
|
|
|
1156
1416
|
}
|
|
1157
1417
|
// A `--snapshot-ref` baseline is read straight from git, so it is never
|
|
1158
1418
|
// masked; only a store-provided baseline needs the live side aligned.
|
|
1159
|
-
const after = effective.snapshotRef
|
|
1419
|
+
const after = (effective.snapshotRef || effective.snapshotFile)
|
|
1160
1420
|
? parseFile(filepath)
|
|
1161
1421
|
: alignStateWithStore(parseFile(filepath), snapshotStore);
|
|
1162
1422
|
let before;
|
|
1163
1423
|
try {
|
|
1164
|
-
before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore);
|
|
1424
|
+
before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore, effective.snapshotFile);
|
|
1165
1425
|
} catch (err) {
|
|
1166
1426
|
throw new Error(
|
|
1167
1427
|
`Failed to resolve snapshot baseline for "${filepath}"` +
|
|
@@ -1191,6 +1451,7 @@ program
|
|
|
1191
1451
|
perFile.push({
|
|
1192
1452
|
filepath,
|
|
1193
1453
|
relFile: baselineRelativePath(filepath, cwd),
|
|
1454
|
+
events,
|
|
1194
1455
|
outboundChanges: maybeMaskChanges(events, maskSecrets),
|
|
1195
1456
|
outboundFindings: maybeMaskFindings(policyFindings, events, maskSecrets),
|
|
1196
1457
|
changesFail: shouldFailFromChanges(events, failOn),
|
|
@@ -1292,13 +1553,24 @@ program
|
|
|
1292
1553
|
}
|
|
1293
1554
|
}
|
|
1294
1555
|
|
|
1556
|
+
// Asked for only once `shouldFail` is settled, and handed nothing that
|
|
1557
|
+
// could change it. Narration covers the findings the gate sees — active
|
|
1558
|
+
// ones, after baseline and suppressions.
|
|
1559
|
+
const narration = explainRequested
|
|
1560
|
+
? await narrateForCi(
|
|
1561
|
+
perFile.map((f) => ({ file: f.filepath, changes: f.events, findings: activeByFile.get(f.relFile) ?? [] })),
|
|
1562
|
+
{ dryRun: explainDryRun, cwd },
|
|
1563
|
+
)
|
|
1564
|
+
: null;
|
|
1565
|
+
|
|
1295
1566
|
if (format === 'pr-comment') {
|
|
1296
|
-
const body = renderPrComment(results, { cwd, failed: shouldFail });
|
|
1567
|
+
const body = renderPrComment(results, { cwd, failed: shouldFail, narration });
|
|
1297
1568
|
await writeStdout(body);
|
|
1298
1569
|
await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
|
|
1299
1570
|
} else {
|
|
1300
1571
|
const collapsible = changedOnly && (format === 'json' || format === 'ndjson');
|
|
1301
1572
|
await printCiOutput(collapsible ? collapseUnchangedResults(results) : results, format);
|
|
1573
|
+
if (narration) process.stderr.write(`\n${formatNarration(narration)}\n`);
|
|
1302
1574
|
}
|
|
1303
1575
|
process.exit(shouldFail ? 1 : 0);
|
|
1304
1576
|
} catch (err) {
|
|
@@ -1307,6 +1579,161 @@ program
|
|
|
1307
1579
|
}
|
|
1308
1580
|
});
|
|
1309
1581
|
|
|
1582
|
+
program
|
|
1583
|
+
.command('explain [files...]')
|
|
1584
|
+
.description('Narrate the masked semantic diff with a model you configure — advisory, opt-in, bring your own key (#143)')
|
|
1585
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
1586
|
+
.option('--snapshot-ref <ref>', 'Baseline git revision (a snapshot path also works if it is path-shaped)')
|
|
1587
|
+
.option('--snapshot-file <path>', 'Baseline snapshot file, never consulted as a git revision')
|
|
1588
|
+
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
1589
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
1590
|
+
.option('--provider <id>', `Model provider: ${EXPLAIN_PROVIDERS.join(' | ')} (else FLECTO_EXPLAIN_PROVIDER)`)
|
|
1591
|
+
.option('--model <name>', 'Model id (else FLECTO_EXPLAIN_MODEL; anthropic defaults to claude-opus-5)')
|
|
1592
|
+
.option('--max-tokens <n>', 'Output token cap (else FLECTO_EXPLAIN_MAX_TOKENS; default 16000)')
|
|
1593
|
+
.option('--format <type>', 'Output format: human | json', 'human')
|
|
1594
|
+
.option('--dry-run', 'Print the exact request that would be sent, and send nothing', false)
|
|
1595
|
+
.option('--no-cache', 'Neither read nor write the narration cache')
|
|
1596
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
1597
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
1598
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
1599
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
1600
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
1601
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
1602
|
+
.action(async (files, opts, command) => {
|
|
1603
|
+
try {
|
|
1604
|
+
const { config } = loadRcConfig(process.cwd());
|
|
1605
|
+
const profile = resolveProfileName(opts.profile);
|
|
1606
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
1607
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
1608
|
+
assertExplainNotFromRc(effective, cliOverrides);
|
|
1609
|
+
assertSnapshotRefFromCli(effective, cliOverrides);
|
|
1610
|
+
// Everything that shapes the request is read from the command line, never
|
|
1611
|
+
// from the merged options: `.flectorc` can name a `format` or `model` for
|
|
1612
|
+
// other commands, and none of it may steer where the diff goes.
|
|
1613
|
+
const format = String(cliOverrides.format ?? 'human');
|
|
1614
|
+
if (!['human', 'json'].includes(format)) {
|
|
1615
|
+
throw new Error('--format must be human or json');
|
|
1616
|
+
}
|
|
1617
|
+
const dryRun = Boolean(cliOverrides.dryRun);
|
|
1618
|
+
const snapshotRef = cliOverrides.snapshotRef;
|
|
1619
|
+
const snapshotFile = cliOverrides.snapshotFile;
|
|
1620
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
1621
|
+
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
1622
|
+
if (targets.length === 0) {
|
|
1623
|
+
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
1624
|
+
}
|
|
1625
|
+
const dOpts = diffOptionsFromEffective(effective, parseCsv(effective.ignore));
|
|
1626
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
1627
|
+
const cwd = process.cwd();
|
|
1628
|
+
|
|
1629
|
+
/** @type {import('./src/explain.js').ExplainInputFile[]} */
|
|
1630
|
+
const diffed = [];
|
|
1631
|
+
for (const filepath of targets) {
|
|
1632
|
+
if (!existsSync(filepath)) {
|
|
1633
|
+
renderWarn(`Skipping missing file: ${filepath}`);
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
if (!isSupported(filepath)) {
|
|
1637
|
+
renderWarn(`Skipping unsupported file: ${filepath}`);
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
const after = (snapshotRef || snapshotFile)
|
|
1641
|
+
? parseFile(filepath)
|
|
1642
|
+
: alignStateWithStore(parseFile(filepath), snapshotStore);
|
|
1643
|
+
let before;
|
|
1644
|
+
try {
|
|
1645
|
+
before = readSnapshotStateFromRef(filepath, snapshotRef, snapshotStore, snapshotFile);
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
throw new Error(
|
|
1648
|
+
`Failed to resolve snapshot baseline for "${filepath}"`
|
|
1649
|
+
+ `${snapshotRef ? ` (ref: ${snapshotRef})` : ''}: ${err.message}`,
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
const events = diffTrees(before, after, dOpts);
|
|
1653
|
+
const rawFindings = await evaluatePolicies(events, {
|
|
1654
|
+
cwd,
|
|
1655
|
+
file: filepath,
|
|
1656
|
+
profile: profile ?? null,
|
|
1657
|
+
source: 'diff',
|
|
1658
|
+
policies: packIds,
|
|
1659
|
+
plugins,
|
|
1660
|
+
severityRemap,
|
|
1661
|
+
});
|
|
1662
|
+
const { active } = resolveSuppressed(filepath, rawFindings);
|
|
1663
|
+
diffed.push({ file: filepath, changes: events, findings: active });
|
|
1664
|
+
}
|
|
1665
|
+
if (diffed.length === 0) {
|
|
1666
|
+
throw new Error('No files were diffed — all targets were missing or unsupported.');
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// The operator sees what is narrated, masked the way it is sent.
|
|
1670
|
+
if (format === 'human') {
|
|
1671
|
+
for (const { file, changes, findings } of diffed) {
|
|
1672
|
+
renderDiff(file, changes, { maskSecrets: true, baseline: snapshotRef ?? 'snapshot' });
|
|
1673
|
+
renderPolicyFindings(maskFindings(findings, changes));
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const payload = buildExplainPayload(diffed, { cwd });
|
|
1678
|
+
if (payload.files.length === 0) {
|
|
1679
|
+
if (format === 'json') {
|
|
1680
|
+
await writeStdout(JSON.stringify({ advisory: true, narration: null, reason: 'no changes and no findings', payload }, null, 2));
|
|
1681
|
+
} else {
|
|
1682
|
+
renderInfo('Nothing to narrate: no semantic changes and no policy findings.');
|
|
1683
|
+
}
|
|
1684
|
+
process.exit(0);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const resolved = resolveExplainConfig(
|
|
1688
|
+
{
|
|
1689
|
+
provider: cliOverrides.provider,
|
|
1690
|
+
model: cliOverrides.model,
|
|
1691
|
+
maxTokens: cliOverrides.maxTokens,
|
|
1692
|
+
cache: cliOverrides.cache,
|
|
1693
|
+
},
|
|
1694
|
+
process.env,
|
|
1695
|
+
{ dryRun },
|
|
1696
|
+
);
|
|
1697
|
+
if (!resolved.ok) throw new Error(`Cannot narrate: ${resolved.reason}.`);
|
|
1698
|
+
|
|
1699
|
+
if (dryRun) {
|
|
1700
|
+
const request = describeRequest(buildExplainRequest(payload, resolved.config), resolved.config);
|
|
1701
|
+
renderNote(
|
|
1702
|
+
`flecto explain --dry-run: nothing was sent. ~${estimateInputTokens(request.body)} input tokens`
|
|
1703
|
+
+ ` (estimated), output capped at ${resolved.config.maxTokens}.`,
|
|
1704
|
+
);
|
|
1705
|
+
await writeStdout(JSON.stringify(request, null, 2));
|
|
1706
|
+
process.exit(0);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
const result = await narrate(payload, resolved.config, { onNote: renderNote });
|
|
1710
|
+
if (format === 'json') {
|
|
1711
|
+
await writeStdout(JSON.stringify({
|
|
1712
|
+
advisory: true,
|
|
1713
|
+
narration: result.ok
|
|
1714
|
+
? {
|
|
1715
|
+
model_generated: true,
|
|
1716
|
+
provider: result.provider,
|
|
1717
|
+
model: result.model,
|
|
1718
|
+
cached: result.cached,
|
|
1719
|
+
truncated: result.truncated,
|
|
1720
|
+
text: result.text,
|
|
1721
|
+
}
|
|
1722
|
+
: null,
|
|
1723
|
+
reason: result.ok ? undefined : result.reason,
|
|
1724
|
+
payload,
|
|
1725
|
+
}, null, 2));
|
|
1726
|
+
} else if (result.ok) {
|
|
1727
|
+
await writeStdout(`\n${formatNarration(result)}`);
|
|
1728
|
+
}
|
|
1729
|
+
if (!result.ok) renderWarn(`No narration: ${result.reason}.`);
|
|
1730
|
+
process.exit(0);
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
renderError(err.message);
|
|
1733
|
+
process.exit(1);
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1310
1737
|
program
|
|
1311
1738
|
.command('plan <planFiles...>')
|
|
1312
1739
|
.description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
|
|
@@ -1607,6 +2034,75 @@ program
|
|
|
1607
2034
|
renderInfo(`Policy packs: ${detection.packs.join(', ')}`);
|
|
1608
2035
|
});
|
|
1609
2036
|
|
|
2037
|
+
program
|
|
2038
|
+
.command('mcp')
|
|
2039
|
+
.description('Run Flecto as a read-only Model Context Protocol server over stdio (#140)')
|
|
2040
|
+
.action(async () => {
|
|
2041
|
+
// The server writes JSON-RPC to stdout and nothing else, so diagnostics go
|
|
2042
|
+
// to stderr. Tools invoke the read-only `ci` path as a subprocess of this
|
|
2043
|
+
// same CLI; the seam is the JSON envelope, so this lifts cleanly into a
|
|
2044
|
+
// standalone flecto-mcp package later without touching the protocol code.
|
|
2045
|
+
const cliPath = fileURLToPath(import.meta.url);
|
|
2046
|
+
const runFlecto = makeCliRunner({ nodeExec: process.execPath, cliPath, cwd: process.cwd() });
|
|
2047
|
+
// Readiness goes to stderr — stdout must carry JSON-RPC and nothing else.
|
|
2048
|
+
renderNote('flecto mcp: read-only server ready (stdio). Secrets masked by default.');
|
|
2049
|
+
await runStdioServer({ version: PKG.version, cwd: process.cwd(), runFlecto });
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
program
|
|
2053
|
+
.command('lsp')
|
|
2054
|
+
.description('Run a Language Server Protocol server over stdio: findings and changes as editor diagnostics (#142)')
|
|
2055
|
+
.option('--stdio', 'Talk LSP over stdin/stdout (the only transport; accepted because editors pass it)')
|
|
2056
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
2057
|
+
.option('--snapshot-ref <ref>', 'Git ref to diff open files against', 'HEAD')
|
|
2058
|
+
.option('--snapshot-store <id>', `Diff against a snapshot store instead of git: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
2059
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store')
|
|
2060
|
+
.option('--plugins <paths>', 'Comma-separated absolute paths of ESM policy plugins to load (never read from .flectorc)')
|
|
2061
|
+
.option('--changes <level>', 'How semantic changes appear: hint | info | none', 'hint')
|
|
2062
|
+
.option('--debounce <ms>', 'Wait this long after the last edit before analyzing', '250')
|
|
2063
|
+
.option('--timeout <ms>', 'Stop an analysis that runs longer than this', '10000')
|
|
2064
|
+
.action(async (opts) => {
|
|
2065
|
+
try {
|
|
2066
|
+
const changes = String(opts.changes);
|
|
2067
|
+
if (!['hint', 'info', 'none'].includes(changes)) {
|
|
2068
|
+
throw new Error('--changes must be hint, info, or none');
|
|
2069
|
+
}
|
|
2070
|
+
const debounceMs = Number(opts.debounce);
|
|
2071
|
+
const timeoutMs = Number(opts.timeout);
|
|
2072
|
+
if (!Number.isInteger(debounceMs) || debounceMs < 0) throw new Error('--debounce must be a whole number of milliseconds');
|
|
2073
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 100) throw new Error('--timeout must be at least 100 milliseconds');
|
|
2074
|
+
const plugins = opts.plugins === undefined ? undefined : parseCsv(opts.plugins);
|
|
2075
|
+
// A relative path would resolve against whichever repository the editor
|
|
2076
|
+
// opens — including one that ships a file at exactly that path.
|
|
2077
|
+
for (const plugin of plugins ?? []) {
|
|
2078
|
+
if (!isAbsolute(plugin)) {
|
|
2079
|
+
throw new Error(`--plugins must be absolute paths in the language server (got "${plugin}"): a relative path would resolve inside whatever repository is open.`);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
renderNote('flecto lsp: ready (stdio). Plugins from .flectorc are never loaded here.');
|
|
2083
|
+
const { done } = startLanguageServer({
|
|
2084
|
+
input: process.stdin,
|
|
2085
|
+
output: process.stdout,
|
|
2086
|
+
version: PKG.version,
|
|
2087
|
+
cwd: process.cwd(),
|
|
2088
|
+
debounceMs,
|
|
2089
|
+
timeoutMs,
|
|
2090
|
+
settings: {
|
|
2091
|
+
profile: resolveProfileName(opts.profile),
|
|
2092
|
+
plugins,
|
|
2093
|
+
snapshotRef: opts.snapshotRef,
|
|
2094
|
+
snapshotStore: opts.snapshotStore,
|
|
2095
|
+
snapshotDir: opts.snapshotDir,
|
|
2096
|
+
changes: /** @type {'hint' | 'info' | 'none'} */ (changes),
|
|
2097
|
+
},
|
|
2098
|
+
});
|
|
2099
|
+
process.exit(await done);
|
|
2100
|
+
} catch (err) {
|
|
2101
|
+
renderError(err.message);
|
|
2102
|
+
process.exit(1);
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
|
|
1610
2106
|
program
|
|
1611
2107
|
.command('doctor')
|
|
1612
2108
|
.description('Check Flecto setup, config, and environment')
|