Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## What this project is

- `merge-jsonc`: CLI + library to deep-merge JSON/JSONC/JSON5 files (later inputs override earlier), safe file I/O, array merge strategies (`replace` default, `concat` optional), adds header comments on JSONC/JSON5 outputs.
- `merge-jsonc`: CLI + library to deep-merge JSON/JSONC/JSON5 files (later inputs override earlier), safe file I/O, array merge strategies (`replace` default, `concat` optional), adds header comments on JSONC/JSON5 outputs, auto-creates output directories, supports quiet mode (`-q`/`--quiet`) for scripting.
- ESM-only, Node 22+ target. Build artifacts are `.mjs` in `dist/`.

## Tech stack
Expand Down Expand Up @@ -34,8 +34,10 @@
- Preserve bin/exports in `package.json` pointing to `.mjs`.
- Avoid adding `prepublishOnly` steps beyond build (keeps semantic-release happy).
- Tests and CLI rely on `dist/cli.mjs`; ensure builds before running integration tests.
- Header comments added for JSONC/JSON5 outputs—tests strip them; dont remove unless intentional.
- Header comments added for JSONC/JSON5 outputs—tests strip them; don't remove unless intentional.
- Default array merge is `replace`; `--array-merge concat` supported; keep behavior documented.
- Output directories are auto-created with `mkdir -p` semantics; no need to pre-create paths.
- `--quiet` flag suppresses non-error output for script-friendly operation; errors still shown for debugging.
- Maintain ASCII in files unless existing content requires otherwise.

## When changing deps
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ It's designed for config layering (base → local → environment), with:
- 🧠 **Incremental builds** — skips work when inputs or content haven't changed
- 🔒 **Path-safe** — resists directory-traversal and symlink escapes (Snyk-friendly)
- 🧾 **Multi-format aware** — parses JSON, JSONC (comments), and JSON5 (extended syntax)
- 📁 **Auto-creates directories** — output paths with missing parent directories are created automatically
- 🪶 **ESM-only, Node 22+** — zero legacy baggage

---
Expand All @@ -34,6 +35,9 @@ merge-jsonc --out final.json base.json dev.jsonc local.json5

# With backup and preview
merge-jsonc --backup --dry-run --out config.json *.jsonc

# Quiet mode for scripts
merge-jsonc -q --out config.json base.json override.json
```

---
Expand All @@ -51,6 +55,7 @@ Options:
--min Minified output [boolean] [default: false]
--dry-run Preview without writing files [boolean] [default: false]
--backup Create .bak before overwriting [boolean] [default: false]
-q, --quiet Suppress non-error output [boolean] [default: false]
--indent Custom indentation spaces [number]
--array-merge Array merge strategy [choices: "replace", "concat"] [default: "replace"]
-h, --help Show help
Expand Down Expand Up @@ -133,6 +138,24 @@ export default {

---

## 🤫 Quiet Mode for Scripting

Use `--quiet` (or `-q`) to suppress all non-error output, perfect for CI/CD pipelines and automated workflows:

```bash
# Silent operation - only errors are shown
merge-jsonc -q --out config.json base.json override.json

# Use in scripts with exit code checking
if merge-jsonc -q --out config.json *.jsonc; then
echo "Config merged successfully"
fi
```

Errors are still displayed even with `--quiet`, ensuring debugging information is available when needed.

---

## 🧪 Examples

See the [`examples/`](./examples/) directory for real-world usage patterns:
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default [
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
},
},
prettier,
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@
"files": ["dist"],
"sideEffects": false,
"lint-staged": {
"**/*.{ts,tsx,js,jsx,mjs,cjs,cts,mts}": ["prettier --write", "eslint --fix --max-warnings=0"],
"src/**/*.{ts,tsx}": ["prettier --write", "eslint --fix --max-warnings=0"],
"test/**/*.{ts,tsx}": ["prettier --write", "eslint --fix --max-warnings=0"],
"*.config.{js,ts}": ["prettier --write"],
"*.{js,cjs}": ["prettier --write"],
"**/*.{json,jsonc,md,mdx,yml,yaml}": "prettier --write"
}
}
27 changes: 19 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,28 @@ interface ParsedArgs {
indent?: number;
arrayMerge?: "replace" | "concat";
header?: boolean;
quiet?: boolean;
inputs: string[];
}

function formatOutputMessage(
result: { wrote: boolean; reason: string; preview?: string; backup?: string },
out: string
out: string,
inputCount: number
): string {
if (result.reason === "dry_run" && result.preview) {
return `[merge-jsonc] DRY RUN - would write to ${out}:\n${result.preview}`;
}

const msgs: Record<string, string> = {
no_inputs: "[merge-jsonc] No existing inputs (skipping).",
up_to_date: "[merge-jsonc] Up-to-date (no source changes).",
no_content_change: "[merge-jsonc] Up-to-date (no content changes).",
wrote: `[merge-jsonc] Wrote ${out}`,
no_inputs: `[merge-jsonc] Skipped: no existing input files found (use --skip-missing to allow this).`,
up_to_date: `[merge-jsonc] Skipped: output is newer than all ${inputCount} input file(s).`,
no_content_change: `[merge-jsonc] Skipped: merged content identical to existing output.`,
wrote: `[merge-jsonc] Wrote ${out} (merged ${inputCount} file(s))`,
wrote_with_backup:
result.wrote && result.backup
? `[merge-jsonc] Wrote ${out} (backup: ${result.backup})`
: `[merge-jsonc] Wrote ${out}`,
? `[merge-jsonc] Wrote ${out} (merged ${inputCount} file(s), backup: ${result.backup})`
: `[merge-jsonc] Wrote ${out} (merged ${inputCount} file(s))`,
};

return msgs[result.reason] || `[merge-jsonc] Unknown result: ${result.reason}`;
Expand Down Expand Up @@ -109,6 +111,12 @@ async function run() {
type: "boolean",
default: false,
})
.option("quiet", {
alias: "q",
describe: "Suppress non-error output (useful for scripts)",
type: "boolean",
default: false,
})
.help("help")
.alias("help", "h")
.version(version)
Expand Down Expand Up @@ -160,14 +168,17 @@ async function run() {
// yargs presents boolean negations as the positive name being false
// yargs supports both --header and --no-header; prefer explicit argv.header when present
header: headerArg,
quiet: argv.quiet,
inputs: Array.from(argv._, (value) => String(value)),
};

const config = await loadConfig();
const mergeOptions = mergeArgsWithConfig(args, config);

const result = mergeJsonc(mergeOptions);
console.log(formatOutputMessage(result, mergeOptions.out));
if (!args.quiet) {
console.log(formatOutputMessage(result, mergeOptions.out, mergeOptions.inputs.length));
}
} catch (error) {
if (error instanceof Error) {
console.error("[merge-jsonc] Error:", error.message);
Expand Down
4 changes: 2 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export function mergeJsonc(opts: MergeOptions): MergeResult {
finalText = header + text;
}

if (!checkContentChanged(outAbs, text)) {
if (!checkContentChanged(outAbs, finalText)) {
return { wrote: false, reason: "no_content_change" };
}

Expand All @@ -161,7 +161,7 @@ export function mergeJsonc(opts: MergeOptions): MergeResult {
}

if (dryRun) {
return { wrote: false, reason: "dry_run", preview: text };
return { wrote: false, reason: "dry_run", preview: finalText };
}

const backupPath = atomicWrite(outAbs, finalText, backup);
Expand Down
7 changes: 7 additions & 0 deletions src/fs-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
readFileSync,
writeFileSync,
renameSync,
mkdirSync,
} from "node:fs";
import { resolve, relative, extname, dirname, sep, posix } from "node:path";

Expand Down Expand Up @@ -74,6 +75,12 @@ export function atomicWrite(
writeFileSync(backupPath, backupContent);
}

// Ensure parent directory exists
const dir = dirname(outAbs);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}

const tmp = `${outAbs}.tmp`;
writeFileSync(tmp, text);
renameSync(tmp, outAbs);
Expand Down
72 changes: 68 additions & 4 deletions test/cli-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe("CLI module", () => {
expect(exitSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(
"[merge-jsonc] Wrote combined.jsonc (backup: target.json.bak)"
"[merge-jsonc] Wrote combined.jsonc (merged 1 file(s), backup: target.json.bak)"
);
});

Expand All @@ -119,7 +119,7 @@ describe("CLI module", () => {

expect(exitSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith("[merge-jsonc] Wrote combined.jsonc");
expect(logSpy).toHaveBeenCalledWith("[merge-jsonc] Wrote combined.jsonc (merged 1 file(s))");
});

test("logs no-input message when merge skips all inputs", async () => {
Expand All @@ -129,7 +129,9 @@ describe("CLI module", () => {

await import("../src/cli.js");
await vi.waitFor(() => {
expect(logSpy).toHaveBeenCalledWith("[merge-jsonc] No existing inputs (skipping).");
expect(logSpy).toHaveBeenCalledWith(
"[merge-jsonc] Skipped: no existing input files found (use --skip-missing to allow this)."
);
});
});

Expand All @@ -140,7 +142,9 @@ describe("CLI module", () => {

await import("../src/cli.js");
await vi.waitFor(() => {
expect(logSpy).toHaveBeenCalledWith("[merge-jsonc] Up-to-date (no content changes).");
expect(logSpy).toHaveBeenCalledWith(
"[merge-jsonc] Skipped: merged content identical to existing output."
);
});
});

Expand Down Expand Up @@ -187,4 +191,64 @@ describe("CLI module", () => {

expect(errorSpy).toHaveBeenCalled();
});

test("suppresses output when --quiet flag is used", async () => {
process.argv = ["node", "merge-jsonc", "--quiet", "input.json"];

mergeJsoncMock.mockReturnValueOnce({ wrote: true, reason: "wrote", out: "output.json" });

const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);

await import("../src/cli.js");
await vi.waitFor(() => {
expect(mergeJsoncMock).toHaveBeenCalled();
});

expect(exitSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});

test("suppresses output with -q alias", async () => {
process.argv = ["node", "merge-jsonc", "-q", "input.json"];

mergeJsoncMock.mockReturnValueOnce({
wrote: false,
reason: "no_content_change",
});

const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);

await import("../src/cli.js");
await vi.waitFor(() => {
expect(mergeJsoncMock).toHaveBeenCalled();
});

expect(exitSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});

test("still shows errors when --quiet is used", async () => {
process.argv = ["node", "merge-jsonc", "--quiet", "input.json"];

mergeJsoncMock.mockImplementationOnce(() => {
throw new Error("test error");
});

const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);

await import("../src/cli.js");
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled();
});

expect(errorSpy).toHaveBeenCalledWith("[merge-jsonc] Error:", "test error");
expect(logSpy).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
2 changes: 1 addition & 1 deletion test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ describe("CLI integration tests", () => {
const result = await runCli(["--out", "output.jsonc", "input.jsonc"]);

expect(result.code).toBe(0);
expect(result.stdout).toContain("Up-to-date");
expect(result.stdout).toContain("Skipped");
});

test("should use default output filename when --out not specified", async () => {
Expand Down
Loading