@chengyixu/json-diff-cli 1.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/index.js +286 -0
- package/package.json +22 -0
package/index.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// json-diff-cli — zero-dependency JSON diff tool
|
|
5
|
+
// Usage: json-diff-cli file1.json file2.json [--keys-only] [--no-color]
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const VERSION = '1.0.0';
|
|
11
|
+
|
|
12
|
+
const RESET = '\x1b[0m';
|
|
13
|
+
const RED = '\x1b[31m';
|
|
14
|
+
const GREEN = '\x1b[32m';
|
|
15
|
+
const YELLOW = '\x1b[33m';
|
|
16
|
+
const CYAN = '\x1b[36m';
|
|
17
|
+
const BLUE = '\x1b[34m';
|
|
18
|
+
const BOLD = '\x1b[1m';
|
|
19
|
+
const DIM = '\x1b[2m';
|
|
20
|
+
|
|
21
|
+
let useColor = true;
|
|
22
|
+
|
|
23
|
+
function c(code, str) {
|
|
24
|
+
return useColor ? `${code}${str}${RESET}` : str;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printHelp() {
|
|
28
|
+
console.log(`
|
|
29
|
+
${c(BOLD, 'json-diff-cli')} v${VERSION} — Diff two JSON files
|
|
30
|
+
|
|
31
|
+
${c(CYAN, 'Usage:')}
|
|
32
|
+
json-diff-cli <file1.json> <file2.json> [options]
|
|
33
|
+
|
|
34
|
+
${c(CYAN, 'Options:')}
|
|
35
|
+
--keys-only Only show changed keys, not values
|
|
36
|
+
--no-color Disable colored output
|
|
37
|
+
--json Output result as JSON
|
|
38
|
+
--quiet Exit with code 0 even if differences found
|
|
39
|
+
--version, -v Show version
|
|
40
|
+
--help, -h Show this help
|
|
41
|
+
|
|
42
|
+
${c(CYAN, 'Exit codes:')}
|
|
43
|
+
0 — No differences
|
|
44
|
+
1 — Differences found
|
|
45
|
+
2 — Error (bad args, parse failure)
|
|
46
|
+
|
|
47
|
+
${c(CYAN, 'Examples:')}
|
|
48
|
+
json-diff-cli package.json package-lock.json
|
|
49
|
+
json-diff-cli a.json b.json --keys-only
|
|
50
|
+
json-diff-cli old.json new.json --json | jq .
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Deep diff ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns a structured diff:
|
|
58
|
+
* { added: {path: value}, removed: {path: value}, changed: {path: {from, to}}, same: {path: value} }
|
|
59
|
+
*/
|
|
60
|
+
function deepDiff(a, b, prefix = '') {
|
|
61
|
+
const added = {};
|
|
62
|
+
const removed = {};
|
|
63
|
+
const changed = {};
|
|
64
|
+
const same = {};
|
|
65
|
+
|
|
66
|
+
function keyPath(base, key) {
|
|
67
|
+
if (base === '') return String(key);
|
|
68
|
+
if (typeof key === 'number') return `${base}[${key}]`;
|
|
69
|
+
return `${base}.${key}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compare(av, bv, path) {
|
|
73
|
+
const ta = typeOf(av);
|
|
74
|
+
const tb = typeOf(bv);
|
|
75
|
+
|
|
76
|
+
// Both objects
|
|
77
|
+
if (ta === 'object' && tb === 'object') {
|
|
78
|
+
const allKeys = new Set([...Object.keys(av), ...Object.keys(bv)]);
|
|
79
|
+
for (const k of allKeys) {
|
|
80
|
+
const kp = keyPath(path, k);
|
|
81
|
+
if (!(k in av)) {
|
|
82
|
+
flatCollect(bv[k], kp, added);
|
|
83
|
+
} else if (!(k in bv)) {
|
|
84
|
+
flatCollect(av[k], kp, removed);
|
|
85
|
+
} else {
|
|
86
|
+
compare(av[k], bv[k], kp);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Both arrays
|
|
93
|
+
if (ta === 'array' && tb === 'array') {
|
|
94
|
+
const len = Math.max(av.length, bv.length);
|
|
95
|
+
for (let i = 0; i < len; i++) {
|
|
96
|
+
const kp = keyPath(path, i);
|
|
97
|
+
if (i >= av.length) {
|
|
98
|
+
flatCollect(bv[i], kp, added);
|
|
99
|
+
} else if (i >= bv.length) {
|
|
100
|
+
flatCollect(av[i], kp, removed);
|
|
101
|
+
} else {
|
|
102
|
+
compare(av[i], bv[i], kp);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Type changed or value changed
|
|
109
|
+
if (ta !== tb || !deepEqual(av, bv)) {
|
|
110
|
+
if (ta !== tb) {
|
|
111
|
+
changed[path] = { from: av, to: bv, typeChange: true };
|
|
112
|
+
} else {
|
|
113
|
+
changed[path] = { from: av, to: bv };
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
same[path] = av;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function flatCollect(val, path, target) {
|
|
121
|
+
const t = typeOf(val);
|
|
122
|
+
if (t === 'object') {
|
|
123
|
+
for (const k of Object.keys(val)) {
|
|
124
|
+
flatCollect(val[k], keyPath(path, k), target);
|
|
125
|
+
}
|
|
126
|
+
} else if (t === 'array') {
|
|
127
|
+
for (let i = 0; i < val.length; i++) {
|
|
128
|
+
flatCollect(val[i], keyPath(path, i), target);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
target[path] = val;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function typeOf(v) {
|
|
136
|
+
if (v === null) return 'null';
|
|
137
|
+
if (Array.isArray(v)) return 'array';
|
|
138
|
+
return typeof v;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function deepEqual(x, y) {
|
|
142
|
+
return JSON.stringify(x) === JSON.stringify(y);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
compare(a, b, prefix);
|
|
146
|
+
return { added, removed, changed, same };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Formatters ─────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function fmtVal(v, maxLen = 80) {
|
|
152
|
+
if (v === null) return c(DIM, 'null');
|
|
153
|
+
if (typeof v === 'boolean') return c(BLUE, String(v));
|
|
154
|
+
if (typeof v === 'number') return c(BLUE, String(v));
|
|
155
|
+
if (typeof v === 'string') {
|
|
156
|
+
const s = JSON.stringify(v);
|
|
157
|
+
return s.length > maxLen ? s.slice(0, maxLen) + '…"' : s;
|
|
158
|
+
}
|
|
159
|
+
const s = JSON.stringify(v);
|
|
160
|
+
return s.length > maxLen ? s.slice(0, maxLen) + '…' : s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function printDiff(diff, keysOnly) {
|
|
164
|
+
const sections = [
|
|
165
|
+
{ label: 'ADDED', color: GREEN, items: diff.added, symbol: '+' },
|
|
166
|
+
{ label: 'REMOVED', color: RED, items: diff.removed, symbol: '-' },
|
|
167
|
+
{ label: 'CHANGED', color: YELLOW, items: diff.changed, symbol: '~' },
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
let totalChanges = 0;
|
|
171
|
+
|
|
172
|
+
for (const sec of sections) {
|
|
173
|
+
const keys = Object.keys(sec.items);
|
|
174
|
+
if (keys.length === 0) continue;
|
|
175
|
+
totalChanges += keys.length;
|
|
176
|
+
|
|
177
|
+
console.log(`\n${c(BOLD + sec.color, sec.label + ` (${keys.length})`)}:`);
|
|
178
|
+
for (const key of keys.sort()) {
|
|
179
|
+
const val = sec.items[key];
|
|
180
|
+
if (sec.label === 'CHANGED') {
|
|
181
|
+
const { from, to, typeChange } = val;
|
|
182
|
+
if (keysOnly) {
|
|
183
|
+
console.log(` ${c(sec.color, sec.symbol)} ${key}`);
|
|
184
|
+
} else {
|
|
185
|
+
const typeNote = typeChange
|
|
186
|
+
? c(DIM, ` [${typeof from} → ${typeof to}]`)
|
|
187
|
+
: '';
|
|
188
|
+
console.log(` ${c(YELLOW, '~')} ${c(BOLD, key)}${typeNote}`);
|
|
189
|
+
console.log(` ${c(RED, '- ')}${fmtVal(from)}`);
|
|
190
|
+
console.log(` ${c(GREEN, '+ ')}${fmtVal(to)}`);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
if (keysOnly) {
|
|
194
|
+
console.log(` ${c(sec.color, sec.symbol)} ${key}`);
|
|
195
|
+
} else {
|
|
196
|
+
console.log(` ${c(sec.color, sec.symbol)} ${c(BOLD, key)}: ${fmtVal(val)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return totalChanges;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
const args = process.argv.slice(2);
|
|
208
|
+
|
|
209
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
210
|
+
printHelp();
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
214
|
+
console.log(`json-diff-cli v${VERSION}`);
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const keysOnly = args.includes('--keys-only');
|
|
219
|
+
const jsonOut = args.includes('--json');
|
|
220
|
+
const quiet = args.includes('--quiet');
|
|
221
|
+
useColor = !args.includes('--no-color') && !jsonOut && process.stdout.isTTY !== false;
|
|
222
|
+
|
|
223
|
+
const files = args.filter(a => !a.startsWith('--'));
|
|
224
|
+
|
|
225
|
+
if (files.length < 2) {
|
|
226
|
+
console.error(c(RED, 'Error: Two JSON files required.'));
|
|
227
|
+
printHelp();
|
|
228
|
+
process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const [file1, file2] = files;
|
|
232
|
+
|
|
233
|
+
let a, b;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
a = JSON.parse(fs.readFileSync(file1, 'utf8'));
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.error(c(RED, `Error: Cannot parse ${file1}: ${e.message}`));
|
|
239
|
+
process.exit(2);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
b = JSON.parse(fs.readFileSync(file2, 'utf8'));
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.error(c(RED, `Error: Cannot parse ${file2}: ${e.message}`));
|
|
246
|
+
process.exit(2);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const diff = deepDiff(a, b);
|
|
250
|
+
const totalAdded = Object.keys(diff.added).length;
|
|
251
|
+
const totalRemoved = Object.keys(diff.removed).length;
|
|
252
|
+
const totalChanged = Object.keys(diff.changed).length;
|
|
253
|
+
const totalSame = Object.keys(diff.same).length;
|
|
254
|
+
const hasChanges = totalAdded + totalRemoved + totalChanged > 0;
|
|
255
|
+
|
|
256
|
+
if (jsonOut) {
|
|
257
|
+
console.log(JSON.stringify({
|
|
258
|
+
added: diff.added,
|
|
259
|
+
removed: diff.removed,
|
|
260
|
+
changed: diff.changed,
|
|
261
|
+
summary: { added: totalAdded, removed: totalRemoved, changed: totalChanged, same: totalSame },
|
|
262
|
+
}, null, 2));
|
|
263
|
+
process.exit(hasChanges && !quiet ? 1 : 0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Header
|
|
267
|
+
console.log(`\n${c(BOLD, 'json-diff-cli')} — comparing:`);
|
|
268
|
+
console.log(` ${c(RED, 'A:')} ${file1}`);
|
|
269
|
+
console.log(` ${c(GREEN, 'B:')} ${file2}`);
|
|
270
|
+
|
|
271
|
+
if (!hasChanges) {
|
|
272
|
+
console.log(`\n${c(GREEN + BOLD, '✔ No differences found')} ${c(DIM, `(${totalSame} matching keys)`)}`);
|
|
273
|
+
process.exit(0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
printDiff(diff, keysOnly);
|
|
277
|
+
|
|
278
|
+
// Summary
|
|
279
|
+
const parts = [];
|
|
280
|
+
if (totalAdded) parts.push(c(GREEN, `+${totalAdded} added`));
|
|
281
|
+
if (totalRemoved) parts.push(c(RED, `-${totalRemoved} removed`));
|
|
282
|
+
if (totalChanged) parts.push(c(YELLOW, `~${totalChanged} changed`));
|
|
283
|
+
if (totalSame) parts.push(c(DIM, `=${totalSame} same`));
|
|
284
|
+
|
|
285
|
+
console.log(`\n${c(BOLD, 'Summary:')} ${parts.join(' ')}`);
|
|
286
|
+
process.exit(quiet ? 0 : 1);
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chengyixu/json-diff-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Show a clear diff between two JSON files — added, removed, and changed keys highlighted. Zero dependencies.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"json-diff-cli": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node index.js --help"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["json", "diff", "compare", "cli", "devtools"],
|
|
13
|
+
"author": "chengyixu",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/chengyixu/json-diff-cli"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=14"
|
|
21
|
+
}
|
|
22
|
+
}
|