@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.
Files changed (2) hide show
  1. package/index.js +286 -0
  2. 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
+ }