flecto 3.0.2 → 4.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/CHANGELOG.md +257 -1
- package/README.md +12 -1
- package/drift.js +226 -0
- package/index.js +672 -152
- package/package.json +10 -7
- package/src/alerter.js +272 -24
- package/src/config.js +369 -3
- package/src/differ.js +76 -12
- 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 +1057 -0
- package/src/pr-comment.js +75 -1
- package/src/pr-providers.js +8 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +27 -15
- package/src/report.js +4 -2
- package/src/secrets.js +27 -0
- package/src/snapshot-store.js +672 -0
package/index.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync,
|
|
5
|
-
import { resolve, relative, dirname, join } from 'path';
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
|
|
5
|
+
import { resolve, relative, dirname, basename, join, isAbsolute } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
-
import { createHash } from 'crypto';
|
|
8
7
|
import { execFileSync } from 'child_process';
|
|
9
8
|
import chalk from 'chalk';
|
|
10
9
|
|
|
11
10
|
import { parseFile, isSupported, parseContent } from './src/parser.js';
|
|
12
|
-
import { diffTrees
|
|
11
|
+
import { diffTrees } from './src/differ.js';
|
|
13
12
|
import { documentKeysOf, withDocumentKeys } from './src/documents.js';
|
|
14
13
|
import { startWatcher } from './src/watcher.js';
|
|
15
14
|
import {
|
|
@@ -21,7 +20,7 @@ import {
|
|
|
21
20
|
renderWarn,
|
|
22
21
|
renderPolicyFindings,
|
|
23
22
|
maskChangeEvent,
|
|
24
|
-
|
|
23
|
+
maskFindings,
|
|
25
24
|
} from './src/renderer.js';
|
|
26
25
|
import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
|
|
27
26
|
import { PR_PROVIDER_IDS } from './src/pr-providers.js';
|
|
@@ -31,11 +30,17 @@ import {
|
|
|
31
30
|
readTerraformPlanFile,
|
|
32
31
|
} from './src/terraform.js';
|
|
33
32
|
import { renderReportHtml } from './src/report.js';
|
|
34
|
-
import { redactSecretString } from './src/secrets.js';
|
|
35
33
|
import { fireAlerts } from './src/alerter.js';
|
|
36
34
|
import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
|
|
37
35
|
import { createEnvelope } from './src/envelope.js';
|
|
36
|
+
import { startLanguageServer } from './src/lsp.js';
|
|
38
37
|
import { buildSarif } from './src/sarif.js';
|
|
38
|
+
import {
|
|
39
|
+
maskState,
|
|
40
|
+
resolveSnapshotStore,
|
|
41
|
+
SNAPSHOT_MASK_MODES,
|
|
42
|
+
SNAPSHOT_STORE_IDS,
|
|
43
|
+
} from './src/snapshot-store.js';
|
|
39
44
|
import {
|
|
40
45
|
loadBaseline,
|
|
41
46
|
applyBaseline,
|
|
@@ -55,6 +60,18 @@ import {
|
|
|
55
60
|
addPolicyPackFromPackage,
|
|
56
61
|
} from './src/policy.js';
|
|
57
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';
|
|
58
75
|
import {
|
|
59
76
|
loadRcConfig,
|
|
60
77
|
resolveEffectiveOptions,
|
|
@@ -63,13 +80,16 @@ import {
|
|
|
63
80
|
resolveProfileName,
|
|
64
81
|
resolvePolicyOptions,
|
|
65
82
|
assertTargetContained,
|
|
83
|
+
assertWriteDestinationContained,
|
|
84
|
+
assertAlertActionsFromCli,
|
|
85
|
+
assertSnapshotRefFromCli,
|
|
86
|
+
assertSafeGitRef,
|
|
66
87
|
} from './src/config.js';
|
|
67
88
|
|
|
68
89
|
const PKG = JSON.parse(
|
|
69
90
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
|
|
70
91
|
);
|
|
71
92
|
|
|
72
|
-
const SNAPSHOT_DIR = '.flecto-snapshots';
|
|
73
93
|
const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn'];
|
|
74
94
|
|
|
75
95
|
/**
|
|
@@ -82,85 +102,42 @@ const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn
|
|
|
82
102
|
const PLAN_DEFAULT_FAIL_ON = 'error';
|
|
83
103
|
const PLAN_DEFAULT_POLICIES = 'terraform';
|
|
84
104
|
|
|
85
|
-
function snapshotIdForPath(absPath) {
|
|
86
|
-
const normalized = absPath.replaceAll('\\', '/');
|
|
87
|
-
return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function snapshotPathForFile(absPath) {
|
|
91
|
-
const id = snapshotIdForPath(absPath);
|
|
92
|
-
return resolve(`${SNAPSHOT_DIR}/${id}.json`);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function snapshotHistoryPathForFile(absPath) {
|
|
96
|
-
const id = snapshotIdForPath(absPath);
|
|
97
|
-
let timestamp = Date.now();
|
|
98
|
-
let path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
|
|
99
|
-
while (existsSync(path)) {
|
|
100
|
-
timestamp += 1;
|
|
101
|
-
path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
|
|
102
|
-
}
|
|
103
|
-
return path;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
105
|
/**
|
|
107
|
-
*
|
|
106
|
+
* Put the live side of a diff in the same form the store recorded the baseline
|
|
107
|
+
* in.
|
|
108
108
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
109
|
+
* A masked store holds `flecto:sha256:…` where a secret was. Diffing that
|
|
110
|
+
* against the plaintext on disk would report every secret in the file as changed
|
|
111
|
+
* on every single run — noise that would train a team to ignore the tool, from a
|
|
112
|
+
* store whose whole purpose is to be trusted. Masking both sides compares digest
|
|
113
|
+
* to digest, so a rotated credential still reports as changed and an untouched
|
|
114
|
+
* one reports nothing.
|
|
115
|
+
* @template T
|
|
116
|
+
* @param {T} state
|
|
117
|
+
* @param {import('./src/snapshot-store.js').SnapshotStore} store
|
|
118
|
+
* @returns {T}
|
|
113
119
|
*/
|
|
114
|
-
function
|
|
115
|
-
/** @type {
|
|
116
|
-
const ids = new Set();
|
|
117
|
-
if (!existsSync(SNAPSHOT_DIR)) return ids;
|
|
118
|
-
for (const name of readdirSync(SNAPSHOT_DIR)) {
|
|
119
|
-
const match = /^([a-f0-9]{16})\.\d+\.json$/.exec(name);
|
|
120
|
-
if (match) ids.add(match[1]);
|
|
121
|
-
}
|
|
122
|
-
return ids;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
|
|
126
|
-
if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
|
|
127
|
-
|
|
128
|
-
const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
129
|
-
writeFileSync(
|
|
130
|
-
snapshotHistoryPathForFile(absPath),
|
|
131
|
-
JSON.stringify({
|
|
132
|
-
file: legacy.file ?? absPath,
|
|
133
|
-
state: legacy.state ?? legacy,
|
|
134
|
-
...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
|
|
135
|
-
createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
|
|
136
|
-
}, null, 2),
|
|
137
|
-
'utf8',
|
|
138
|
-
);
|
|
120
|
+
function alignStateWithStore(state, store) {
|
|
121
|
+
return store.maskMode === 'hash' ? /** @type {T} */ (maskState(state)) : state;
|
|
139
122
|
}
|
|
140
123
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
return {
|
|
160
|
-
file: snapshot.file,
|
|
161
|
-
state,
|
|
162
|
-
createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
|
|
163
|
-
};
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the snapshot store a command should read and write (#141).
|
|
126
|
+
*
|
|
127
|
+
* Every snapshot consumer goes through this, so `--snapshot-store shared` in
|
|
128
|
+
* `.flectorc` means the same thing to `watch`, `ci`, `history`, and `report` —
|
|
129
|
+
* a store the editor of the config and the runner gating it disagree about
|
|
130
|
+
* would be worse than having only the local one.
|
|
131
|
+
* @param {Record<string, unknown>} effective
|
|
132
|
+
* @returns {import('./src/snapshot-store.js').SnapshotStore}
|
|
133
|
+
*/
|
|
134
|
+
function snapshotStoreFromEffective(effective) {
|
|
135
|
+
return resolveSnapshotStore({
|
|
136
|
+
store: effective.snapshotStore,
|
|
137
|
+
dir: effective.snapshotDir,
|
|
138
|
+
mask: effective.snapshotMask,
|
|
139
|
+
retention: effective.snapshotRetention,
|
|
140
|
+
cwd: process.cwd(),
|
|
164
141
|
});
|
|
165
142
|
}
|
|
166
143
|
|
|
@@ -265,11 +242,6 @@ function maybeMaskChanges(events, maskSecrets) {
|
|
|
265
242
|
}
|
|
266
243
|
|
|
267
244
|
/**
|
|
268
|
-
* Redact secret-shaped text from policy messages. A rule using
|
|
269
|
-
* `messageTemplate` can interpolate `{before}` / `{after}`, so a finding can
|
|
270
|
-
* carry a credential even when the change events beside it are masked. Replace
|
|
271
|
-
* exact interpolated values using the same path-aware masking as change events,
|
|
272
|
-
* then catch any other recognizable secret fragments in free-form messages.
|
|
273
245
|
* @param {import('./src/policy.js').PolicyFinding[]} findings
|
|
274
246
|
* @param {import('./src/differ.js').ChangeEvent[]} changes
|
|
275
247
|
* @param {boolean} maskSecrets
|
|
@@ -277,17 +249,7 @@ function maybeMaskChanges(events, maskSecrets) {
|
|
|
277
249
|
*/
|
|
278
250
|
function maybeMaskFindings(findings, changes, maskSecrets) {
|
|
279
251
|
if (!maskSecrets) return findings;
|
|
280
|
-
return findings
|
|
281
|
-
let message = String(finding.message ?? '');
|
|
282
|
-
for (const change of changes.filter((event) => event.path === finding.path)) {
|
|
283
|
-
for (const value of [change.before, change.after]) {
|
|
284
|
-
const original = String(value);
|
|
285
|
-
const masked = String(maskSensitiveValue(value, secretMatchPath(change)));
|
|
286
|
-
if (original && original !== masked) message = message.replaceAll(original, masked);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return { ...finding, message: redactSecretString(message) };
|
|
290
|
-
});
|
|
252
|
+
return maskFindings(findings, changes);
|
|
291
253
|
}
|
|
292
254
|
|
|
293
255
|
/**
|
|
@@ -347,6 +309,37 @@ function restoreSnapshotDocumentKeys(state, snapshot) {
|
|
|
347
309
|
return withDocumentKeys(state, documents.map(String));
|
|
348
310
|
}
|
|
349
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
|
+
|
|
350
343
|
function readSnapshotStateFromFile(snapshotPath) {
|
|
351
344
|
const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
352
345
|
return restoreSnapshotDocumentKeys(snap?.state ?? snap, snap);
|
|
@@ -367,7 +360,41 @@ function gitRepoRelativePath(filePath) {
|
|
|
367
360
|
const top = execFileSync('git', ['-C', dirname(filePath), 'rev-parse', '--show-toplevel'], {
|
|
368
361
|
encoding: 'utf8',
|
|
369
362
|
}).trim();
|
|
370
|
-
|
|
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
|
+
}
|
|
371
398
|
}
|
|
372
399
|
|
|
373
400
|
/**
|
|
@@ -402,29 +429,169 @@ function canonicalPath(path) {
|
|
|
402
429
|
}
|
|
403
430
|
}
|
|
404
431
|
|
|
405
|
-
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
|
+
}
|
|
406
535
|
if (!snapshotRef) {
|
|
407
|
-
const snapshotPath = snapshotPathForFile(filePath);
|
|
408
536
|
// Failing closed here is right — a diff with no baseline is not a clean
|
|
409
|
-
// diff — but an ENOENT on a hashed filename explains nothing.
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
+ ' <git-ref> to diff against a committed revision instead',
|
|
417
|
-
);
|
|
537
|
+
// diff — but an ENOENT on a hashed filename explains nothing. The default
|
|
538
|
+
// store is local to the working directory, so this is what an ephemeral CI
|
|
539
|
+
// runner hits on every run; the message names the store it looked in and
|
|
540
|
+
// the two ways to give it one (#141).
|
|
541
|
+
const record = store.readLatest(filePath);
|
|
542
|
+
if (!record) {
|
|
543
|
+
throw new Error(`no snapshot has been saved for this file (${store.emptyHint})`);
|
|
418
544
|
}
|
|
419
|
-
return
|
|
545
|
+
return record.state;
|
|
420
546
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
+
);
|
|
424
576
|
}
|
|
425
577
|
|
|
426
578
|
const rel = gitRepoRelativePath(filePath);
|
|
427
|
-
|
|
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
|
+
);
|
|
428
595
|
return parseContent(filePath, raw);
|
|
429
596
|
}
|
|
430
597
|
|
|
@@ -651,6 +818,50 @@ async function deliverPrCommentSafely(body, enabled, provider) {
|
|
|
651
818
|
}
|
|
652
819
|
}
|
|
653
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
|
+
|
|
654
865
|
program
|
|
655
866
|
.name('flecto')
|
|
656
867
|
.description('Flecto — semantic config watcher for meaningful structured file changes')
|
|
@@ -684,6 +895,10 @@ program
|
|
|
684
895
|
.option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
|
|
685
896
|
.option('--snapshot', 'Save current state as baseline instead of watching')
|
|
686
897
|
.option('--diff', 'Diff current file against saved baseline and exit')
|
|
898
|
+
.option('--snapshot-store <id>', `Snapshot store: ${SNAPSHOT_STORE_IDS.join(' | ')} (shared is repo-relative and meant to be committed)`)
|
|
899
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
900
|
+
.option('--snapshot-mask <mode>', `How the store records secret-like values: ${SNAPSHOT_MASK_MODES.join(' | ')} (default: hash for shared, none for local)`)
|
|
901
|
+
.option('--snapshot-retention <n>', 'Snapshots kept per file, 0 keeps every one (default: 20 for shared, unlimited for local)')
|
|
687
902
|
.option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
|
|
688
903
|
.action(async (files, opts, command) => {
|
|
689
904
|
try {
|
|
@@ -691,6 +906,7 @@ program
|
|
|
691
906
|
const profile = resolveProfileName(opts.profile);
|
|
692
907
|
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
693
908
|
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
909
|
+
assertAlertActionsFromCli(effective, cliOverrides);
|
|
694
910
|
const { policies, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
695
911
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
696
912
|
if (targets.length === 0) {
|
|
@@ -707,16 +923,18 @@ program
|
|
|
707
923
|
const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
|
|
708
924
|
const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
|
|
709
925
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
926
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
710
927
|
|
|
711
928
|
if (effective.snapshot) {
|
|
712
|
-
mkdirSync(
|
|
713
|
-
// Snapshots carry config values, so a
|
|
929
|
+
mkdirSync(snapshotStore.root, { recursive: true });
|
|
930
|
+
// Snapshots carry config values, so a store directory that is itself a
|
|
714
931
|
// link out of the project would write them somewhere the repository does
|
|
715
932
|
// not control. Same rule as a target, checked after mkdir so an existing
|
|
716
933
|
// link is seen rather than a path that does not exist yet.
|
|
717
|
-
assertTargetContained(
|
|
718
|
-
const idsWithHistory = snapshotIdsWithHistory();
|
|
934
|
+
assertTargetContained(snapshotStore.root, process.cwd());
|
|
719
935
|
let written = 0;
|
|
936
|
+
let pruned = 0;
|
|
937
|
+
const warnings = new Set();
|
|
720
938
|
for (const filepath of targets) {
|
|
721
939
|
if (!existsSync(filepath)) {
|
|
722
940
|
renderWarn(`Skipping missing file: ${filepath}`);
|
|
@@ -727,25 +945,32 @@ program
|
|
|
727
945
|
continue;
|
|
728
946
|
}
|
|
729
947
|
const state = parseFile(filepath);
|
|
730
|
-
const snapshotPath = snapshotPathForFile(filepath);
|
|
731
|
-
preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
|
|
732
948
|
// Only a multi-document file records `documents`, so an ordinary
|
|
733
949
|
// snapshot is byte-for-byte what it was before this field existed.
|
|
734
|
-
const
|
|
735
|
-
const snapshot = {
|
|
736
|
-
file: filepath,
|
|
950
|
+
const result = snapshotStore.write(filepath, {
|
|
737
951
|
state,
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// Keep the set in step with what this run has written, so a repeated
|
|
744
|
-
// target behaves exactly as it did when the check hit the disk.
|
|
745
|
-
idsWithHistory.add(snapshotIdForPath(filepath));
|
|
746
|
-
console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
|
|
952
|
+
documents: documentKeysOf(state) ?? [],
|
|
953
|
+
});
|
|
954
|
+
if (result.warning) warnings.add(result.warning);
|
|
955
|
+
pruned += result.pruned;
|
|
956
|
+
console.log(chalk.green(`✓ Snapshot saved: ${result.path}`));
|
|
747
957
|
written += 1;
|
|
748
958
|
}
|
|
959
|
+
for (const warning of warnings) renderWarn(warning);
|
|
960
|
+
if (pruned > 0) {
|
|
961
|
+
renderNote(
|
|
962
|
+
`Pruned ${pruned} snapshot${pruned === 1 ? '' : 's'} beyond the`
|
|
963
|
+
+ ` ${snapshotStore.retention}-entry retention of the ${snapshotStore.id} store.`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
if (written > 0 && snapshotStore.id === 'shared') {
|
|
967
|
+
renderNote(
|
|
968
|
+
`Shared store: commit ${snapshotStore.label} so every runner reads the same history.`
|
|
969
|
+
+ (snapshotStore.maskMode === 'none'
|
|
970
|
+
? ' Masking is off, so these files carry config values verbatim into git history.'
|
|
971
|
+
: ' Secret-like values are stored as digests, not plaintext.'),
|
|
972
|
+
);
|
|
973
|
+
}
|
|
749
974
|
if (written === 0 && !effective.allowEmpty) {
|
|
750
975
|
throw new Error(
|
|
751
976
|
'No snapshots written — all targets were missing or unsupported.' +
|
|
@@ -760,14 +985,14 @@ program
|
|
|
760
985
|
let compared = 0;
|
|
761
986
|
let missing = 0;
|
|
762
987
|
for (const filepath of targets) {
|
|
763
|
-
const
|
|
764
|
-
if (!
|
|
765
|
-
renderWarn(`No snapshot found for "${filepath}"`);
|
|
988
|
+
const record = snapshotStore.readLatest(filepath);
|
|
989
|
+
if (!record) {
|
|
990
|
+
renderWarn(`No snapshot found for "${filepath}" in ${snapshotStore.label}`);
|
|
766
991
|
missing += 1;
|
|
767
992
|
continue;
|
|
768
993
|
}
|
|
769
|
-
const before =
|
|
770
|
-
const after = parseFile(filepath);
|
|
994
|
+
const before = record.state;
|
|
995
|
+
const after = alignStateWithStore(parseFile(filepath), snapshotStore);
|
|
771
996
|
const events = diffTrees(before, after, dOpts);
|
|
772
997
|
renderDiff(filepath, events, { maskSecrets });
|
|
773
998
|
compared += 1;
|
|
@@ -779,7 +1004,7 @@ program
|
|
|
779
1004
|
// is the normal state of a fresh CI runner.
|
|
780
1005
|
if (compared === 0) {
|
|
781
1006
|
throw new Error(
|
|
782
|
-
|
|
1007
|
+
`No snapshot found for any target in ${snapshotStore.label}, so nothing was compared.`
|
|
783
1008
|
+ ' Run "flecto watch <file> --snapshot" first — no history is not no drift.',
|
|
784
1009
|
);
|
|
785
1010
|
}
|
|
@@ -897,8 +1122,10 @@ program
|
|
|
897
1122
|
|
|
898
1123
|
program
|
|
899
1124
|
.command('history [files...]')
|
|
900
|
-
.description('Summarize drift across
|
|
1125
|
+
.description('Summarize drift across saved snapshots')
|
|
901
1126
|
.option('-l, --limit <n>', 'Number of recent snapshots to show', '10')
|
|
1127
|
+
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
1128
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
902
1129
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
903
1130
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
904
1131
|
.option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
|
|
@@ -917,7 +1144,8 @@ program
|
|
|
917
1144
|
const ignorePaths = parseCsv(effective.ignore);
|
|
918
1145
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
919
1146
|
|
|
920
|
-
const
|
|
1147
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
1148
|
+
const allSnapshots = snapshotStore.readHistory();
|
|
921
1149
|
let snapshots = allSnapshots;
|
|
922
1150
|
if (files.length > 0) {
|
|
923
1151
|
const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
|
|
@@ -928,13 +1156,20 @@ program
|
|
|
928
1156
|
if (summaries.length === 0) {
|
|
929
1157
|
if (files.length > 0 && allSnapshots.length > 0) {
|
|
930
1158
|
throw new Error(
|
|
931
|
-
|
|
1159
|
+
`No snapshots in ${snapshotStore.label} matched the given files.`
|
|
1160
|
+
+ ' Omit files to view all saved snapshot history.',
|
|
932
1161
|
);
|
|
933
1162
|
}
|
|
934
|
-
throw new Error(
|
|
1163
|
+
throw new Error(
|
|
1164
|
+
`No snapshots found in ${snapshotStore.label}.`
|
|
1165
|
+
+ ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
|
|
1166
|
+
+ ' — no history is not no drift.',
|
|
1167
|
+
);
|
|
935
1168
|
}
|
|
936
1169
|
|
|
937
|
-
console.log(
|
|
1170
|
+
console.log(
|
|
1171
|
+
`Snapshot history from ${snapshotStore.label} (${summaries.length} snapshots, ${snapshotStore.id} store)`,
|
|
1172
|
+
);
|
|
938
1173
|
let baselines = 0;
|
|
939
1174
|
for (const snapshot of summaries) {
|
|
940
1175
|
const file = relative(process.cwd(), snapshot.file) || snapshot.file;
|
|
@@ -965,8 +1200,10 @@ program
|
|
|
965
1200
|
|
|
966
1201
|
program
|
|
967
1202
|
.command('report [files...]')
|
|
968
|
-
.description('Render
|
|
1203
|
+
.description('Render saved snapshot history as a self-contained HTML report')
|
|
969
1204
|
.option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
|
|
1205
|
+
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
1206
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
970
1207
|
.option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
|
|
971
1208
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
972
1209
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
@@ -992,10 +1229,15 @@ program
|
|
|
992
1229
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
993
1230
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
994
1231
|
const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
|
|
1232
|
+
assertWriteDestinationContained(outputPath, {
|
|
1233
|
+
option: '--output',
|
|
1234
|
+
fromCli: cliOverrides.output !== undefined,
|
|
1235
|
+
});
|
|
995
1236
|
|
|
996
1237
|
// Same snapshot source, filtering, and errors as `flecto history` — this
|
|
997
1238
|
// command only changes how that history is rendered.
|
|
998
|
-
const
|
|
1239
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
1240
|
+
const allSnapshots = snapshotStore.readHistory();
|
|
999
1241
|
let snapshots = allSnapshots;
|
|
1000
1242
|
if (files.length > 0) {
|
|
1001
1243
|
const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
|
|
@@ -1006,10 +1248,15 @@ program
|
|
|
1006
1248
|
if (summaries.length === 0) {
|
|
1007
1249
|
if (files.length > 0 && allSnapshots.length > 0) {
|
|
1008
1250
|
throw new Error(
|
|
1009
|
-
|
|
1251
|
+
`No snapshots in ${snapshotStore.label} matched the given files.`
|
|
1252
|
+
+ ' Omit files to report on all saved snapshot history.',
|
|
1010
1253
|
);
|
|
1011
1254
|
}
|
|
1012
|
-
throw new Error(
|
|
1255
|
+
throw new Error(
|
|
1256
|
+
`No snapshots found in ${snapshotStore.label}.`
|
|
1257
|
+
+ ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
|
|
1258
|
+
+ ' — no history is not no drift.',
|
|
1259
|
+
);
|
|
1013
1260
|
}
|
|
1014
1261
|
|
|
1015
1262
|
const reportSnapshots = [];
|
|
@@ -1043,6 +1290,7 @@ program
|
|
|
1043
1290
|
version: PKG.version,
|
|
1044
1291
|
limit,
|
|
1045
1292
|
maskSecrets,
|
|
1293
|
+
store: snapshotStore.label,
|
|
1046
1294
|
});
|
|
1047
1295
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
1048
1296
|
writeFileSync(outputPath, html, 'utf8');
|
|
@@ -1059,7 +1307,10 @@ program
|
|
|
1059
1307
|
.command('ci [files...]')
|
|
1060
1308
|
.description('Run semantic diff in CI mode')
|
|
1061
1309
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
1062
|
-
.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')
|
|
1312
|
+
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
1313
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
1063
1314
|
.option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
|
|
1064
1315
|
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
|
|
1065
1316
|
.option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
|
|
@@ -1076,12 +1327,18 @@ program
|
|
|
1076
1327
|
.option('--show-suppressed', 'List inline-suppressed findings instead of only counting them', false)
|
|
1077
1328
|
.option('--changed-only', 'With --format json|ndjson, replace envelopes for unchanged files with one scanned manifest', false)
|
|
1078
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)
|
|
1079
1332
|
.action(async (files, opts, command) => {
|
|
1080
1333
|
try {
|
|
1081
1334
|
const { config } = loadRcConfig(process.cwd());
|
|
1082
1335
|
const profile = resolveProfileName(opts.profile);
|
|
1083
1336
|
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
1084
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;
|
|
1085
1342
|
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
1086
1343
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
1087
1344
|
if (targets.length === 0) {
|
|
@@ -1112,14 +1369,37 @@ program
|
|
|
1112
1369
|
}
|
|
1113
1370
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
1114
1371
|
|
|
1372
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
1373
|
+
|
|
1115
1374
|
const cwd = process.cwd();
|
|
1116
1375
|
const baselinePath = effective.baseline ? resolve(cwd, String(effective.baseline)) : null;
|
|
1376
|
+
if (baselinePath) {
|
|
1377
|
+
assertWriteDestinationContained(baselinePath, {
|
|
1378
|
+
option: '--baseline',
|
|
1379
|
+
fromCli: cliOverrides.baseline !== undefined,
|
|
1380
|
+
cwd,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
// `--update-baseline` accepts every finding this run produced, so honoring
|
|
1384
|
+
// it from `.flectorc` would let a pull request turn its own failing gate
|
|
1385
|
+
// green — including one whose `--fail-on` was set explicitly on the command
|
|
1386
|
+
// line. It is an action, not a setting: the CLI is the only place it can
|
|
1387
|
+
// come from, and a declaration in the rc file is refused rather than
|
|
1388
|
+
// ignored, so a repository that meant it finds out.
|
|
1389
|
+
if (effective.updateBaseline && cliOverrides.updateBaseline === undefined) {
|
|
1390
|
+
throw new Error(
|
|
1391
|
+
'updateBaseline is declared in .flectorc, and it is refused there: it accepts every'
|
|
1392
|
+
+ ' current finding, which would turn a failing gate green from a file a pull request'
|
|
1393
|
+
+ ' can edit. Pass --update-baseline on the command line when you mean to record a'
|
|
1394
|
+
+ ' baseline.',
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1117
1397
|
const updateBaseline = Boolean(effective.updateBaseline);
|
|
1118
1398
|
if (updateBaseline && !baselinePath) {
|
|
1119
1399
|
throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
|
|
1120
1400
|
}
|
|
1121
1401
|
|
|
1122
|
-
/** @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 }>} */
|
|
1123
1403
|
const perFile = [];
|
|
1124
1404
|
/** @type {Array<{ file: string, finding: any, reason: string }>} */
|
|
1125
1405
|
const allSuppressed = [];
|
|
@@ -1134,10 +1414,14 @@ program
|
|
|
1134
1414
|
renderWarn(`Skipping unsupported file: ${filepath}`);
|
|
1135
1415
|
continue;
|
|
1136
1416
|
}
|
|
1137
|
-
|
|
1417
|
+
// A `--snapshot-ref` baseline is read straight from git, so it is never
|
|
1418
|
+
// masked; only a store-provided baseline needs the live side aligned.
|
|
1419
|
+
const after = (effective.snapshotRef || effective.snapshotFile)
|
|
1420
|
+
? parseFile(filepath)
|
|
1421
|
+
: alignStateWithStore(parseFile(filepath), snapshotStore);
|
|
1138
1422
|
let before;
|
|
1139
1423
|
try {
|
|
1140
|
-
before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
|
|
1424
|
+
before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore, effective.snapshotFile);
|
|
1141
1425
|
} catch (err) {
|
|
1142
1426
|
throw new Error(
|
|
1143
1427
|
`Failed to resolve snapshot baseline for "${filepath}"` +
|
|
@@ -1167,6 +1451,7 @@ program
|
|
|
1167
1451
|
perFile.push({
|
|
1168
1452
|
filepath,
|
|
1169
1453
|
relFile: baselineRelativePath(filepath, cwd),
|
|
1454
|
+
events,
|
|
1170
1455
|
outboundChanges: maybeMaskChanges(events, maskSecrets),
|
|
1171
1456
|
outboundFindings: maybeMaskFindings(policyFindings, events, maskSecrets),
|
|
1172
1457
|
changesFail: shouldFailFromChanges(events, failOn),
|
|
@@ -1268,13 +1553,24 @@ program
|
|
|
1268
1553
|
}
|
|
1269
1554
|
}
|
|
1270
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
|
+
|
|
1271
1566
|
if (format === 'pr-comment') {
|
|
1272
|
-
const body = renderPrComment(results, { cwd, failed: shouldFail });
|
|
1567
|
+
const body = renderPrComment(results, { cwd, failed: shouldFail, narration });
|
|
1273
1568
|
await writeStdout(body);
|
|
1274
1569
|
await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
|
|
1275
1570
|
} else {
|
|
1276
1571
|
const collapsible = changedOnly && (format === 'json' || format === 'ndjson');
|
|
1277
1572
|
await printCiOutput(collapsible ? collapseUnchangedResults(results) : results, format);
|
|
1573
|
+
if (narration) process.stderr.write(`\n${formatNarration(narration)}\n`);
|
|
1278
1574
|
}
|
|
1279
1575
|
process.exit(shouldFail ? 1 : 0);
|
|
1280
1576
|
} catch (err) {
|
|
@@ -1283,6 +1579,161 @@ program
|
|
|
1283
1579
|
}
|
|
1284
1580
|
});
|
|
1285
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
|
+
|
|
1286
1737
|
program
|
|
1287
1738
|
.command('plan <planFiles...>')
|
|
1288
1739
|
.description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
|
|
@@ -1583,6 +2034,75 @@ program
|
|
|
1583
2034
|
renderInfo(`Policy packs: ${detection.packs.join(', ')}`);
|
|
1584
2035
|
});
|
|
1585
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
|
+
|
|
1586
2106
|
program
|
|
1587
2107
|
.command('doctor')
|
|
1588
2108
|
.description('Check Flecto setup, config, and environment')
|