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
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'fs';
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
|
|
13
|
+
|
|
14
|
+
import { containsSecret, looksLikeSecretPath } from './secrets.js';
|
|
15
|
+
import { documentKeysOf, withDocumentKeys } from './documents.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where snapshot history lives (#141).
|
|
19
|
+
*
|
|
20
|
+
* Until now there was one answer: `.flecto-snapshots/` in the working
|
|
21
|
+
* directory, keyed by a hash of each file's **absolute** path. That is right for
|
|
22
|
+
* a laptop and useless everywhere else — an ephemeral CI runner starts with an
|
|
23
|
+
* empty directory on every run, and even a cached one would key `/home/runner/
|
|
24
|
+
* work/repo/config/prod.yaml` differently from the `/Users/me/repo/...` the same
|
|
25
|
+
* file has on the machine that wrote the snapshot. So `history` and `report` are
|
|
26
|
+
* local-only by construction, and `ci` has to be handed `--snapshot-ref`.
|
|
27
|
+
*
|
|
28
|
+
* This module makes the store pluggable and adds one shared backend:
|
|
29
|
+
*
|
|
30
|
+
* - **`local`** — unchanged, still the default. Absolute-path hash, one
|
|
31
|
+
* `<id>.json` baseline plus timestamped history entries, written byte-for-byte
|
|
32
|
+
* as before so an existing `.flecto-snapshots/` keeps working.
|
|
33
|
+
* - **`shared`** — a git-tracked store under `.flecto/snapshots/`, designed to be
|
|
34
|
+
* committed and read back on a runner that has never seen the file before.
|
|
35
|
+
*
|
|
36
|
+
* Four things make the shared store shareable, and each is a deliberate
|
|
37
|
+
* difference from `local`:
|
|
38
|
+
*
|
|
39
|
+
* 1. **Keyed by repo-relative path**, resolved from the git top level, so the
|
|
40
|
+
* same config file has the same key on every checkout and from any
|
|
41
|
+
* subdirectory.
|
|
42
|
+
* 2. **One file per config file**, holding that file's history oldest-first, so
|
|
43
|
+
* a new snapshot is an appended block in a diff rather than a new file with a
|
|
44
|
+
* hashed name nobody can review.
|
|
45
|
+
* 3. **Stable serialization** — keys sorted at every level, one scalar per line.
|
|
46
|
+
* Reordering keys in the source config produces no diff at all, and a real
|
|
47
|
+
* change produces exactly the lines that changed.
|
|
48
|
+
* 4. **Masking is a recorded property of the store**, defaulting to on, because
|
|
49
|
+
* committing snapshots writes config values into git history permanently.
|
|
50
|
+
*
|
|
51
|
+
* Masked values are stored as a digest rather than dropped or replaced with a
|
|
52
|
+
* constant: `flecto:sha256:…` still *changes* when the underlying value
|
|
53
|
+
* rotates, so a masked store detects "the production key was rotated" without
|
|
54
|
+
* ever recording either key. The honest limitation is that a digest of a
|
|
55
|
+
* low-entropy value can be brute-forced — it is a change detector, not a vault.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
export const SNAPSHOT_STORE_IDS = /** @type {const} */ (['local', 'shared']);
|
|
59
|
+
export const SNAPSHOT_MASK_MODES = /** @type {const} */ (['hash', 'none']);
|
|
60
|
+
|
|
61
|
+
export const LOCAL_SNAPSHOT_DIR = '.flecto-snapshots';
|
|
62
|
+
export const SHARED_SNAPSHOT_DIR = join('.flecto', 'snapshots');
|
|
63
|
+
|
|
64
|
+
/** Schema version of a shared-store file, for a future reshaping. */
|
|
65
|
+
export const SHARED_STORE_VERSION = 1;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How many snapshots per file the shared store keeps. An append-forever store
|
|
69
|
+
* inside a repository becomes its own problem, and drift is a recent-history
|
|
70
|
+
* question — nobody reviews the 200th-oldest snapshot of `prod.yaml`.
|
|
71
|
+
* `local` stays unbounded by default: it is not committed, and changing what an
|
|
72
|
+
* existing directory holds is not this change's business.
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_SHARED_RETENTION = 20;
|
|
75
|
+
|
|
76
|
+
const MASK_PREFIX = 'flecto:sha256:';
|
|
77
|
+
const MASK_DIGEST_LENGTH = 12;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {'local' | 'shared'} SnapshotStoreId
|
|
81
|
+
* @typedef {'hash' | 'none'} SnapshotMaskMode
|
|
82
|
+
*
|
|
83
|
+
* @typedef {{
|
|
84
|
+
* file: string,
|
|
85
|
+
* state: unknown,
|
|
86
|
+
* documents?: string[],
|
|
87
|
+
* createdAt: string
|
|
88
|
+
* }} SnapshotRecord
|
|
89
|
+
*
|
|
90
|
+
* @typedef {{
|
|
91
|
+
* id: SnapshotStoreId,
|
|
92
|
+
* root: string,
|
|
93
|
+
* label: string,
|
|
94
|
+
* maskMode: SnapshotMaskMode,
|
|
95
|
+
* retention: number,
|
|
96
|
+
* emptyHint: string,
|
|
97
|
+
* exists: () => boolean,
|
|
98
|
+
* readLatest: (absFile: string) => SnapshotRecord | null,
|
|
99
|
+
* readHistory: () => SnapshotRecord[],
|
|
100
|
+
* write: (absFile: string, record: { state: unknown, documents?: string[], createdAt?: string })
|
|
101
|
+
* => { path: string, pruned: number, warning?: string }
|
|
102
|
+
* }} SnapshotStore
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate and resolve the store selection into a store instance.
|
|
107
|
+
* @param {{
|
|
108
|
+
* store?: unknown,
|
|
109
|
+
* dir?: unknown,
|
|
110
|
+
* mask?: unknown,
|
|
111
|
+
* retention?: unknown,
|
|
112
|
+
* cwd?: string
|
|
113
|
+
* }} [options]
|
|
114
|
+
* @returns {SnapshotStore}
|
|
115
|
+
*/
|
|
116
|
+
export function resolveSnapshotStore(options = {}) {
|
|
117
|
+
const cwd = options.cwd ?? process.cwd();
|
|
118
|
+
const id = normalizeStoreId(options.store);
|
|
119
|
+
const maskMode = normalizeMaskMode(options.mask, id);
|
|
120
|
+
const retention = normalizeRetention(options.retention, id);
|
|
121
|
+
|
|
122
|
+
if (id === 'local') {
|
|
123
|
+
const root = resolve(cwd, options.dir ? String(options.dir) : LOCAL_SNAPSHOT_DIR);
|
|
124
|
+
return createLocalStore({ root, retention, maskMode, cwd });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const projectRoot = resolveProjectRoot(cwd);
|
|
128
|
+
const root = options.dir
|
|
129
|
+
? resolve(cwd, String(options.dir))
|
|
130
|
+
: resolve(projectRoot, SHARED_SNAPSHOT_DIR);
|
|
131
|
+
return createSharedStore({ root, projectRoot, retention, maskMode });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {unknown} raw
|
|
136
|
+
* @returns {SnapshotStoreId}
|
|
137
|
+
*/
|
|
138
|
+
function normalizeStoreId(raw) {
|
|
139
|
+
if (raw === undefined || raw === null || raw === '') return 'local';
|
|
140
|
+
const id = String(raw);
|
|
141
|
+
if (!SNAPSHOT_STORE_IDS.includes(/** @type {SnapshotStoreId} */ (id))) {
|
|
142
|
+
throw new Error(`--snapshot-store must be one of: ${SNAPSHOT_STORE_IDS.join(', ')} (got "${id}")`);
|
|
143
|
+
}
|
|
144
|
+
return /** @type {SnapshotStoreId} */ (id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Masking defaults **on** for the shared store and **off** for the local one.
|
|
149
|
+
* The inversion is the point: a local store is a scratch directory on the
|
|
150
|
+
* author's own machine, a shared one is headed for a commit, and a default that
|
|
151
|
+
* writes plaintext secrets into git history is not a default worth having.
|
|
152
|
+
* @param {unknown} raw
|
|
153
|
+
* @param {SnapshotStoreId} storeId
|
|
154
|
+
* @returns {SnapshotMaskMode}
|
|
155
|
+
*/
|
|
156
|
+
function normalizeMaskMode(raw, storeId) {
|
|
157
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
158
|
+
return storeId === 'shared' ? 'hash' : 'none';
|
|
159
|
+
}
|
|
160
|
+
const mode = String(raw);
|
|
161
|
+
if (!SNAPSHOT_MASK_MODES.includes(/** @type {SnapshotMaskMode} */ (mode))) {
|
|
162
|
+
throw new Error(`--snapshot-mask must be one of: ${SNAPSHOT_MASK_MODES.join(', ')} (got "${mode}")`);
|
|
163
|
+
}
|
|
164
|
+
return /** @type {SnapshotMaskMode} */ (mode);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {unknown} raw
|
|
169
|
+
* @param {SnapshotStoreId} storeId
|
|
170
|
+
* @returns {number} 0 means "keep everything"
|
|
171
|
+
*/
|
|
172
|
+
function normalizeRetention(raw, storeId) {
|
|
173
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
174
|
+
return storeId === 'shared' ? DEFAULT_SHARED_RETENTION : 0;
|
|
175
|
+
}
|
|
176
|
+
const retention = Number.parseInt(String(raw), 10);
|
|
177
|
+
if (!Number.isInteger(retention) || retention < 0) {
|
|
178
|
+
throw new Error('--snapshot-retention must be a non-negative integer (0 keeps every snapshot)');
|
|
179
|
+
}
|
|
180
|
+
return retention;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The directory repo-relative snapshot keys are measured from: the git top
|
|
185
|
+
* level when there is one, else the working directory.
|
|
186
|
+
*
|
|
187
|
+
* Using the top level rather than `process.cwd()` is what lets `flecto history`
|
|
188
|
+
* run from `services/api/` and still find the snapshots `flecto watch` wrote
|
|
189
|
+
* from the repository root — the same reason `--snapshot-ref` resolves paths
|
|
190
|
+
* through `git rev-parse` (#79).
|
|
191
|
+
* @param {string} cwd
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
function resolveProjectRoot(cwd) {
|
|
195
|
+
try {
|
|
196
|
+
const top = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
199
|
+
}).trim();
|
|
200
|
+
if (top) return resolve(top);
|
|
201
|
+
} catch {
|
|
202
|
+
// Not a git repository, or git is not installed. A shared store still works
|
|
203
|
+
// relative to the working directory; it is only *sharing* it that wants git.
|
|
204
|
+
}
|
|
205
|
+
return resolve(cwd);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ------------------------------------------------------------------ local -- */
|
|
209
|
+
|
|
210
|
+
function snapshotIdForPath(absPath) {
|
|
211
|
+
const normalized = absPath.replaceAll('\\', '/');
|
|
212
|
+
return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The `.flecto-snapshots/` store, unchanged in every observable way: same
|
|
217
|
+
* filenames, same JSON, same absolute-path keying. It is wrapped in the store
|
|
218
|
+
* interface so the consumers have exactly one code path.
|
|
219
|
+
* @param {{ root: string, retention: number, maskMode: SnapshotMaskMode, cwd: string }} options
|
|
220
|
+
* @returns {SnapshotStore}
|
|
221
|
+
*/
|
|
222
|
+
function createLocalStore({ root, retention, maskMode, cwd }) {
|
|
223
|
+
const label = `${relative(cwd, root).split(sep).join('/') || root}/`;
|
|
224
|
+
|
|
225
|
+
const baselinePath = (absFile) => join(root, `${snapshotIdForPath(absFile)}.json`);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Timestamped history entries per snapshot id, listed **once** per run and
|
|
229
|
+
* then maintained in memory. Probing the directory per file made writing N
|
|
230
|
+
* baselines cost N listings of O(N) entries each, which is quadratic in the
|
|
231
|
+
* number of tracked files (see `docs/performance.md`); the store is the only
|
|
232
|
+
* writer during a run, so keeping the map in step is exact.
|
|
233
|
+
* @type {Map<string, string[]> | null}
|
|
234
|
+
*/
|
|
235
|
+
let historyNames = null;
|
|
236
|
+
|
|
237
|
+
const historyNamesById = () => {
|
|
238
|
+
if (historyNames) return historyNames;
|
|
239
|
+
historyNames = new Map();
|
|
240
|
+
if (!existsSync(root)) return historyNames;
|
|
241
|
+
for (const name of readdirSync(root)) {
|
|
242
|
+
const match = /^([a-f0-9]{16})\.\d+\.json$/.exec(name);
|
|
243
|
+
if (!match) continue;
|
|
244
|
+
const names = historyNames.get(match[1]) ?? [];
|
|
245
|
+
names.push(name);
|
|
246
|
+
historyNames.set(match[1], names);
|
|
247
|
+
}
|
|
248
|
+
for (const names of historyNames.values()) {
|
|
249
|
+
names.sort((a, b) => Number(a.split('.')[1]) - Number(b.split('.')[1]));
|
|
250
|
+
}
|
|
251
|
+
return historyNames;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const historyPath = (absFile) => {
|
|
255
|
+
const id = snapshotIdForPath(absFile);
|
|
256
|
+
const taken = new Set(historyNamesById().get(id) ?? []);
|
|
257
|
+
let timestamp = Date.now();
|
|
258
|
+
while (taken.has(`${id}.${timestamp}.json`) || existsSync(join(root, `${id}.${timestamp}.json`))) {
|
|
259
|
+
timestamp += 1;
|
|
260
|
+
}
|
|
261
|
+
const name = `${id}.${timestamp}.json`;
|
|
262
|
+
historyNamesById().set(id, [...(historyNamesById().get(id) ?? []), name]);
|
|
263
|
+
return join(root, name);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
id: 'local',
|
|
268
|
+
root,
|
|
269
|
+
label,
|
|
270
|
+
maskMode,
|
|
271
|
+
retention,
|
|
272
|
+
emptyHint: `${label} holds none. Save one with "flecto watch <file> --snapshot",`
|
|
273
|
+
+ ' or pass --snapshot-ref <git-ref> to diff against a committed revision instead',
|
|
274
|
+
|
|
275
|
+
exists: () => existsSync(root),
|
|
276
|
+
|
|
277
|
+
readLatest(absFile) {
|
|
278
|
+
const path = baselinePath(absFile);
|
|
279
|
+
if (!existsSync(path)) return null;
|
|
280
|
+
const snapshot = JSON.parse(readFileSync(path, 'utf8'));
|
|
281
|
+
return {
|
|
282
|
+
file: typeof snapshot?.file === 'string' ? snapshot.file : absFile,
|
|
283
|
+
state: restoreDocumentKeys(snapshot?.state ?? snapshot, snapshot?.documents),
|
|
284
|
+
documents: Array.isArray(snapshot?.documents) ? snapshot.documents.map(String) : undefined,
|
|
285
|
+
createdAt: snapshot?.createdAt ?? statSync(path).mtime.toISOString(),
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
readHistory() {
|
|
290
|
+
if (!existsSync(root)) return [];
|
|
291
|
+
const entries = readdirSync(root, { withFileTypes: true })
|
|
292
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
|
|
293
|
+
const timestamped = entries.filter((entry) => /^[a-f0-9]{16}\.\d+\.json$/.test(entry.name));
|
|
294
|
+
const withHistory = new Set(timestamped.map((entry) => entry.name.slice(0, 16)));
|
|
295
|
+
// A `<id>.json` with no timestamped sibling is a store written before
|
|
296
|
+
// history existed; it is that file's only snapshot.
|
|
297
|
+
const legacy = entries.filter((entry) =>
|
|
298
|
+
/^[a-f0-9]{16}\.json$/.test(entry.name) && !withHistory.has(entry.name.slice(0, 16)));
|
|
299
|
+
|
|
300
|
+
return [...timestamped, ...legacy].map((entry) => {
|
|
301
|
+
const path = join(root, entry.name);
|
|
302
|
+
const snapshot = JSON.parse(readFileSync(path, 'utf8'));
|
|
303
|
+
if (typeof snapshot?.file !== 'string') {
|
|
304
|
+
throw new Error(`Invalid snapshot file: ${path}`);
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
file: snapshot.file,
|
|
308
|
+
state: restoreDocumentKeys(snapshot?.state ?? snapshot, snapshot?.documents),
|
|
309
|
+
documents: Array.isArray(snapshot?.documents) ? snapshot.documents.map(String) : undefined,
|
|
310
|
+
createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
write(absFile, record) {
|
|
316
|
+
mkdirSync(root, { recursive: true });
|
|
317
|
+
const createdAt = record.createdAt ?? new Date().toISOString();
|
|
318
|
+
const documents = record.documents ?? [];
|
|
319
|
+
const state = maskMode === 'hash' ? maskState(record.state) : record.state;
|
|
320
|
+
// Field order is the order this file has always written, so an unmasked
|
|
321
|
+
// local snapshot is byte-for-byte what it was before the store existed.
|
|
322
|
+
const snapshot = {
|
|
323
|
+
file: absFile,
|
|
324
|
+
state,
|
|
325
|
+
...(documents.length > 0 ? { documents: [...documents] } : {}),
|
|
326
|
+
createdAt,
|
|
327
|
+
};
|
|
328
|
+
const serialized = JSON.stringify(snapshot, null, 2);
|
|
329
|
+
|
|
330
|
+
const id = snapshotIdForPath(absFile);
|
|
331
|
+
const path = baselinePath(absFile);
|
|
332
|
+
// A store written before timestamped history existed holds a baseline and
|
|
333
|
+
// nothing else; promote it to a history entry before it is overwritten, so
|
|
334
|
+
// the first `--snapshot` after an upgrade does not silently drop it.
|
|
335
|
+
if (existsSync(path) && (historyNamesById().get(id) ?? []).length === 0) {
|
|
336
|
+
const legacy = JSON.parse(readFileSync(path, 'utf8'));
|
|
337
|
+
writeFileSync(historyPath(absFile), `${JSON.stringify({
|
|
338
|
+
file: legacy.file ?? absFile,
|
|
339
|
+
state: legacy.state ?? legacy,
|
|
340
|
+
...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
|
|
341
|
+
createdAt: legacy.createdAt ?? statSync(path).mtime.toISOString(),
|
|
342
|
+
}, null, 2)}`, 'utf8');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
writeFileSync(path, serialized, 'utf8');
|
|
346
|
+
writeFileSync(historyPath(absFile), serialized, 'utf8');
|
|
347
|
+
|
|
348
|
+
let pruned = 0;
|
|
349
|
+
if (retention > 0) {
|
|
350
|
+
const names = historyNamesById().get(id) ?? [];
|
|
351
|
+
const excess = names.slice(0, Math.max(0, names.length - retention));
|
|
352
|
+
for (const name of excess) {
|
|
353
|
+
rmSync(join(root, name), { force: true });
|
|
354
|
+
pruned += 1;
|
|
355
|
+
}
|
|
356
|
+
historyNamesById().set(id, names.slice(excess.length));
|
|
357
|
+
}
|
|
358
|
+
return { path, pruned };
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* ----------------------------------------------------------------- shared -- */
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @param {{ root: string, projectRoot: string, retention: number, maskMode: SnapshotMaskMode }} options
|
|
367
|
+
* @returns {SnapshotStore}
|
|
368
|
+
*/
|
|
369
|
+
function createSharedStore({ root, projectRoot, retention, maskMode }) {
|
|
370
|
+
const label = `${relative(projectRoot, root).split(sep).join('/') || root}/`;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The store key for a file: its path relative to the project root, POSIX
|
|
374
|
+
* slashed. A file outside the project has no such key, and inventing one (a
|
|
375
|
+
* hash, an absolute path) would produce a store entry that is meaningless on
|
|
376
|
+
* any other checkout — so it is refused rather than written somewhere useless.
|
|
377
|
+
*
|
|
378
|
+
* "Outside" includes a file `relative` cannot reach at all: on Windows a target
|
|
379
|
+
* on another drive or a UNC share comes back *absolute* (`D:\etc\hosts`), not
|
|
380
|
+
* `..`-prefixed, and would otherwise be accepted as a key.
|
|
381
|
+
* @param {string} absFile
|
|
382
|
+
* @returns {string}
|
|
383
|
+
*/
|
|
384
|
+
const keyFor = (absFile) => {
|
|
385
|
+
const rel = relative(projectRoot, resolve(absFile));
|
|
386
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`The shared snapshot store keys snapshots by repo-relative path, and "${absFile}" is`
|
|
389
|
+
+ ` outside ${projectRoot}. Run Flecto from the repository that holds the file, or use`
|
|
390
|
+
+ ' --snapshot-store local for files outside a repository.',
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return rel.split(sep).join('/');
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const pathForKey = (key) => join(root, `${key.split('/').join(sep)}.json`);
|
|
397
|
+
|
|
398
|
+
/** @returns {string[]} every `*.json` under the store root, POSIX-keyed */
|
|
399
|
+
const storeFiles = () => {
|
|
400
|
+
if (!existsSync(root)) return [];
|
|
401
|
+
/** @type {string[]} */
|
|
402
|
+
const out = [];
|
|
403
|
+
const walk = (dir) => {
|
|
404
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
405
|
+
const full = join(dir, entry.name);
|
|
406
|
+
if (entry.isDirectory()) walk(full);
|
|
407
|
+
else if (entry.isFile() && entry.name.endsWith('.json')) out.push(full);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
walk(root);
|
|
411
|
+
return out.sort();
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {string} path
|
|
416
|
+
* @returns {{ version: number, file: string, masking: SnapshotMaskMode, snapshots: any[] }}
|
|
417
|
+
*/
|
|
418
|
+
const readStoreFile = (path) => {
|
|
419
|
+
let parsed;
|
|
420
|
+
try {
|
|
421
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
422
|
+
} catch (err) {
|
|
423
|
+
throw new Error(`Snapshot store file is not valid JSON: ${path}: ${err.message}`);
|
|
424
|
+
}
|
|
425
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.snapshots)) {
|
|
426
|
+
throw new Error(`Snapshot store file is malformed (expected a "snapshots" array): ${path}`);
|
|
427
|
+
}
|
|
428
|
+
if (typeof parsed.file !== 'string') {
|
|
429
|
+
throw new Error(`Snapshot store file is missing its "file" key: ${path}`);
|
|
430
|
+
}
|
|
431
|
+
return parsed;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
id: 'shared',
|
|
436
|
+
root,
|
|
437
|
+
label,
|
|
438
|
+
maskMode,
|
|
439
|
+
retention,
|
|
440
|
+
emptyHint: `${label} holds none. Save one with "flecto watch <file> --snapshot`
|
|
441
|
+
+ ' --snapshot-store shared" and commit it, so every runner reads the same history',
|
|
442
|
+
|
|
443
|
+
exists: () => existsSync(root),
|
|
444
|
+
|
|
445
|
+
readLatest(absFile) {
|
|
446
|
+
const path = pathForKey(keyFor(absFile));
|
|
447
|
+
if (!existsSync(path)) return null;
|
|
448
|
+
const stored = readStoreFile(path);
|
|
449
|
+
const latest = stored.snapshots.at(-1);
|
|
450
|
+
if (!latest) return null;
|
|
451
|
+
return {
|
|
452
|
+
file: resolve(projectRoot, stored.file),
|
|
453
|
+
state: restoreDocumentKeys(latest.state, latest.documents),
|
|
454
|
+
documents: Array.isArray(latest.documents) ? latest.documents.map(String) : undefined,
|
|
455
|
+
createdAt: String(latest.createdAt ?? statSync(path).mtime.toISOString()),
|
|
456
|
+
};
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
readHistory() {
|
|
460
|
+
/** @type {SnapshotRecord[]} */
|
|
461
|
+
const records = [];
|
|
462
|
+
for (const path of storeFiles()) {
|
|
463
|
+
const stored = readStoreFile(path);
|
|
464
|
+
// Absolute, because every consumer compares a snapshot's file against
|
|
465
|
+
// resolved CLI targets. The stored form stays repo-relative.
|
|
466
|
+
const file = resolve(projectRoot, stored.file);
|
|
467
|
+
for (const entry of stored.snapshots) {
|
|
468
|
+
records.push({
|
|
469
|
+
file,
|
|
470
|
+
state: restoreDocumentKeys(entry?.state, entry?.documents),
|
|
471
|
+
documents: Array.isArray(entry?.documents) ? entry.documents.map(String) : undefined,
|
|
472
|
+
createdAt: String(entry?.createdAt ?? statSync(path).mtime.toISOString()),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return records;
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
write(absFile, record) {
|
|
480
|
+
const key = keyFor(absFile);
|
|
481
|
+
const path = pathForKey(key);
|
|
482
|
+
const createdAt = record.createdAt ?? new Date().toISOString();
|
|
483
|
+
const documents = record.documents ?? [];
|
|
484
|
+
const state = maskMode === 'hash' ? maskState(record.state) : record.state;
|
|
485
|
+
|
|
486
|
+
let snapshots = [];
|
|
487
|
+
let warning;
|
|
488
|
+
if (existsSync(path)) {
|
|
489
|
+
const stored = readStoreFile(path);
|
|
490
|
+
snapshots = stored.snapshots;
|
|
491
|
+
const previousMask = stored.masking === 'hash' ? 'hash' : 'none';
|
|
492
|
+
if (previousMask !== maskMode) {
|
|
493
|
+
// Say it rather than let the next diff read as "every secret changed",
|
|
494
|
+
// which is what switching masking mid-history looks like downstream.
|
|
495
|
+
warning = `${label}${key}.json was written with masking "${previousMask}" and this run`
|
|
496
|
+
+ ` uses "${maskMode}". Existing entries keep the form they were written in, so the`
|
|
497
|
+
+ ' next diff will report masked values as changed.';
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
snapshots.push({
|
|
502
|
+
createdAt,
|
|
503
|
+
...(documents.length > 0 ? { documents: [...documents] } : {}),
|
|
504
|
+
state,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
let pruned = 0;
|
|
508
|
+
if (retention > 0 && snapshots.length > retention) {
|
|
509
|
+
pruned = snapshots.length - retention;
|
|
510
|
+
snapshots = snapshots.slice(pruned);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
514
|
+
writeFileSync(path, `${stableStringify({
|
|
515
|
+
version: SHARED_STORE_VERSION,
|
|
516
|
+
file: key,
|
|
517
|
+
masking: maskMode,
|
|
518
|
+
snapshots,
|
|
519
|
+
})}\n`, 'utf8');
|
|
520
|
+
return { path, pruned, warning };
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Put the parser's multi-document signal back on a state read out of a store.
|
|
527
|
+
* A stored snapshot is plain JSON, so the in-memory marking is gone; one written
|
|
528
|
+
* before the field existed leaves the provenance unknown, which is what it is.
|
|
529
|
+
* @param {unknown} state
|
|
530
|
+
* @param {unknown} documents
|
|
531
|
+
* @returns {unknown} the same state
|
|
532
|
+
*/
|
|
533
|
+
function restoreDocumentKeys(state, documents) {
|
|
534
|
+
if (!Array.isArray(documents)) return state;
|
|
535
|
+
return withDocumentKeys(state, documents.map(String));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/* ------------------------------------------------------------ serialization */
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* JSON with object keys sorted at every level, two-space indent, one scalar per
|
|
542
|
+
* line — the serialization a committed store needs.
|
|
543
|
+
*
|
|
544
|
+
* `JSON.stringify` preserves insertion order, which is the parser's order, which
|
|
545
|
+
* is the file's order: moving a key in `prod.yaml` would rewrite the snapshot
|
|
546
|
+
* from that point down and bury the one line that actually changed. Sorting
|
|
547
|
+
* makes the diff of a snapshot the diff of the config's *meaning*, which is the
|
|
548
|
+
* whole premise of the tool.
|
|
549
|
+
* @param {unknown} value
|
|
550
|
+
* @returns {string}
|
|
551
|
+
*/
|
|
552
|
+
export function stableStringify(value) {
|
|
553
|
+
return JSON.stringify(sortDeep(value), null, 2);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* @param {unknown} value
|
|
558
|
+
* @returns {unknown}
|
|
559
|
+
*/
|
|
560
|
+
function sortDeep(value) {
|
|
561
|
+
if (Array.isArray(value)) return value.map(sortDeep);
|
|
562
|
+
if (!isPlainObject(value)) return value;
|
|
563
|
+
const out = {};
|
|
564
|
+
for (const key of Object.keys(/** @type {object} */ (value)).sort()) {
|
|
565
|
+
// defineProperty so a config key literally named `__proto__` stays an
|
|
566
|
+
// ordinary own property instead of reaching Object.prototype, matching how
|
|
567
|
+
// the parser and the differ handle the same name.
|
|
568
|
+
Object.defineProperty(out, key, {
|
|
569
|
+
value: sortDeep(/** @type {Record<string, unknown>} */ (value)[key]),
|
|
570
|
+
enumerable: true,
|
|
571
|
+
writable: true,
|
|
572
|
+
configurable: true,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* @param {unknown} value
|
|
580
|
+
* @returns {boolean}
|
|
581
|
+
*/
|
|
582
|
+
function isPlainObject(value) {
|
|
583
|
+
if (!value || typeof value !== 'object') return false;
|
|
584
|
+
const proto = Object.getPrototypeOf(value);
|
|
585
|
+
return proto === Object.prototype || proto === null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* ---------------------------------------------------------------- masking -- */
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The stored form of a secret value: a truncated digest, prefixed so it is
|
|
592
|
+
* obvious in a review what it is and where it came from.
|
|
593
|
+
* @param {string} value
|
|
594
|
+
* @returns {string}
|
|
595
|
+
*/
|
|
596
|
+
export function maskedDigest(value) {
|
|
597
|
+
return MASK_PREFIX + createHash('sha256').update(value).digest('hex').slice(0, MASK_DIGEST_LENGTH);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* True for a value this module produced, so masking is idempotent: re-masking a
|
|
602
|
+
* store that already holds digests must not digest the digests.
|
|
603
|
+
* @param {unknown} value
|
|
604
|
+
* @returns {boolean}
|
|
605
|
+
*/
|
|
606
|
+
export function isMaskedDigest(value) {
|
|
607
|
+
return typeof value === 'string' && value.startsWith(MASK_PREFIX);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Replace every secret-like value in a parsed state with its digest.
|
|
612
|
+
*
|
|
613
|
+
* Two things make a value secret-like, and the store needs both:
|
|
614
|
+
*
|
|
615
|
+
* - **Its shape** — an opaque high-entropy string, a private key block, a URL
|
|
616
|
+
* with credentials in it. Whole values, not just the matched span: a
|
|
617
|
+
* connection string keeps its shape under `redactSecretString`, and a shape
|
|
618
|
+
* that stays constant while the embedded credential rotates is exactly the
|
|
619
|
+
* silent all-clear a drift store must not produce.
|
|
620
|
+
* - **Its key name** — `password: hunter2` is a credential and looks like
|
|
621
|
+
* nothing at all. This is the half the terminal has always masked, and it is
|
|
622
|
+
* the half that matters more here: `--mask-secrets` keeps a value out of a
|
|
623
|
+
* log that scrolls away, while this store is *committed*, so a value it
|
|
624
|
+
* records in plaintext is in git history permanently.
|
|
625
|
+
*
|
|
626
|
+
* Matching the two makes the store no more permissive than the renderer, which
|
|
627
|
+
* is the only defensible relationship between them.
|
|
628
|
+
*
|
|
629
|
+
* Containers are walked rather than collapsed. The renderer replaces a whole
|
|
630
|
+
* subtree under a secret-looking key with `***`, which is right for display and
|
|
631
|
+
* wrong for a store: a single digest standing in for an object would report
|
|
632
|
+
* "something under here changed" without ever saying what, and the point of the
|
|
633
|
+
* store is that a real change produces exactly the lines that changed.
|
|
634
|
+
* @param {unknown} state
|
|
635
|
+
* @param {string} [path] configuration path of `state`, for key-name matching
|
|
636
|
+
* @returns {unknown}
|
|
637
|
+
*/
|
|
638
|
+
export function maskState(state, path = '') {
|
|
639
|
+
if (Array.isArray(state)) return state.map((entry, index) => maskState(entry, `${path}[${index}]`));
|
|
640
|
+
|
|
641
|
+
if (isPlainObject(state)) {
|
|
642
|
+
const documents = documentKeysOf(state);
|
|
643
|
+
const documentKeys = new Set(documents ?? []);
|
|
644
|
+
const masked = Object.fromEntries(
|
|
645
|
+
Object.entries(/** @type {Record<string, unknown>} */ (state)).map(([key, value]) => [
|
|
646
|
+
key,
|
|
647
|
+
// A document identity is a resource name the user chose, not a key
|
|
648
|
+
// name: a Deployment called `token-service` must not make every value
|
|
649
|
+
// inside it read as a secret. The renderer strips the same prefix via
|
|
650
|
+
// `secretMatchPath`; here the document keys are the root keys, so
|
|
651
|
+
// skipping them in the path is the same rule.
|
|
652
|
+
maskState(value, documentKeys.has(key) ? path : (path ? `${path}.${key}` : key)),
|
|
653
|
+
]),
|
|
654
|
+
);
|
|
655
|
+
// Rebuilding the root drops the parser's multi-document marking, which the
|
|
656
|
+
// stripping above reads. Carry it across rather than making the masked tree
|
|
657
|
+
// look single-document.
|
|
658
|
+
return documents ? withDocumentKeys(masked, documents) : masked;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Nothing to hide in an absent value, and digesting it would invent a secret
|
|
662
|
+
// where the config says there is none.
|
|
663
|
+
if (state === null || state === undefined) return state;
|
|
664
|
+
// Idempotent: re-masking a store that already holds digests must not digest
|
|
665
|
+
// the digests.
|
|
666
|
+
if (isMaskedDigest(state)) return state;
|
|
667
|
+
// `String(state)` because a credential is not always a string — `password:
|
|
668
|
+
// 12345` parses as a number, and it is still the password.
|
|
669
|
+
if (looksLikeSecretPath(path)) return maskedDigest(String(state));
|
|
670
|
+
if (typeof state === 'string') return containsSecret(state) ? maskedDigest(state) : state;
|
|
671
|
+
return state;
|
|
672
|
+
}
|