confdiff 0.14.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/dist/cli.js ADDED
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join, basename } from "node:path";
5
+ import pc from "picocolors";
6
+ import { diff } from "./diff.js";
7
+ import { parseContent, keyRowsByColumn, detectFormat } from "./parse.js";
8
+ import { renderText, renderJson } from "./render.js";
9
+ import { makeRedactMatcher } from "./redact.js";
10
+ import { installGitDriver, DEFAULT_PATTERNS } from "./gitdriver.js";
11
+ import { isDirectory, dirDiff } from "./dirdiff.js";
12
+ const FORMATS = ["json", "yaml", "toml", "ini", "env", "properties", "csv", "xml"];
13
+ function getVersion() {
14
+ try {
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
17
+ return pkg.version ?? "0.0.0";
18
+ }
19
+ catch {
20
+ return "0.0.0";
21
+ }
22
+ }
23
+ const HELP = `${pc.bold("confdiff")} — semantic, format-aware diff for config & structured data
24
+
25
+ ${pc.bold("USAGE")}
26
+ confdiff <a> <b> [options]
27
+ confdiff old.yaml new.yaml
28
+ confdiff config.json config.yaml # cross-format compare
29
+ confdiff old.csv new.csv --csv-key id # match CSV rows by a key column
30
+ confdiff old.xml new.xml # semantic XML (order-insensitive)
31
+ confdiff old-manifests/ new-manifests/ # recurse: diff every config file in a tree
32
+ cat a.env | confdiff - b.env --format env
33
+
34
+ ${pc.bold("OUTPUT")}
35
+ Shows only what semantically changed: added (${pc.green("+")}), removed (${pc.red("-")}),
36
+ changed (${pc.yellow("~")}). Key order, formatting, comments and quoting are ignored.
37
+
38
+ ${pc.bold("OPTIONS")}
39
+ -f, --format <fmt> Force format for BOTH inputs (${FORMATS.join(", ")})
40
+ --format-a <fmt> Force format for the first input
41
+ --format-b <fmt> Force format for the second input
42
+ -i, --ignore <glob> Ignore paths matching glob (repeatable / comma-separated)
43
+ e.g. -i "metadata.*" -i "**.timestamp"
44
+ -o, --only <glob> Only compare paths matching glob (repeatable)
45
+ -l, --loose Loose scalars: "3"==3, "true"==true (great for .env/.ini)
46
+ --csv-key <col> For CSV/TSV: match rows by this column, not by position
47
+ --redact Mask secret values (passwords/tokens/keys) as a stable
48
+ fingerprint — safe to paste a diff into a PR/Slack/CI
49
+ --redact-key <glob> Also redact values at these key/path globs (repeatable)
50
+ --redact-entropy Also redact values that LOOK like secrets (long, random,
51
+ high-entropy tokens) under any key name; implies --redact
52
+ --array-set Compare arrays as unordered sets (ignore element order)
53
+ --array-key <spec> Match arrays of objects by a key field, not by position
54
+ (k8s env/containers): --array-key name; scope with
55
+ <pathGlob>=<field>, repeatable / comma-separated
56
+ --json Machine-readable JSON output (for CI / scripts)
57
+ -q, --quiet No output; communicate via exit code only
58
+ --no-color Disable ANSI color
59
+ --exit-zero Always exit 0 even when there are differences
60
+ -h, --help Show this help
61
+ -v, --version Show version
62
+
63
+ ${pc.bold("EXIT CODES")}
64
+ 0 no semantic differences
65
+ 1 differences found
66
+ 2 usage or parse error
67
+
68
+ ${pc.bold("GIT INTEGRATION")}
69
+ One-time setup, then ${pc.bold("git diff")} shows semantic diffs for config files:
70
+ confdiff install-git-driver # wire up the current repo
71
+ confdiff install-git-driver --global # wire up all your repos
72
+ This sets diff.confdiff.command and adds patterns (${DEFAULT_PATTERNS.slice(0, 4).join(", ")}, …)
73
+ to .gitattributes. To wire it up by hand instead:
74
+ git config diff.confdiff.command 'confdiff --git-diff-driver'
75
+ echo '*.yaml diff=confdiff' >> .gitattributes
76
+
77
+ Docs: https://github.com/esperanza-volkov/confdiff
78
+ `;
79
+ const INSTALL_HELP = `${pc.bold("confdiff install-git-driver")} — set up confdiff as a git diff driver
80
+
81
+ ${pc.bold("USAGE")}
82
+ confdiff install-git-driver [--global] [pattern ...]
83
+
84
+ ${pc.bold("OPTIONS")}
85
+ --global Configure for all repos (git config --global + global attributes)
86
+ pattern ... File patterns to wire up (default: ${DEFAULT_PATTERNS.join(" ")})
87
+
88
+ After running this, ${pc.bold("git diff")} / ${pc.bold("git log -p")} on matching files shows
89
+ confdiff's semantic diff instead of raw text. Re-running is safe (idempotent).
90
+ `;
91
+ function runInstall(rest) {
92
+ if (rest.includes("-h") || rest.includes("--help")) {
93
+ process.stdout.write(INSTALL_HELP);
94
+ return;
95
+ }
96
+ let global = false;
97
+ const patterns = [];
98
+ for (const arg of rest) {
99
+ if (arg === "--global")
100
+ global = true;
101
+ else if (arg.startsWith("-"))
102
+ fail(`unknown option "${arg}" for install-git-driver`);
103
+ else
104
+ patterns.push(arg);
105
+ }
106
+ let res;
107
+ try {
108
+ res = installGitDriver({ global, patterns });
109
+ }
110
+ catch (e) {
111
+ fail(`could not configure git: ${e.message}`);
112
+ }
113
+ const scope = res.scope === "global" ? "globally (all repos)" : "for this repo";
114
+ process.stdout.write(pc.green("✓ ") + `configured ${pc.bold("git")} diff driver ${scope}\n`);
115
+ process.stdout.write(` diff.confdiff.command = ${pc.dim(res.command)}\n`);
116
+ if (res.added.length) {
117
+ process.stdout.write(` added to ${pc.bold(res.attributesFile)}:\n` +
118
+ res.added.map((p) => ` ${pc.cyan(p)} diff=confdiff`).join("\n") +
119
+ "\n");
120
+ }
121
+ if (res.alreadyPresent.length) {
122
+ process.stdout.write(pc.dim(` already present: ${res.alreadyPresent.join(", ")}\n`));
123
+ }
124
+ process.stdout.write(`\nDone. ${pc.bold("git diff")} on those files now shows semantic changes.\n`);
125
+ }
126
+ function fail(msg) {
127
+ process.stderr.write(pc.red(`error: `) + msg + "\n");
128
+ process.stderr.write(`Run ${pc.bold("confdiff --help")} for usage.\n`);
129
+ process.exit(2);
130
+ }
131
+ function asFormat(v) {
132
+ if (FORMATS.includes(v))
133
+ return v;
134
+ fail(`unknown format "${v}". Valid: ${FORMATS.join(", ")}`);
135
+ }
136
+ function parseArgs(argv) {
137
+ const a = {
138
+ files: [],
139
+ ignore: [],
140
+ only: [],
141
+ arraySet: false,
142
+ arrayKey: [],
143
+ loose: false,
144
+ redact: false,
145
+ redactKeys: [],
146
+ redactEntropy: false,
147
+ json: false,
148
+ quiet: false,
149
+ exitZero: false,
150
+ gitDiffDriver: false,
151
+ help: false,
152
+ version: false,
153
+ };
154
+ for (let i = 0; i < argv.length; i++) {
155
+ const arg = argv[i];
156
+ const next = () => {
157
+ const v = argv[++i];
158
+ if (v === undefined)
159
+ fail(`option ${arg} requires a value`);
160
+ return v;
161
+ };
162
+ switch (arg) {
163
+ case "-h":
164
+ case "--help":
165
+ a.help = true;
166
+ break;
167
+ case "-v":
168
+ case "--version":
169
+ a.version = true;
170
+ break;
171
+ case "-f":
172
+ case "--format":
173
+ a.format = asFormat(next());
174
+ break;
175
+ case "--format-a":
176
+ a.formatA = asFormat(next());
177
+ break;
178
+ case "--format-b":
179
+ a.formatB = asFormat(next());
180
+ break;
181
+ case "-i":
182
+ case "--ignore":
183
+ a.ignore.push(...next().split(",").map((s) => s.trim()).filter(Boolean));
184
+ break;
185
+ case "-o":
186
+ case "--only":
187
+ a.only.push(...next().split(",").map((s) => s.trim()).filter(Boolean));
188
+ break;
189
+ case "-l":
190
+ case "--loose":
191
+ a.loose = true;
192
+ break;
193
+ case "--csv-key":
194
+ a.csvKey = next();
195
+ break;
196
+ case "--redact":
197
+ a.redact = true;
198
+ break;
199
+ case "--redact-key":
200
+ a.redactKeys.push(...next().split(",").map((s) => s.trim()).filter(Boolean));
201
+ a.redact = true;
202
+ break;
203
+ case "--redact-entropy":
204
+ a.redactEntropy = true;
205
+ a.redact = true;
206
+ break;
207
+ case "--array-set":
208
+ a.arraySet = true;
209
+ break;
210
+ case "--array-key":
211
+ a.arrayKey.push(...next().split(",").map((s) => s.trim()).filter(Boolean));
212
+ break;
213
+ case "--json":
214
+ a.json = true;
215
+ break;
216
+ case "-q":
217
+ case "--quiet":
218
+ a.quiet = true;
219
+ break;
220
+ case "--color":
221
+ a.color = true;
222
+ break;
223
+ case "--no-color":
224
+ a.color = false;
225
+ break;
226
+ case "--exit-zero":
227
+ a.exitZero = true;
228
+ break;
229
+ case "--git-diff-driver":
230
+ a.gitDiffDriver = true;
231
+ break;
232
+ default:
233
+ if (arg.startsWith("--") && arg.includes("=")) {
234
+ const eq = arg.indexOf("=");
235
+ argv.splice(i + 1, 0, arg.slice(eq + 1));
236
+ argv[i] = arg.slice(0, eq);
237
+ i--;
238
+ break;
239
+ }
240
+ if (arg.startsWith("-") && arg !== "-")
241
+ fail(`unknown option "${arg}"`);
242
+ a.files.push(arg);
243
+ }
244
+ }
245
+ return a;
246
+ }
247
+ function readInput(file) {
248
+ if (file === "-")
249
+ return readFileSync(0, "utf8");
250
+ try {
251
+ return readFileSync(file, "utf8");
252
+ }
253
+ catch (e) {
254
+ fail(`cannot read "${file}": ${e.message}`);
255
+ }
256
+ }
257
+ function renderDirText(result, color, redact) {
258
+ const c = color ? pc : undefined;
259
+ const b = (s) => (c ? c.bold(s) : s);
260
+ const dim = (s) => (c ? c.dim(s) : s);
261
+ if (result.files.length === 0) {
262
+ return dim("no semantic changes across the two directories") + "\n";
263
+ }
264
+ let out = "";
265
+ let added = 0, removed = 0, changed = 0, skipped = 0;
266
+ for (const f of result.files) {
267
+ if (f.status === "added") {
268
+ added++;
269
+ const mark = c ? c.green("+ ") : "+ ";
270
+ out += `${mark}${b(f.path)} ${dim("(new file)")}\n`;
271
+ }
272
+ else if (f.status === "removed") {
273
+ removed++;
274
+ const mark = c ? c.red("- ") : "- ";
275
+ out += `${mark}${b(f.path)} ${dim("(deleted)")}\n`;
276
+ }
277
+ else if (f.status === "error") {
278
+ skipped++;
279
+ const mark = c ? c.yellow("! ") : "! ";
280
+ out += `${mark}${b(f.path)} ${dim(`(skipped: ${f.error})`)}\n`;
281
+ }
282
+ else {
283
+ changed++;
284
+ const mark = c ? c.yellow("~ ") : "~ ";
285
+ out += `${mark}${b(f.path)}\n`;
286
+ const body = renderText(f.changes ?? [], { color, redact });
287
+ out += body
288
+ .split("\n")
289
+ .map((l) => (l ? " " + l : l))
290
+ .join("\n");
291
+ out += "\n";
292
+ }
293
+ }
294
+ const parts = [];
295
+ if (changed)
296
+ parts.push(`${changed} changed`);
297
+ if (added)
298
+ parts.push(`${added} added`);
299
+ if (removed)
300
+ parts.push(`${removed} removed`);
301
+ if (skipped)
302
+ parts.push(`${skipped} skipped (parse error)`);
303
+ out += "\n" + b(`${result.files.length} file(s): ` + parts.join(", ")) + "\n";
304
+ return out;
305
+ }
306
+ function renderDirJson(result, redact) {
307
+ const files = result.files.map((f) => {
308
+ const base = { path: f.path, status: f.status };
309
+ if (f.status === "error")
310
+ base.error = f.error;
311
+ if (f.status === "changed" && f.changes) {
312
+ base.changes = JSON.parse(renderJson(f.changes, { redact }));
313
+ }
314
+ return base;
315
+ });
316
+ return JSON.stringify({ changed: result.changed, errored: result.errored, files }, null, 2);
317
+ }
318
+ export function main(argv = process.argv.slice(2)) {
319
+ if (argv[0] === "install-git-driver") {
320
+ runInstall(argv.slice(1));
321
+ return;
322
+ }
323
+ const args = parseArgs(argv);
324
+ if (args.version) {
325
+ process.stdout.write(getVersion() + "\n");
326
+ return;
327
+ }
328
+ if (args.help || (args.files.length === 0 && !args.gitDiffDriver)) {
329
+ process.stdout.write(HELP);
330
+ if (args.files.length === 0 && !args.help)
331
+ process.exit(2);
332
+ return;
333
+ }
334
+ // Git external diff driver calling convention: git invokes the command with
335
+ // 7 positional args — path old-file old-hex old-mode new-file new-hex new-mode.
336
+ // Map old-file/new-file to our two inputs and use `path` for format detection.
337
+ let driverPath;
338
+ if (args.gitDiffDriver) {
339
+ if (args.files.length !== 7) {
340
+ fail(`--git-diff-driver expects git's 7 diff arguments, got ${args.files.length}. ` +
341
+ `It is meant to be used via: git config diff.confdiff.command 'confdiff --git-diff-driver'`);
342
+ }
343
+ driverPath = args.files[0];
344
+ args.files = [args.files[1], args.files[4]];
345
+ // git aborts the whole diff if the driver exits non-zero; never do that.
346
+ args.exitZero = true;
347
+ }
348
+ if (args.files.length !== 2)
349
+ fail(`expected exactly 2 inputs, got ${args.files.length}`);
350
+ const [fileA, fileB] = args.files;
351
+ // Directory-vs-directory: recursively diff matching config files by relative path.
352
+ const aIsDir = isDirectory(fileA);
353
+ const bIsDir = isDirectory(fileB);
354
+ if (aIsDir || bIsDir) {
355
+ if (!(aIsDir && bIsDir)) {
356
+ fail(`both inputs must be directories to diff a tree (got ${aIsDir ? "a directory and a file" : "a file and a directory"})`);
357
+ }
358
+ const redactMatcherDir = args.redact
359
+ ? makeRedactMatcher(true, args.redactKeys, args.redactEntropy)
360
+ : undefined;
361
+ let result;
362
+ try {
363
+ result = dirDiff(fileA, fileB, {
364
+ ignore: args.ignore,
365
+ only: args.only,
366
+ arraySet: args.arraySet,
367
+ arrayKey: args.arrayKey,
368
+ loose: args.loose,
369
+ csvKey: args.csvKey,
370
+ });
371
+ }
372
+ catch (e) {
373
+ fail(e.message);
374
+ }
375
+ if (!args.quiet) {
376
+ if (args.json) {
377
+ process.stdout.write(renderDirJson(result, redactMatcherDir) + "\n");
378
+ }
379
+ else {
380
+ const color = args.color ?? (process.stdout.isTTY && !process.env.NO_COLOR);
381
+ process.stdout.write(renderDirText(result, !!color, redactMatcherDir));
382
+ }
383
+ }
384
+ // Exit-code precedence matches single-file mode: a parse/read error is a
385
+ // hard failure (2), differences are 1, a clean tree is 0. --exit-zero (and
386
+ // the git driver) still forces 0.
387
+ process.exit(args.exitZero ? 0 : result.errored ? 2 : result.changed ? 1 : 0);
388
+ }
389
+ const rawA = readInput(fileA);
390
+ const rawB = readInput(fileB);
391
+ const nameA = driverPath ?? (fileA === "-" ? undefined : basename(fileA));
392
+ const nameB = driverPath ?? (fileB === "-" ? undefined : basename(fileB));
393
+ const fmtA = args.formatA ?? args.format ?? detectFormat(nameA, rawA);
394
+ const fmtB = args.formatB ?? args.format ?? detectFormat(nameB, rawB);
395
+ let valA;
396
+ let valB;
397
+ try {
398
+ valA = parseContent(rawA, fmtA);
399
+ }
400
+ catch (e) {
401
+ fail(`failed to parse "${fileA}" as ${fmtA}: ${e.message}`);
402
+ }
403
+ try {
404
+ valB = parseContent(rawB, fmtB);
405
+ }
406
+ catch (e) {
407
+ fail(`failed to parse "${fileB}" as ${fmtB}: ${e.message}`);
408
+ }
409
+ if (args.csvKey) {
410
+ try {
411
+ if (fmtA === "csv")
412
+ valA = keyRowsByColumn(valA, args.csvKey);
413
+ if (fmtB === "csv")
414
+ valB = keyRowsByColumn(valB, args.csvKey);
415
+ }
416
+ catch (e) {
417
+ fail(e.message);
418
+ }
419
+ }
420
+ const changes = diff(valA, valB, {
421
+ ignore: args.ignore,
422
+ only: args.only,
423
+ arraySet: args.arraySet,
424
+ arrayKey: args.arrayKey,
425
+ loose: args.loose,
426
+ });
427
+ // Built-in secret heuristics are always active when redaction is on (adding
428
+ // --redact-key extends them). Erring toward over-redaction is safe; the failure
429
+ // mode to avoid is leaking a value the heuristics didn't catch.
430
+ const redactMatcher = args.redact ? makeRedactMatcher(true, args.redactKeys, args.redactEntropy) : undefined;
431
+ if (!args.quiet) {
432
+ if (args.json) {
433
+ process.stdout.write(renderJson(changes, { redact: redactMatcher }) + "\n");
434
+ }
435
+ else {
436
+ const color = args.color ?? (process.stdout.isTTY && !process.env.NO_COLOR);
437
+ if (driverPath) {
438
+ const header = `confdiff ${driverPath}`;
439
+ process.stdout.write((color ? pc.bold(header) : header) + "\n");
440
+ if (changes.length === 0) {
441
+ process.stdout.write((color ? pc.dim(" (no semantic changes)") : " (no semantic changes)") + "\n");
442
+ }
443
+ }
444
+ process.stdout.write(renderText(changes, { color: !!color, redact: redactMatcher }) + "\n");
445
+ }
446
+ }
447
+ process.exit(args.exitZero ? 0 : changes.length > 0 ? 1 : 0);
448
+ }
449
+ main();
package/dist/diff.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** A keyed-array selector segment, e.g. `containers[name=web]`. Produced when
2
+ * `--array-key` matches list-of-object elements by a field value instead of by
3
+ * positional index, so reordering a list doesn't create phantom diffs. */
4
+ export interface KeySeg {
5
+ key: string;
6
+ value: string | number | boolean | null;
7
+ }
8
+ export type PathSeg = string | number | KeySeg;
9
+ export type Path = PathSeg[];
10
+ export declare function isKeySeg(s: PathSeg): s is KeySeg;
11
+ /** Canonical string form of a single path segment (used for glob matching and
12
+ * pointers): keyed segments render as `key=value`, everything else as-is. */
13
+ export declare function segStr(s: PathSeg): string;
14
+ export type ChangeKind = "add" | "remove" | "change";
15
+ export interface Change {
16
+ path: Path;
17
+ kind: ChangeKind;
18
+ /** present for "remove" and "change" */
19
+ oldValue?: unknown;
20
+ /** present for "add" and "change" */
21
+ newValue?: unknown;
22
+ /** true when a "change" also changed the JSON type (e.g. number -> string) */
23
+ typeChanged?: boolean;
24
+ }
25
+ export interface DiffOptions {
26
+ /** Path glob patterns to ignore (dot notation, `*` = one segment, `**` = any depth). */
27
+ ignore?: string[];
28
+ /** If set, only paths matching one of these globs are compared. */
29
+ only?: string[];
30
+ /** Compare arrays as unordered multisets instead of by index. */
31
+ arraySet?: boolean;
32
+ /**
33
+ * Match arrays of objects by a key field's value instead of by position, so
34
+ * reordering a list (e.g. a k8s `env:` or `containers:` block) produces no
35
+ * noise and each element is diffed against its same-keyed counterpart.
36
+ * Each entry is a bare field name (`name`) applied wherever every element is
37
+ * an object carrying that field, or a scoped `pathGlob=field` mapping. The
38
+ * first applicable entry wins; if a field's values aren't unique on a side,
39
+ * that array falls back to indexed comparison.
40
+ */
41
+ arrayKey?: string[];
42
+ /**
43
+ * Loose scalar comparison: coerce string<->number<->boolean so that
44
+ * "3" == 3 and "true" == true. Handy for INI/.env where all values are strings.
45
+ */
46
+ loose?: boolean;
47
+ }
48
+ export declare function typeOf(v: unknown): string;
49
+ /**
50
+ * Public helper: does `path` match ANY of the given glob `patterns`? Uses the
51
+ * same matcher as --ignore/--only (dot + bracket notation, `*`/`**`/`?`, dotted
52
+ * keys). Also treats a bare key-name token (no separators) as matching that key
53
+ * at any depth, so `--redact password` masks `db.password` and `password`.
54
+ */
55
+ export declare function matchAnyGlob(path: Path, patterns: string[]): boolean;
56
+ export declare function diff(a: unknown, b: unknown, opts?: DiffOptions): Change[];
57
+ export declare function formatPath(path: Path): string;