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/LICENSE +21 -0
- package/README.md +587 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +449 -0
- package/dist/diff.d.ts +57 -0
- package/dist/diff.js +430 -0
- package/dist/dirdiff.d.ts +28 -0
- package/dist/dirdiff.js +99 -0
- package/dist/gitdriver.d.ts +25 -0
- package/dist/gitdriver.js +84 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +19 -0
- package/dist/parse.d.ts +64 -0
- package/dist/parse.js +566 -0
- package/dist/redact.d.ts +39 -0
- package/dist/redact.js +172 -0
- package/dist/render.d.ts +13 -0
- package/dist/render.js +105 -0
- package/package.json +72 -0
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;
|