From 512c40a01c59a6d53f722ab5614de68da16caaa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 13:32:01 +0100 Subject: [PATCH 01/13] feat: add paths output and conf-backed config --- README.md | 17 ++- package.json | 1 + pnpm-lock.yaml | 115 ++++++++++++++++++ src/cli/main.test.ts | 63 +++++++--- src/cli/main.ts | 113 +++++++++++++++--- src/cli/register-config-path-command.ts | 25 ---- src/cli/register-init-command.ts | 27 ----- src/cli/register-sync-command.ts | 34 ------ src/config/config.test.ts | 117 ++++++++++++++----- src/config/constants.ts | 24 +++- src/config/loader.createSampleConfig.test.ts | 83 +++++++++---- src/config/loader.ts | 69 ++++++----- src/utils/errors.test.ts | 4 +- src/utils/errors.ts | 4 +- src/utils/paths.ts | 18 ++- 15 files changed, 498 insertions(+), 216 deletions(-) delete mode 100644 src/cli/register-config-path-command.ts delete mode 100644 src/cli/register-init-command.ts delete mode 100644 src/cli/register-sync-command.ts diff --git a/README.md b/README.md index 064a492..2bd7916 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Or run without installing globally using npx: ```bash npx sync-rules --help # e.g. -npx sync-rules init +npx sync-rules --init ``` ## Usage @@ -42,10 +42,16 @@ The workflow involves initializing a configuration file, defining your projects First, initialize the configuration file: ```bash -sync-rules init +sync-rules --init ``` -This creates a sample `config.json`. By default, it is stored in your system's application data directory. You can specify a custom path using the `--config ` flag or the `SYNC_RULES_CONFIG` environment variable. +This creates a sample `config.json`. By default, it is stored in your system's application data directory. You can specify a custom path using the `--config ` flag or the `SYNC_RULES_CONFIG` environment variable. Use `--force` to overwrite an existing config file. + +To show the resolved config and rules source paths: + +```bash +sync-rules --paths +``` ### 2\. Configure Projects and Rules @@ -80,14 +86,15 @@ To synchronize the rules for all configured projects, run the default command: ```bash sync-rules -# or -sync-rules sync ``` This reads the rules and writes `AGENTS.md` in each project. It also writes `CLAUDE.md` containing `@AGENTS.md` for Claude Code. #### Options +- `--init`: Create a sample config file +- `--force` / `-f`: Overwrite existing config file (with `--init`) +- `--paths`: Print resolved config and rules source paths - `--verbose` / `-v`: Show status messages (silent by default) - `--dry-run` / `-n`: Preview changes without writing files - `--porcelain`: Machine-readable TSV output (implies `--dry-run`) diff --git a/package.json b/package.json index 1401b32..bef76df 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "dependencies": { "@commander-js/extra-typings": "^14.0.0", "commander": "^14.0.2", + "conf": "^15.0.2", "env-paths": "^3.0.0", "globby": "^16.1.0", "zod": "^4.3.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14f9324..3a1333f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: commander: specifier: ^14.0.2 version: 14.0.2 + conf: + specifier: ^15.0.2 + version: 15.0.2 env-paths: specifier: ^3.0.0 version: 3.0.0 @@ -849,9 +852,20 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@7.2.0: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} @@ -895,6 +909,9 @@ packages: ast-v8-to-istanbul@0.3.10: resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==} + atomically@2.1.0: + resolution: {integrity: sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1008,6 +1025,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@15.0.2: + resolution: {integrity: sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw==} + engines: {node: '>=20'} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -1056,6 +1077,10 @@ packages: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} + debounce-fn@6.0.0: + resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1076,6 +1101,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -1229,6 +1258,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1564,6 +1596,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1677,6 +1715,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2016,6 +2058,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2171,6 +2217,12 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + super-regex@1.1.0: resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} engines: {node: '>=18'} @@ -2286,6 +2338,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2424,6 +2480,9 @@ packages: web-worker@1.2.0: resolution: {integrity: sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==} + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3203,6 +3262,10 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3210,6 +3273,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.2.0: dependencies: environment: 1.1.0 @@ -3244,6 +3314,11 @@ snapshots: estree-walker: 3.0.3 js-tokens: 9.0.1 + atomically@2.1.0: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + balanced-match@1.0.2: {} baseline-browser-mapping@2.9.14: {} @@ -3356,6 +3431,18 @@ snapshots: concat-map@0.0.1: {} + conf@15.0.2: + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + atomically: 2.1.0 + debounce-fn: 6.0.0 + dot-prop: 10.1.0 + env-paths: 3.0.0 + json-schema-typed: 8.0.2 + semver: 7.7.3 + uint8array-extras: 1.5.0 + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -3405,6 +3492,10 @@ snapshots: dependencies: type-fest: 1.4.0 + debounce-fn@6.0.0: + dependencies: + mimic-function: 5.0.1 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3417,6 +3508,10 @@ snapshots: dependencies: path-type: 4.0.0 + dot-prop@10.1.0: + dependencies: + type-fest: 5.4.1 + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -3645,6 +3740,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3928,6 +4025,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} jsonfile@6.2.0: @@ -4046,6 +4147,8 @@ snapshots: mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -4320,6 +4423,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -4501,6 +4606,12 @@ snapshots: strip-json-comments@5.0.3: {} + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + super-regex@1.1.0: dependencies: function-timeout: 1.0.2 @@ -4604,6 +4715,8 @@ snapshots: uglify-js@3.19.3: optional: true + uint8array-extras@1.5.0: {} + undici-types@7.16.0: {} undici@5.29.0: @@ -4701,6 +4814,8 @@ snapshots: web-worker@1.2.0: {} + when-exit@2.1.5: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/src/cli/main.test.ts b/src/cli/main.test.ts index 42c677d..6f169e5 100644 --- a/src/cli/main.test.ts +++ b/src/cli/main.test.ts @@ -2,7 +2,11 @@ import { homedir } from "node:os"; import path from "node:path"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { main } from "./main.js"; -import { DEFAULT_CONFIG_PATH } from "../config/constants.js"; +import { + DEFAULT_CONFIG_PATH, + DEFAULT_RULES_SOURCE, +} from "../config/constants.js"; +import { ConfigNotFoundError } from "../utils/errors.js"; // Mock the entire modules with dynamic imports support vi.mock("../config/loader.js", () => ({ @@ -29,11 +33,11 @@ describe("cli/main", () => { vi.clearAllMocks(); }); - describe("init command", () => { + describe("--init", () => { it("creates sample config at default path", async () => { vi.mocked(loader.createSampleConfig).mockResolvedValue(); - const code = await main(["node", "sync-rules", "init"]); + const code = await main(["node", "sync-rules", "--init"]); expect(code).toBe(0); expect(loader.createSampleConfig).toHaveBeenCalledWith( DEFAULT_CONFIG_PATH, @@ -44,7 +48,7 @@ describe("cli/main", () => { it("honors --force flag", async () => { vi.mocked(loader.createSampleConfig).mockResolvedValue(); - const code = await main(["node", "sync-rules", "init", "--force"]); + const code = await main(["node", "sync-rules", "--init", "--force"]); expect(code).toBe(0); expect(loader.createSampleConfig).toHaveBeenCalledWith( DEFAULT_CONFIG_PATH, @@ -57,52 +61,85 @@ describe("cli/main", () => { new Error("Write failed"), ); - const code = await main(["node", "sync-rules", "init"]); + const code = await main(["node", "sync-rules", "--init"]); expect(code).toBe(1); }); }); - describe("config-path command", () => { - it("prints the default config path", async () => { + describe("--paths", () => { + it("prints the resolved config and rules source paths", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(loader.loadConfig).mockResolvedValue({ + rulesSource: "/rules", + projects: [], + }); + + const code = await main(["node", "sync-rules", "--paths"]); + + expect(code).toBe(0); + expect(logSpy).toHaveBeenCalledWith("NAME\tPATH"); + expect(logSpy).toHaveBeenCalledWith(`CONFIG\t${DEFAULT_CONFIG_PATH}`); + expect(logSpy).toHaveBeenCalledWith("RULES_SOURCE\t/rules"); + logSpy.mockRestore(); + }); + + it("prints defaults when config is missing", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(loader.loadConfig).mockRejectedValue( + new ConfigNotFoundError(DEFAULT_CONFIG_PATH, true), + ); - const code = await main(["node", "sync-rules", "config-path"]); + const code = await main(["node", "sync-rules", "--paths"]); expect(code).toBe(0); - expect(logSpy).toHaveBeenCalledWith(DEFAULT_CONFIG_PATH); + expect(logSpy).toHaveBeenCalledWith("NAME\tPATH"); + expect(logSpy).toHaveBeenCalledWith(`CONFIG\t${DEFAULT_CONFIG_PATH}`); + expect(logSpy).toHaveBeenCalledWith( + `RULES_SOURCE\t${DEFAULT_RULES_SOURCE}`, + ); logSpy.mockRestore(); }); it("prints custom config path when --config is provided", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(loader.loadConfig).mockResolvedValue({ + rulesSource: "/rules", + projects: [], + }); const code = await main([ "node", "sync-rules", "--config", "./custom.json", - "config-path", + "--paths", ]); expect(code).toBe(0); - expect(logSpy).toHaveBeenCalledWith(path.resolve("./custom.json")); + expect(logSpy).toHaveBeenCalledWith( + `CONFIG\t${path.resolve("./custom.json")}`, + ); logSpy.mockRestore(); }); it("expands tilde in config path", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(loader.loadConfig).mockResolvedValue({ + rulesSource: "/rules", + projects: [], + }); const code = await main([ "node", "sync-rules", "--config", "~/.config/sync-rules.json", - "config-path", + "--paths", ]); expect(code).toBe(0); expect(logSpy).toHaveBeenCalledWith( - path.resolve(homedir(), ".config/sync-rules.json"), + `CONFIG\t${path.resolve(homedir(), ".config/sync-rules.json")}`, ); logSpy.mockRestore(); }); diff --git a/src/cli/main.ts b/src/cli/main.ts index 37bfc71..11317bb 100755 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,10 +1,53 @@ import { Command, CommanderError } from "@commander-js/extra-typings"; import packageJson from "../../package.json" with { type: "json" }; -import { DEFAULT_CONFIG_PATH } from "../config/constants.js"; -import { ensureError } from "../utils/errors.js"; -import { registerConfigPathCommand } from "./register-config-path-command.js"; -import { registerInitCommand } from "./register-init-command.js"; -import { registerSyncCommand } from "./register-sync-command.js"; +import { + DEFAULT_CONFIG_PATH, + DEFAULT_RULES_SOURCE, +} from "../config/constants.js"; +import { createSampleConfig, loadConfig } from "../config/loader.js"; +import { ConfigNotFoundError, ensureError } from "../utils/errors.js"; +import { normalizePath } from "../utils/paths.js"; +import { runSyncCommand } from "./run-sync-command.js"; + +type ResolvedPaths = { + configPath: string; + rulesSource: string; + error?: Error; +}; + +type CliOptions = { + config: string; + verbose?: boolean; + dryRun?: boolean; + porcelain?: boolean; + init?: boolean; + force?: boolean; + paths?: boolean; +}; + +async function resolvePaths(configPath: string): Promise { + const normalizedPath = normalizePath(configPath); + try { + const config = await loadConfig(configPath); + return { configPath: normalizedPath, rulesSource: config.rulesSource }; + } catch (error) { + const error_ = ensureError(error); + if (error_ instanceof ConfigNotFoundError) { + return { configPath: normalizedPath, rulesSource: DEFAULT_RULES_SOURCE }; + } + return { + configPath: normalizedPath, + rulesSource: DEFAULT_RULES_SOURCE, + error: error_, + }; + } +} + +function printPaths(paths: ResolvedPaths): void { + console.log("NAME\tPATH"); + console.log(`CONFIG\t${paths.configPath}`); + console.log(`RULES_SOURCE\t${paths.rulesSource}`); +} /** * Entry point for the CLI application. @@ -13,40 +56,80 @@ import { registerSyncCommand } from "./register-sync-command.js"; * @param argv - The raw argv array (typically `process.argv`) */ export async function main(argv: string[]): Promise { - const program = new Command() + const program = new Command<[], CliOptions>() .name(packageJson.name) .description(packageJson.description) .version(packageJson.version) .helpCommand(false) - .enablePositionalOptions() + .allowExcessArguments(false) .option( "-c, --config ", "path to configuration file", DEFAULT_CONFIG_PATH, ) + .option("--init", "create a sample configuration file") + .option("-f, --force", "overwrite existing config file (with --init)") + .option("--paths", "print resolved config and rules source paths") + .option("-n, --dry-run", "preview changes without writing files") + .option("--porcelain", "machine-readable output (implies --dry-run)") .option("-v, --verbose", "enable verbose output") .showHelpAfterError("(add --help for additional information)") .showSuggestionAfterError() .exitOverride() .configureHelp({ - sortSubcommands: true, sortOptions: true, - showGlobalOptions: true, }); program.addHelpText( "after", ` +Resolved defaults: + CONFIG: ${DEFAULT_CONFIG_PATH} + RULES_SOURCE: ${DEFAULT_RULES_SOURCE} + Examples: sync-rules # Sync all projects (default) - sync-rules init # Create a sample config file - sync-rules config-path # Print the config file path + sync-rules --init # Create a sample config file + sync-rules --paths # Print resolved config and rules paths sync-rules --porcelain | tail -n +2 | wc -l # Count files that would be written`, ); + program.action(async (options) => { + const configPath = normalizePath(options.config); + const wantsInit = options.init ?? false; + const wantsPaths = options.paths ?? false; + const wantsSyncFlags = + (options.dryRun ?? false) || (options.porcelain ?? false); + + if (options.force && !wantsInit) { + throw new Error("--force can only be used with --init"); + } + if (wantsInit && wantsPaths) { + throw new Error("Use only one of --init or --paths"); + } + if ((wantsInit || wantsPaths) && wantsSyncFlags) { + throw new Error("--dry-run and --porcelain apply only to sync"); + } - // Register subcommands - registerConfigPathCommand(program); - registerInitCommand(program); - registerSyncCommand(program); + if (wantsInit) { + await createSampleConfig(configPath, options.force ?? false); + return; + } + + if (wantsPaths) { + const resolved = await resolvePaths(configPath); + printPaths(resolved); + if (resolved.error) { + throw resolved.error; + } + return; + } + + await runSyncCommand({ + configPath, + verbose: options.verbose ?? false, + dryRun: options.dryRun ?? options.porcelain ?? false, + porcelain: options.porcelain ?? false, + }); + }); try { await program.parseAsync(argv); diff --git a/src/cli/register-config-path-command.ts b/src/cli/register-config-path-command.ts deleted file mode 100644 index 61d86b6..0000000 --- a/src/cli/register-config-path-command.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Command } from "@commander-js/extra-typings"; -import { normalizePath } from "../utils/paths.js"; - -type ParentCommand = Command<[], { config: string; verbose?: true }>; - -export function registerConfigPathCommand(program: ParentCommand): void { - program - .command("config-path") - .description("Print the resolved configuration file path") - .addHelpText( - "after", - ` -Prints the path to the configuration file that would be used. -Useful for scripting and debugging configuration issues. - -Examples: - sync-rules config-path # Print default config path - sync-rules --config ./my.json config-path # Print custom config path - cat "$(sync-rules config-path)" # View config contents`, - ) - .action(() => { - const parentOptions = program.opts(); - console.log(normalizePath(parentOptions.config)); - }); -} diff --git a/src/cli/register-init-command.ts b/src/cli/register-init-command.ts deleted file mode 100644 index 9a23286..0000000 --- a/src/cli/register-init-command.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Command } from "@commander-js/extra-typings"; -import { DEFAULT_CONFIG_PATH } from "../config/constants.js"; -import { createSampleConfig } from "../config/loader.js"; - -type ParentCommand = Command<[], { config: string; verbose?: true }>; - -export function registerInitCommand(program: ParentCommand): void { - program - .command("init") - .description("Initialize a new configuration file") - .option("-f, --force", "overwrite existing config file", false) - .addHelpText( - "after", - ` -This command creates a sample configuration file with example settings. - -Examples: - sync-rules init # Create config at default location - sync-rules init --config ./my.json # Create config at custom path - sync-rules init --force # Overwrite existing config`, - ) - .action(async (options) => { - const parentOptions = program.opts(); - const configPath = parentOptions.config || DEFAULT_CONFIG_PATH; - await createSampleConfig(configPath, options.force); - }); -} diff --git a/src/cli/register-sync-command.ts b/src/cli/register-sync-command.ts deleted file mode 100644 index d616c13..0000000 --- a/src/cli/register-sync-command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Command } from "@commander-js/extra-typings"; -import { runSyncCommand } from "./run-sync-command.js"; - -type ParentCommand = Command<[], { config: string; verbose?: true }>; - -export function registerSyncCommand(program: ParentCommand): void { - program - .command("sync", { isDefault: true }) - .description("Synchronize rules across all configured projects (default)") - .option("-n, --dry-run", "preview changes without writing files") - .option("--porcelain", "machine-readable output (implies --dry-run)") - .addHelpText( - "after", - ` -This is the default command when no subcommand is specified. - -Examples: - sync-rules # Sync all projects (silent on success) - sync-rules --verbose # Sync with status output - sync-rules --dry-run # Preview what would be written - sync-rules --porcelain # Machine-readable dry-run output - sync-rules && claude --chat # Chain with AI tool on success - sync-rules --porcelain | wc -l # Count files that would be written`, - ) - .action(async (options) => { - const parentOptions = program.opts(); - await runSyncCommand({ - configPath: parentOptions.config, - verbose: parentOptions.verbose ?? false, - dryRun: options.dryRun ?? options.porcelain ?? false, - porcelain: options.porcelain ?? false, - }); - }); -} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 78a2bea..d39efbe 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -4,9 +4,18 @@ import { parseConfig, findProjectForPath } from "./config.js"; import { loadConfig } from "./loader.js"; import { ConfigNotFoundError, ConfigParseError } from "../utils/errors.js"; import * as fs from "node:fs/promises"; +import { createConfigStore } from "./constants.js"; import type { Config } from "./config.js"; vi.mock("node:fs/promises"); +vi.mock("./constants.js", async () => { + const actual = + await vi.importActual("./constants.js"); + return { + ...actual, + createConfigStore: vi.fn(), + }; +}); // No need to mock paths - use real normalizePath @@ -278,30 +287,41 @@ describe("config", () => { }); it("should load and parse valid config successfully", async () => { - const configContent = JSON.stringify({ - rulesSource: "/path/to/rules", - projects: [ - { - path: "/home/user/project", - rules: ["**/*.md"], - }, - ], - }); + const store = { + path: "/path/to/config.json", + store: { + rulesSource: "/path/to/rules", + projects: [ + { + path: "/home/user/project", + rules: ["**/*.md"], + }, + ], + }, + }; - vi.mocked(fs.readFile).mockResolvedValue(configContent); + vi.mocked(createConfigStore).mockReturnValue(store as never); + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => true, + } as never); const config = await loadConfig("/path/to/config.json"); - expect(fs.readFile).toHaveBeenCalledWith("/path/to/config.json", "utf8"); + expect(fs.stat).toHaveBeenCalledWith("/path/to/config.json"); expect(config.projects).toHaveLength(1); }); it("should throw ConfigNotFoundError for missing default config", async () => { const error = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); - vi.mocked(fs.readFile).mockRejectedValueOnce(error); + vi.mocked(fs.stat).mockRejectedValueOnce(error); const { DEFAULT_CONFIG_PATH } = await import("./constants.js"); + vi.mocked(createConfigStore).mockReturnValue({ + path: DEFAULT_CONFIG_PATH, + store: {}, + } as never); + const promise = loadConfig(DEFAULT_CONFIG_PATH); await expect(promise).rejects.toThrow(ConfigNotFoundError); @@ -313,16 +333,34 @@ describe("config", () => { it("should throw ConfigNotFoundError for custom config path", async () => { const error = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); - vi.mocked(fs.readFile).mockRejectedValueOnce(error); + vi.mocked(fs.stat).mockRejectedValueOnce(error); + + vi.mocked(createConfigStore).mockReturnValue({ + path: "/custom/config.json", + store: {}, + } as never); await expect(loadConfig("/custom/config.json")).rejects.toThrow( ConfigNotFoundError, ); - expect(fs.readFile).toHaveBeenCalledTimes(1); + expect(fs.stat).toHaveBeenCalledTimes(1); }); it("should throw ConfigParseError for invalid JSON", async () => { - vi.mocked(fs.readFile).mockResolvedValue("{invalid json}"); + const store = { path: "/path/to/config.json" } as { + path: string; + store: Record; + }; + Object.defineProperty(store, "store", { + get() { + throw new SyntaxError("Invalid JSON"); + }, + }); + + vi.mocked(createConfigStore).mockReturnValue(store as never); + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => true, + } as never); await expect(loadConfig("/path/to/config.json")).rejects.toThrow( ConfigParseError, @@ -335,7 +373,12 @@ describe("config", () => { it("should throw ConfigParseError for permission errors", async () => { const error = Object.assign(new Error("EACCES"), { code: "EACCES" }); - vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.stat).mockRejectedValue(error); + + vi.mocked(createConfigStore).mockReturnValue({ + path: "/path/to/config.json", + store: {}, + } as never); await expect(loadConfig("/path/to/config.json")).rejects.toThrow( ConfigParseError, @@ -347,12 +390,18 @@ describe("config", () => { }); it("should throw ConfigParseError for Zod validation errors", async () => { - const invalidConfig = JSON.stringify({ - rulesSource: "/path/to/rules", - projects: [], // Empty projects array - }); + const store = { + path: "/path/to/config.json", + store: { + rulesSource: "/path/to/rules", + projects: [], + }, + }; - vi.mocked(fs.readFile).mockResolvedValue(invalidConfig); + vi.mocked(createConfigStore).mockReturnValue(store as never); + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => true, + } as never); await expect(loadConfig("/path/to/config.json")).rejects.toThrow( ConfigParseError, @@ -360,17 +409,23 @@ describe("config", () => { }); it("should normalize config paths", async () => { - const configContent = JSON.stringify({ - rulesSource: "/path/to/rules", - projects: [ - { - path: "~/project", - rules: ["**/*.md"], - }, - ], - }); + const store = { + path: "/path/to/config.json", + store: { + rulesSource: "/path/to/rules", + projects: [ + { + path: "~/project", + rules: ["**/*.md"], + }, + ], + }, + }; - vi.mocked(fs.readFile).mockResolvedValue(configContent); + vi.mocked(createConfigStore).mockReturnValue(store as never); + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => true, + } as never); const config = await loadConfig("/path/to/config.json"); diff --git a/src/config/constants.ts b/src/config/constants.ts index a90bb5d..63a64c9 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,14 +1,14 @@ import path from "node:path"; +import Conf from "conf"; import envPaths from "env-paths"; import { normalizePath } from "../utils/paths.js"; -const paths = envPaths("sync-rules", { suffix: "" }); -const defaultConfigPath = path.resolve(paths.config, "config.json"); +const defaultStore = new Conf({ projectName: "sync-rules", projectSuffix: "" }); /** * Built-in default configuration file path, ignoring env overrides. */ -export const BUILTIN_DEFAULT_CONFIG_PATH = defaultConfigPath; +export const BUILTIN_DEFAULT_CONFIG_PATH = defaultStore.path; /** * Default configuration file path @@ -16,10 +16,24 @@ export const BUILTIN_DEFAULT_CONFIG_PATH = defaultConfigPath; */ export const DEFAULT_CONFIG_PATH = process.env.SYNC_RULES_CONFIG ? normalizePath(process.env.SYNC_RULES_CONFIG) - : defaultConfigPath; + : BUILTIN_DEFAULT_CONFIG_PATH; /** * Default rules source directory path * Uses the system-specific data directory via env-paths without a Node.js suffix. */ -export const DEFAULT_RULES_SOURCE = path.resolve(paths.data, "rules"); +const dataPaths = envPaths("sync-rules", { suffix: "" }); +export const DEFAULT_RULES_SOURCE = path.resolve(dataPaths.data, "rules"); + +export function createConfigStore(configPath: string): Conf { + const normalizedPath = normalizePath(configPath); + const extension = path.extname(normalizedPath); + const configName = path.basename(normalizedPath, extension); + const fileExtension = extension ? extension.slice(1) : ""; + return new Conf({ + cwd: path.dirname(normalizedPath), + configName, + fileExtension, + projectSuffix: "", + }); +} diff --git a/src/config/loader.createSampleConfig.test.ts b/src/config/loader.createSampleConfig.test.ts index 148d478..62ac1fe 100644 --- a/src/config/loader.createSampleConfig.test.ts +++ b/src/config/loader.createSampleConfig.test.ts @@ -1,49 +1,79 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fs from "node:fs/promises"; +import { createConfigStore } from "./constants.js"; vi.mock("node:fs/promises", () => ({ - mkdir: vi.fn(), - writeFile: vi.fn(), + stat: vi.fn(), })); +vi.mock("./constants.js", async () => { + const actual = + await vi.importActual("./constants.js"); + return { + ...actual, + createConfigStore: vi.fn(), + }; +}); describe("createSampleConfig", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("createSampleConfig uses atomic 'wx' when force=false", async () => { - const fs = await import("node:fs/promises"); + it("createSampleConfig writes a sample config when file is missing", async () => { const { createSampleConfig } = await import("./loader.js"); - vi.mocked(fs.writeFile).mockResolvedValue(); + const store = { + path: "/tmp/config.json", + store: {} as Record, + }; + vi.mocked(createConfigStore).mockReturnValue(store as never); + const missing = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + vi.mocked(fs.stat).mockRejectedValue(missing); await createSampleConfig("/tmp/config.json", false); - expect(fs.mkdir).toHaveBeenCalledWith("/tmp", { recursive: true }); - expect(fs.writeFile).toHaveBeenCalledWith( - "/tmp/config.json", - expect.stringContaining('"projects"'), - { encoding: "utf8", flag: "wx" }, - ); + expect(store.store).toEqual({ + global: ["global-rules/*.md"], + projects: [ + { + path: "/path/to/project", + rules: ["**/*.md"], + }, + ], + }); }); it("createSampleConfig overwrites file when force=true", async () => { - const fs = await import("node:fs/promises"); const { createSampleConfig } = await import("./loader.js"); - vi.mocked(fs.writeFile).mockResolvedValue(); + const store = { + path: "/tmp/config.json", + store: {} as Record, + }; + vi.mocked(createConfigStore).mockReturnValue(store as never); await createSampleConfig("/tmp/config.json", true); - expect(fs.writeFile).toHaveBeenCalledWith( - "/tmp/config.json", - expect.any(String), - { encoding: "utf8", flag: "w" }, - ); + expect(fs.stat).not.toHaveBeenCalled(); + expect(store.store).toEqual({ + global: ["global-rules/*.md"], + projects: [ + { + path: "/path/to/project", + rules: ["**/*.md"], + }, + ], + }); }); it("atomic create: EEXIST yields actionable 'use --force' hint", async () => { - const fs = await import("node:fs/promises"); const { createSampleConfig } = await import("./loader.js"); - const eexist = Object.assign(new Error("exists"), { code: "EEXIST" }); - vi.mocked(fs.writeFile).mockRejectedValue(eexist); + const store = { + path: "/tmp/config.json", + store: {} as Record, + }; + vi.mocked(createConfigStore).mockReturnValue(store as never); + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => true, + } as never); const error = await createSampleConfig("/tmp/config.json", false).catch( (error_: unknown) => error_, @@ -52,16 +82,19 @@ describe("createSampleConfig", () => { expect(error).toBeInstanceOf(Error); const error_ = error as Error; expect(error_.message).toMatch(/already exists.*--force/iu); - expect(error_.cause).toBe(eexist); }); it("non-EEXIST errors are wrapped with normalized path context", async () => { - const fs = await import("node:fs/promises"); const { createSampleConfig } = await import("./loader.js"); + const store = { + path: "/tmp/config.json", + store: {} as Record, + }; + vi.mocked(createConfigStore).mockReturnValue(store as never); const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }); - vi.mocked(fs.writeFile).mockRejectedValue(eacces); + vi.mocked(fs.stat).mockRejectedValue(eacces); - const error = await createSampleConfig("/tmp/config.json", true).catch( + const error = await createSampleConfig("/tmp/config.json", false).catch( (error_: unknown) => error_, ); diff --git a/src/config/loader.ts b/src/config/loader.ts index 2334296..9d2bcbb 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,8 +1,7 @@ -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import path from "node:path"; +import { stat } from "node:fs/promises"; import { parseConfig } from "./config.js"; import { normalizePath } from "../utils/paths.js"; -import { BUILTIN_DEFAULT_CONFIG_PATH } from "./constants.js"; +import { BUILTIN_DEFAULT_CONFIG_PATH, createConfigStore } from "./constants.js"; import { ConfigNotFoundError, ConfigParseError, @@ -14,15 +13,15 @@ import type { Config } from "./config.js"; /** * Sample configuration template for new installations */ -const SAMPLE_CONFIG = `{ - "global": ["global-rules/*.md"], - "projects": [ +const SAMPLE_CONFIG = { + global: ["global-rules/*.md"], + projects: [ { - "path": "/path/to/project", - "rules": ["**/*.md"] - } - ] -}`; + path: "/path/to/project", + rules: ["**/*.md"], + }, + ], +}; /** * Creates a new configuration file with sample content @@ -35,26 +34,28 @@ export async function createSampleConfig( configPath: string, force = false, ): Promise { - const normalizedPath = normalizePath(configPath); - const configDirectory = path.dirname(normalizedPath); + const store = createConfigStore(configPath); + const normalizedPath = normalizePath(store.path); try { - await mkdir(configDirectory, { recursive: true }); + if (!force) { + try { + await stat(normalizedPath); + throw new Error( + `Config file already exists at ${normalizedPath}. Use --force to overwrite`, + ); + } catch (error) { + if (!(isNodeError(error) && error.code === "ENOENT")) { + throw error; + } + } + } - // Use 'wx' flag for atomic exclusive create when not forcing - // This prevents TOCTOU race conditions by atomically failing if file exists - const writeFlags = force ? "w" : "wx"; - await writeFile(normalizedPath, SAMPLE_CONFIG, { - encoding: "utf8", - flag: writeFlags, - }); + store.store = SAMPLE_CONFIG; } catch (error) { const error_ = ensureError(error); - if (isNodeError(error_) && error_.code === "EEXIST" && !force) { - throw new Error( - `Config file already exists at ${normalizedPath}. Use --force to overwrite`, - { cause: error_ }, - ); + if (error_.message.includes("Config file already exists") && !force) { + throw new Error(error_.message, { cause: error_ }); } throw new Error( `Failed to create config file at ${normalizedPath}: ${error_.message}`, @@ -72,11 +73,14 @@ export async function createSampleConfig( * @throws {ConfigParseError} When the config file cannot be parsed or is invalid */ export async function loadConfig(configPath: string): Promise { - const normalizedPath = normalizePath(configPath); + const store = createConfigStore(configPath); + const normalizedPath = normalizePath(store.path); try { - const configContent = await readFile(normalizedPath, "utf8"); - return parseConfig(configContent); + const configStat = await stat(normalizedPath); + if (!configStat.isFile()) { + throw new Error(`${normalizedPath} is not a file`); + } } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { const isBuiltinDefault = @@ -84,7 +88,12 @@ export async function loadConfig(configPath: string): Promise { throw new ConfigNotFoundError(normalizedPath, isBuiltinDefault); } - // Handle other errors (permissions, invalid JSON, parsing errors, etc.) + throw new ConfigParseError(normalizedPath, ensureError(error)); + } + + try { + return parseConfig(JSON.stringify(store.store)); + } catch (error) { throw new ConfigParseError(normalizedPath, ensureError(error)); } } diff --git a/src/utils/errors.test.ts b/src/utils/errors.test.ts index 2cb8e05..16eb392 100644 --- a/src/utils/errors.test.ts +++ b/src/utils/errors.test.ts @@ -59,14 +59,14 @@ describe("ConfigNotFoundError", () => { path: "/path/to/config.json", isDefault: false, message: "Config file not found at /path/to/config.json", - hintContains: "init --config ", + hintContains: "--init --config ", }, { title: "missing default config", path: "/default/config.json", isDefault: true, message: "Default config file not found at /default/config.json", - hintContains: "Run 'sync-rules init'", + hintContains: "Run 'sync-rules --init'", }, ] as const; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index fb0c47c..8a10618 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -30,8 +30,8 @@ export class ConfigNotFoundError extends Error { constructor(path: string, isDefault = false) { const location = isDefault ? "Default config file" : "Config file"; const hint = isDefault - ? "Run 'sync-rules init' to create one, or pass --config ." - : "Check the path, or create one with 'sync-rules init --config '."; + ? "Run 'sync-rules --init' to create one, or pass --config ." + : "Check the path, or create one with 'sync-rules --init --config '."; super( `${location} not found at ${path}.\n${hint}\nTry 'sync-rules --help' for details.`, ); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 73c4c48..f3bfbee 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -16,15 +16,29 @@ export function normalizePath(input: string): string { /** * Resolve a relative path inside a base directory, rejecting escapes. * - Rejects absolute input paths explicitly (e.g. "/etc/passwd"). - * - Uses path.resolve + path.relative to ensure the final path stays within baseDir. + * - Uses path.relative to ensure the final path stays within baseDir. */ export function resolveInside( baseDirectory: string, relativePath: string, ): string { + if (path.isAbsolute(relativePath)) { + throw new Error( + `Refusing to write outside ${baseDirectory}: ${relativePath}`, + ); + } + const full = path.resolve(baseDirectory, relativePath); const relative_ = path.relative(baseDirectory, full); - if (relative_.startsWith("..") || path.isAbsolute(relativePath)) { + if (relative_ === "") { + return full; + } + if (path.isAbsolute(relative_)) { + throw new Error( + `Refusing to write outside ${baseDirectory}: ${relativePath}`, + ); + } + if (relative_ === ".." || relative_.startsWith(`..${path.sep}`)) { throw new Error( `Refusing to write outside ${baseDirectory}: ${relativePath}`, ); From 00f5965de42ed897789cef9a67b0820b489b7a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 14:04:41 +0100 Subject: [PATCH 02/13] fix: harden config init and tests --- src/cli/main.test.ts | 58 +++++++++++++- src/config/config.test.ts | 40 +++++----- src/config/constants.ts | 9 ++- src/config/loader.createSampleConfig.test.ts | 84 +++++++------------- src/config/loader.ts | 66 +++++++-------- 5 files changed, 147 insertions(+), 110 deletions(-) diff --git a/src/cli/main.test.ts b/src/cli/main.test.ts index 6f169e5..4eb1241 100644 --- a/src/cli/main.test.ts +++ b/src/cli/main.test.ts @@ -6,7 +6,7 @@ import { DEFAULT_CONFIG_PATH, DEFAULT_RULES_SOURCE, } from "../config/constants.js"; -import { ConfigNotFoundError } from "../utils/errors.js"; +import { ConfigNotFoundError, ConfigParseError } from "../utils/errors.js"; // Mock the entire modules with dynamic imports support vi.mock("../config/loader.js", () => ({ @@ -143,6 +143,62 @@ describe("cli/main", () => { ); logSpy.mockRestore(); }); + + it("prints paths and exits non-zero on config parse errors", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(loader.loadConfig).mockRejectedValue( + new ConfigParseError(DEFAULT_CONFIG_PATH, new Error("Bad config")), + ); + + const code = await main(["node", "sync-rules", "--paths"]); + + expect(code).toBe(1); + expect(logSpy).toHaveBeenCalledWith("NAME\tPATH"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to load config"), + ); + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + }); + + describe("flag validation", () => { + it("rejects --force without --init", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const code = await main(["node", "sync-rules", "--force"]); + + expect(code).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + "--force can only be used with --init", + ); + errorSpy.mockRestore(); + }); + + it("rejects --init with --paths", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const code = await main(["node", "sync-rules", "--init", "--paths"]); + + expect(code).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + "Use only one of --init or --paths", + ); + errorSpy.mockRestore(); + }); + + it("rejects --dry-run with --init", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const code = await main(["node", "sync-rules", "--init", "--dry-run"]); + + expect(code).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + "--dry-run and --porcelain apply only to sync", + ); + errorSpy.mockRestore(); + }); }); describe("sync command (default)", () => { diff --git a/src/config/config.test.ts b/src/config/config.test.ts index d39efbe..8d7879d 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -347,26 +347,14 @@ describe("config", () => { }); it("should throw ConfigParseError for invalid JSON", async () => { - const store = { path: "/path/to/config.json" } as { - path: string; - store: Record; - }; - Object.defineProperty(store, "store", { - get() { - throw new SyntaxError("Invalid JSON"); - }, + const error = new SyntaxError("Invalid JSON"); + vi.mocked(createConfigStore).mockImplementation(() => { + throw error; }); - vi.mocked(createConfigStore).mockReturnValue(store as never); - vi.mocked(fs.stat).mockResolvedValue({ - isFile: () => true, - } as never); - - await expect(loadConfig("/path/to/config.json")).rejects.toThrow( - ConfigParseError, - ); - - await expect(loadConfig("/path/to/config.json")).rejects.toMatchObject({ + const promise = loadConfig("/path/to/config.json"); + await expect(promise).rejects.toThrow(ConfigParseError); + await expect(promise).rejects.toMatchObject({ path: "/path/to/config.json", }); }); @@ -389,6 +377,22 @@ describe("config", () => { }); }); + it("should throw ConfigParseError when path is a directory", async () => { + vi.mocked(createConfigStore).mockReturnValue({ + path: "/path/to/config.json", + store: {}, + } as never); + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => false, + } as never); + + const promise = loadConfig("/path/to/config.json"); + await expect(promise).rejects.toThrow(ConfigParseError); + await expect(promise).rejects.toMatchObject({ + path: "/path/to/config.json", + }); + }); + it("should throw ConfigParseError for Zod validation errors", async () => { const store = { path: "/path/to/config.json", diff --git a/src/config/constants.ts b/src/config/constants.ts index 63a64c9..3b30ec0 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -3,12 +3,14 @@ import Conf from "conf"; import envPaths from "env-paths"; import { normalizePath } from "../utils/paths.js"; -const defaultStore = new Conf({ projectName: "sync-rules", projectSuffix: "" }); - /** * Built-in default configuration file path, ignoring env overrides. */ -export const BUILTIN_DEFAULT_CONFIG_PATH = defaultStore.path; +const configPaths = envPaths("sync-rules", { suffix: "" }); +export const BUILTIN_DEFAULT_CONFIG_PATH = path.resolve( + configPaths.config, + "config.json", +); /** * Default configuration file path @@ -34,6 +36,7 @@ export function createConfigStore(configPath: string): Conf { cwd: path.dirname(normalizedPath), configName, fileExtension, + projectName: "sync-rules", projectSuffix: "", }); } diff --git a/src/config/loader.createSampleConfig.test.ts b/src/config/loader.createSampleConfig.test.ts index 62ac1fe..776337e 100644 --- a/src/config/loader.createSampleConfig.test.ts +++ b/src/config/loader.createSampleConfig.test.ts @@ -1,79 +1,53 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import * as fs from "node:fs/promises"; -import { createConfigStore } from "./constants.js"; vi.mock("node:fs/promises", () => ({ - stat: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), })); -vi.mock("./constants.js", async () => { - const actual = - await vi.importActual("./constants.js"); - return { - ...actual, - createConfigStore: vi.fn(), - }; -}); describe("createSampleConfig", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("createSampleConfig writes a sample config when file is missing", async () => { + it("writes a sample config with exclusive create by default", async () => { const { createSampleConfig } = await import("./loader.js"); - const store = { - path: "/tmp/config.json", - store: {} as Record, - }; - vi.mocked(createConfigStore).mockReturnValue(store as never); - const missing = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); - vi.mocked(fs.stat).mockRejectedValue(missing); + vi.mocked(fs.mkdir).mockResolvedValue(void 0); + vi.mocked(fs.writeFile).mockResolvedValue(void 0); await createSampleConfig("/tmp/config.json", false); - expect(store.store).toEqual({ - global: ["global-rules/*.md"], - projects: [ - { - path: "/path/to/project", - rules: ["**/*.md"], - }, - ], - }); + expect(fs.mkdir).toHaveBeenCalledWith("/tmp", { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith( + "/tmp/config.json", + expect.any(String), + { flag: "wx" }, + ); + const content = vi.mocked(fs.writeFile).mock.calls[0]?.[1] as string; + expect(content).toContain('"global-rules/*.md"'); + expect(content).toContain('"projects"'); }); - it("createSampleConfig overwrites file when force=true", async () => { + it("overwrites file when force=true", async () => { const { createSampleConfig } = await import("./loader.js"); - const store = { - path: "/tmp/config.json", - store: {} as Record, - }; - vi.mocked(createConfigStore).mockReturnValue(store as never); + vi.mocked(fs.mkdir).mockResolvedValue(void 0); + vi.mocked(fs.writeFile).mockResolvedValue(void 0); await createSampleConfig("/tmp/config.json", true); - expect(fs.stat).not.toHaveBeenCalled(); - expect(store.store).toEqual({ - global: ["global-rules/*.md"], - projects: [ - { - path: "/path/to/project", - rules: ["**/*.md"], - }, - ], - }); + expect(fs.writeFile).toHaveBeenCalledWith( + "/tmp/config.json", + expect.any(String), + { flag: "w" }, + ); }); it("atomic create: EEXIST yields actionable 'use --force' hint", async () => { const { createSampleConfig } = await import("./loader.js"); - const store = { - path: "/tmp/config.json", - store: {} as Record, - }; - vi.mocked(createConfigStore).mockReturnValue(store as never); - vi.mocked(fs.stat).mockResolvedValue({ - isFile: () => true, - } as never); + vi.mocked(fs.mkdir).mockResolvedValue(void 0); + const eexist = Object.assign(new Error("EEXIST"), { code: "EEXIST" }); + vi.mocked(fs.writeFile).mockRejectedValue(eexist); const error = await createSampleConfig("/tmp/config.json", false).catch( (error_: unknown) => error_, @@ -86,13 +60,9 @@ describe("createSampleConfig", () => { it("non-EEXIST errors are wrapped with normalized path context", async () => { const { createSampleConfig } = await import("./loader.js"); - const store = { - path: "/tmp/config.json", - store: {} as Record, - }; - vi.mocked(createConfigStore).mockReturnValue(store as never); + vi.mocked(fs.mkdir).mockResolvedValue(void 0); const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }); - vi.mocked(fs.stat).mockRejectedValue(eacces); + vi.mocked(fs.writeFile).mockRejectedValue(eacces); const error = await createSampleConfig("/tmp/config.json", false).catch( (error_: unknown) => error_, diff --git a/src/config/loader.ts b/src/config/loader.ts index 9d2bcbb..434db9e 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,5 +1,6 @@ -import { stat } from "node:fs/promises"; -import { parseConfig } from "./config.js"; +import { mkdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Config as ConfigSchema } from "./config.js"; import { normalizePath } from "../utils/paths.js"; import { BUILTIN_DEFAULT_CONFIG_PATH, createConfigStore } from "./constants.js"; import { @@ -8,7 +9,7 @@ import { ensureError, isNodeError, } from "../utils/errors.js"; -import type { Config } from "./config.js"; +import type { Config as ConfigShape } from "./config.js"; /** * Sample configuration template for new installations @@ -34,28 +35,21 @@ export async function createSampleConfig( configPath: string, force = false, ): Promise { - const store = createConfigStore(configPath); - const normalizedPath = normalizePath(store.path); + const normalizedPath = normalizePath(configPath); + const configDirectory = path.dirname(normalizedPath); try { - if (!force) { - try { - await stat(normalizedPath); - throw new Error( - `Config file already exists at ${normalizedPath}. Use --force to overwrite`, - ); - } catch (error) { - if (!(isNodeError(error) && error.code === "ENOENT")) { - throw error; - } - } - } - - store.store = SAMPLE_CONFIG; + await mkdir(configDirectory, { recursive: true }); + const content = JSON.stringify(SAMPLE_CONFIG, undefined, "\t"); + const flag = force ? "w" : "wx"; + await writeFile(normalizedPath, content, { flag }); } catch (error) { const error_ = ensureError(error); - if (error_.message.includes("Config file already exists") && !force) { - throw new Error(error_.message, { cause: error_ }); + if (isNodeError(error_) && error_.code === "EEXIST" && !force) { + throw new Error( + `Config file already exists at ${normalizedPath}. Use --force to overwrite`, + { cause: error_ }, + ); } throw new Error( `Failed to create config file at ${normalizedPath}: ${error_.message}`, @@ -72,28 +66,38 @@ export async function createSampleConfig( * @throws {ConfigNotFoundError} When the config file doesn't exist * @throws {ConfigParseError} When the config file cannot be parsed or is invalid */ -export async function loadConfig(configPath: string): Promise { - const store = createConfigStore(configPath); - const normalizedPath = normalizePath(store.path); +export async function loadConfig(configPath: string): Promise { + const normalizedPath = normalizePath(configPath); + let store: ReturnType; + try { + store = createConfigStore(configPath); + } catch (error) { + throw new ConfigParseError(normalizedPath, ensureError(error)); + } + const storePath = normalizePath(store.path); try { - const configStat = await stat(normalizedPath); + const configStat = await stat(storePath); if (!configStat.isFile()) { - throw new Error(`${normalizedPath} is not a file`); + throw new Error(`${storePath} is not a file`); } } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { const isBuiltinDefault = - normalizedPath === normalizePath(BUILTIN_DEFAULT_CONFIG_PATH); - throw new ConfigNotFoundError(normalizedPath, isBuiltinDefault); + storePath === normalizePath(BUILTIN_DEFAULT_CONFIG_PATH); + throw new ConfigNotFoundError(storePath, isBuiltinDefault); } - throw new ConfigParseError(normalizedPath, ensureError(error)); + throw new ConfigParseError(storePath, ensureError(error)); } try { - return parseConfig(JSON.stringify(store.store)); + const result = ConfigSchema.safeParse(store.store); + if (!result.success) { + throw result.error; + } + return result.data; } catch (error) { - throw new ConfigParseError(normalizedPath, ensureError(error)); + throw new ConfigParseError(storePath, ensureError(error)); } } From 721d1acde9b643d29e138dc10044dab9a7a564e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 14:15:37 +0100 Subject: [PATCH 03/13] fix: stat config before conf load --- src/config/config.test.ts | 3 +++ src/config/loader.ts | 27 +++++++++++++-------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 8d7879d..3d44526 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -351,6 +351,9 @@ describe("config", () => { vi.mocked(createConfigStore).mockImplementation(() => { throw error; }); + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => true, + } as never); const promise = loadConfig("/path/to/config.json"); await expect(promise).rejects.toThrow(ConfigParseError); diff --git a/src/config/loader.ts b/src/config/loader.ts index 434db9e..e9bfbc5 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -68,27 +68,26 @@ export async function createSampleConfig( */ export async function loadConfig(configPath: string): Promise { const normalizedPath = normalizePath(configPath); - let store: ReturnType; - try { - store = createConfigStore(configPath); - } catch (error) { - throw new ConfigParseError(normalizedPath, ensureError(error)); - } - const storePath = normalizePath(store.path); - try { - const configStat = await stat(storePath); + const configStat = await stat(normalizedPath); if (!configStat.isFile()) { - throw new Error(`${storePath} is not a file`); + throw new Error(`${normalizedPath} is not a file`); } } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { const isBuiltinDefault = - storePath === normalizePath(BUILTIN_DEFAULT_CONFIG_PATH); - throw new ConfigNotFoundError(storePath, isBuiltinDefault); + normalizedPath === normalizePath(BUILTIN_DEFAULT_CONFIG_PATH); + throw new ConfigNotFoundError(normalizedPath, isBuiltinDefault); } - throw new ConfigParseError(storePath, ensureError(error)); + throw new ConfigParseError(normalizedPath, ensureError(error)); + } + + let store: ReturnType; + try { + store = createConfigStore(configPath); + } catch (error) { + throw new ConfigParseError(normalizedPath, ensureError(error)); } try { @@ -98,6 +97,6 @@ export async function loadConfig(configPath: string): Promise { } return result.data; } catch (error) { - throw new ConfigParseError(storePath, ensureError(error)); + throw new ConfigParseError(normalizedPath, ensureError(error)); } } From 6b6c160c723fcdae183192e30118d976fe6b0195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 14:18:42 +0100 Subject: [PATCH 04/13] fix: normalize config path before conf --- src/config/loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/loader.ts b/src/config/loader.ts index e9bfbc5..a67b6ab 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -85,7 +85,7 @@ export async function loadConfig(configPath: string): Promise { let store: ReturnType; try { - store = createConfigStore(configPath); + store = createConfigStore(normalizedPath); } catch (error) { throw new ConfigParseError(normalizedPath, ensureError(error)); } From f3e810e386794f47e49ec87d257821a2695ed151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 14:19:46 +0100 Subject: [PATCH 05/13] test: assert init error cause --- src/config/loader.createSampleConfig.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/loader.createSampleConfig.test.ts b/src/config/loader.createSampleConfig.test.ts index 776337e..d342d0e 100644 --- a/src/config/loader.createSampleConfig.test.ts +++ b/src/config/loader.createSampleConfig.test.ts @@ -56,6 +56,7 @@ describe("createSampleConfig", () => { expect(error).toBeInstanceOf(Error); const error_ = error as Error; expect(error_.message).toMatch(/already exists.*--force/iu); + expect(error_.cause).toBe(eexist); }); it("non-EEXIST errors are wrapped with normalized path context", async () => { From 3bc9759a963dbac8aefd37af568a6003717bbc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 14:23:18 +0100 Subject: [PATCH 06/13] fix: include rulesSource in sample config --- src/config/loader.createSampleConfig.test.ts | 1 + src/config/loader.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/config/loader.createSampleConfig.test.ts b/src/config/loader.createSampleConfig.test.ts index d342d0e..da61084 100644 --- a/src/config/loader.createSampleConfig.test.ts +++ b/src/config/loader.createSampleConfig.test.ts @@ -25,6 +25,7 @@ describe("createSampleConfig", () => { { flag: "wx" }, ); const content = vi.mocked(fs.writeFile).mock.calls[0]?.[1] as string; + expect(content).toContain('"rulesSource"'); expect(content).toContain('"global-rules/*.md"'); expect(content).toContain('"projects"'); }); diff --git a/src/config/loader.ts b/src/config/loader.ts index a67b6ab..53a1fcb 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -15,6 +15,7 @@ import type { Config as ConfigShape } from "./config.js"; * Sample configuration template for new installations */ const SAMPLE_CONFIG = { + rulesSource: "/path/to/rules", global: ["global-rules/*.md"], projects: [ { From c215027b23da8d164bf0051a71850c20d03f54f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 14:25:24 +0100 Subject: [PATCH 07/13] docs: clarify path.relative empty case --- src/utils/paths.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/paths.ts b/src/utils/paths.ts index f3bfbee..d3655c1 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -31,6 +31,7 @@ export function resolveInside( const full = path.resolve(baseDirectory, relativePath); const relative_ = path.relative(baseDirectory, full); if (relative_ === "") { + // Empty relative means baseDirectory and full are the same path. return full; } if (path.isAbsolute(relative_)) { From bc187a1191def3223c2308ca8ea1c4560ea10c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 14:33:50 +0100 Subject: [PATCH 08/13] fix: tighten config defaults and path normalization --- src/config/constants.ts | 5 +++-- src/config/loader.ts | 12 ++++++++---- src/utils/paths.ts | 19 +++++++------------ src/utils/utilities.test.ts | 6 ++++++ 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/config/constants.ts b/src/config/constants.ts index 3b30ec0..8a1eef4 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -16,8 +16,9 @@ export const BUILTIN_DEFAULT_CONFIG_PATH = path.resolve( * Default configuration file path * Can be overridden via SYNC_RULES_CONFIG environment variable */ -export const DEFAULT_CONFIG_PATH = process.env.SYNC_RULES_CONFIG - ? normalizePath(process.env.SYNC_RULES_CONFIG) +const environmentConfigPath = process.env.SYNC_RULES_CONFIG?.trim(); +export const DEFAULT_CONFIG_PATH = environmentConfigPath + ? normalizePath(environmentConfigPath) : BUILTIN_DEFAULT_CONFIG_PATH; /** diff --git a/src/config/loader.ts b/src/config/loader.ts index 53a1fcb..c4a9be9 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,8 +1,12 @@ import { mkdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; -import { Config as ConfigSchema } from "./config.js"; +import { Config as ConfigValidator } from "./config.js"; import { normalizePath } from "../utils/paths.js"; -import { BUILTIN_DEFAULT_CONFIG_PATH, createConfigStore } from "./constants.js"; +import { + BUILTIN_DEFAULT_CONFIG_PATH, + DEFAULT_RULES_SOURCE, + createConfigStore, +} from "./constants.js"; import { ConfigNotFoundError, ConfigParseError, @@ -15,7 +19,7 @@ import type { Config as ConfigShape } from "./config.js"; * Sample configuration template for new installations */ const SAMPLE_CONFIG = { - rulesSource: "/path/to/rules", + rulesSource: DEFAULT_RULES_SOURCE, global: ["global-rules/*.md"], projects: [ { @@ -92,7 +96,7 @@ export async function loadConfig(configPath: string): Promise { } try { - const result = ConfigSchema.safeParse(store.store); + const result = ConfigValidator.safeParse(store.store); if (!result.success) { throw result.error; } diff --git a/src/utils/paths.ts b/src/utils/paths.ts index d3655c1..50d2d1b 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -8,7 +8,7 @@ import path from "node:path"; */ export function normalizePath(input: string): string { const expanded = input.startsWith("~") - ? input.replace(/^~/u, homedir()) + ? input.replace(/^~(?=$|[\\/])/u, homedir()) : input; return path.resolve(expanded); } @@ -22,27 +22,22 @@ export function resolveInside( baseDirectory: string, relativePath: string, ): string { + const base = path.resolve(baseDirectory); if (path.isAbsolute(relativePath)) { - throw new Error( - `Refusing to write outside ${baseDirectory}: ${relativePath}`, - ); + throw new Error(`Refusing to write outside ${base}: ${relativePath}`); } - const full = path.resolve(baseDirectory, relativePath); - const relative_ = path.relative(baseDirectory, full); + const full = path.resolve(base, relativePath); + const relative_ = path.relative(base, full); if (relative_ === "") { // Empty relative means baseDirectory and full are the same path. return full; } if (path.isAbsolute(relative_)) { - throw new Error( - `Refusing to write outside ${baseDirectory}: ${relativePath}`, - ); + throw new Error(`Refusing to write outside ${base}: ${relativePath}`); } if (relative_ === ".." || relative_.startsWith(`..${path.sep}`)) { - throw new Error( - `Refusing to write outside ${baseDirectory}: ${relativePath}`, - ); + throw new Error(`Refusing to write outside ${base}: ${relativePath}`); } return full; } diff --git a/src/utils/utilities.test.ts b/src/utils/utilities.test.ts index b52ab02..ab6920f 100644 --- a/src/utils/utilities.test.ts +++ b/src/utils/utilities.test.ts @@ -40,5 +40,11 @@ describe("utilities", () => { expect(normalizePath(c.input)).toBe(c.expected); } }); + + it("does not expand ~user paths", () => { + expect(normalizePath("~someone/config.json")).toBe( + path.resolve("~someone/config.json"), + ); + }); }); }); From 6e284b03373de4738b2a7da424eab82831e9fcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 15:34:08 +0100 Subject: [PATCH 09/13] fix: pass normalized path to loadConfig in resolvePaths Avoids redundant normalization since loadConfig also normalizes. --- src/cli/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index 11317bb..a77aa21 100755 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -28,7 +28,7 @@ type CliOptions = { async function resolvePaths(configPath: string): Promise { const normalizedPath = normalizePath(configPath); try { - const config = await loadConfig(configPath); + const config = await loadConfig(normalizedPath); return { configPath: normalizedPath, rulesSource: config.rulesSource }; } catch (error) { const error_ = ensureError(error); From 7a24dcb8d282f28eb9689db9aa1a2b291b0dabe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 15:36:59 +0100 Subject: [PATCH 10/13] fix: use path.relative for Windows case-insensitive path comparison path.relative is case-insensitive on Windows, ensuring the default config detection works correctly regardless of path casing. --- src/config/loader.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config/loader.ts b/src/config/loader.ts index c4a9be9..c4df72e 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -80,8 +80,12 @@ export async function loadConfig(configPath: string): Promise { } } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { + // Use path.relative for comparison - it's case-insensitive on Windows const isBuiltinDefault = - normalizedPath === normalizePath(BUILTIN_DEFAULT_CONFIG_PATH); + path.relative( + normalizedPath, + normalizePath(BUILTIN_DEFAULT_CONFIG_PATH), + ) === ""; throw new ConfigNotFoundError(normalizedPath, isBuiltinDefault); } From f3de9ff6e9695205e4cd2d7d9d0c90a27f8941cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 15:39:10 +0100 Subject: [PATCH 11/13] feat: add ConfigAccessError for file access issues Separates access errors (EACCES, not a file) from parse errors (invalid JSON, schema validation) so users get appropriate hints. --- src/config/config.test.ts | 14 +++++++++----- src/config/loader.ts | 4 +++- src/utils/errors.ts | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 3d44526..1fafe6e 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { z } from "zod"; import { parseConfig, findProjectForPath } from "./config.js"; import { loadConfig } from "./loader.js"; -import { ConfigNotFoundError, ConfigParseError } from "../utils/errors.js"; +import { + ConfigAccessError, + ConfigNotFoundError, + ConfigParseError, +} from "../utils/errors.js"; import * as fs from "node:fs/promises"; import { createConfigStore } from "./constants.js"; import type { Config } from "./config.js"; @@ -362,7 +366,7 @@ describe("config", () => { }); }); - it("should throw ConfigParseError for permission errors", async () => { + it("should throw ConfigAccessError for permission errors", async () => { const error = Object.assign(new Error("EACCES"), { code: "EACCES" }); vi.mocked(fs.stat).mockRejectedValue(error); @@ -372,7 +376,7 @@ describe("config", () => { } as never); await expect(loadConfig("/path/to/config.json")).rejects.toThrow( - ConfigParseError, + ConfigAccessError, ); await expect(loadConfig("/path/to/config.json")).rejects.toMatchObject({ @@ -380,7 +384,7 @@ describe("config", () => { }); }); - it("should throw ConfigParseError when path is a directory", async () => { + it("should throw ConfigAccessError when path is a directory", async () => { vi.mocked(createConfigStore).mockReturnValue({ path: "/path/to/config.json", store: {}, @@ -390,7 +394,7 @@ describe("config", () => { } as never); const promise = loadConfig("/path/to/config.json"); - await expect(promise).rejects.toThrow(ConfigParseError); + await expect(promise).rejects.toThrow(ConfigAccessError); await expect(promise).rejects.toMatchObject({ path: "/path/to/config.json", }); diff --git a/src/config/loader.ts b/src/config/loader.ts index c4df72e..263a2e0 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -8,6 +8,7 @@ import { createConfigStore, } from "./constants.js"; import { + ConfigAccessError, ConfigNotFoundError, ConfigParseError, ensureError, @@ -69,6 +70,7 @@ export async function createSampleConfig( * * @param configPath - Path to the JSON config file. `~` is supported. * @throws {ConfigNotFoundError} When the config file doesn't exist + * @throws {ConfigAccessError} When the config file cannot be accessed (permissions, not a file) * @throws {ConfigParseError} When the config file cannot be parsed or is invalid */ export async function loadConfig(configPath: string): Promise { @@ -89,7 +91,7 @@ export async function loadConfig(configPath: string): Promise { throw new ConfigNotFoundError(normalizedPath, isBuiltinDefault); } - throw new ConfigParseError(normalizedPath, ensureError(error)); + throw new ConfigAccessError(normalizedPath, ensureError(error)); } let store: ReturnType; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 8a10618..89ba4df 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -41,6 +41,26 @@ export class ConfigNotFoundError extends Error { } } +/** + * Error thrown when config file cannot be accessed (permissions, not a file, etc.) + */ +export class ConfigAccessError extends Error { + readonly path: string; + readonly originalError?: Error; + + constructor(path: string, originalError?: Error) { + const base = originalError + ? `Cannot access config at ${path}: ${originalError.message}` + : `Cannot access config at ${path}`; + const hint = "Check the file path and permissions."; + const baseWithPeriod = /[.!?]$/u.test(base) ? base : `${base}.`; + super(`${baseWithPeriod}\n${hint}`, { cause: originalError }); + this.name = this.constructor.name; + this.path = path; + this.originalError = originalError; + } +} + /** * Error thrown when config file cannot be parsed or is invalid. */ From 09a65491eb106b1ecdb87e8cab845a77a8dc8a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 15:46:47 +0100 Subject: [PATCH 12/13] fix: use placeholder path in sample config Avoid writing system-specific paths to the sample config file. Use "/path/to/rules" as a clear placeholder. --- src/config/loader.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/config/loader.ts b/src/config/loader.ts index 263a2e0..8194d98 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -2,11 +2,7 @@ import { mkdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { Config as ConfigValidator } from "./config.js"; import { normalizePath } from "../utils/paths.js"; -import { - BUILTIN_DEFAULT_CONFIG_PATH, - DEFAULT_RULES_SOURCE, - createConfigStore, -} from "./constants.js"; +import { BUILTIN_DEFAULT_CONFIG_PATH, createConfigStore } from "./constants.js"; import { ConfigAccessError, ConfigNotFoundError, @@ -20,7 +16,7 @@ import type { Config as ConfigShape } from "./config.js"; * Sample configuration template for new installations */ const SAMPLE_CONFIG = { - rulesSource: DEFAULT_RULES_SOURCE, + rulesSource: "/path/to/rules", global: ["global-rules/*.md"], projects: [ { From a14165c32a890c9f8d3205580a5cd75f3f2b0416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Thu, 15 Jan 2026 15:49:56 +0100 Subject: [PATCH 13/13] refactor: rename store to configStore for clarity --- src/config/loader.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/loader.ts b/src/config/loader.ts index 8194d98..c0bece0 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -90,15 +90,15 @@ export async function loadConfig(configPath: string): Promise { throw new ConfigAccessError(normalizedPath, ensureError(error)); } - let store: ReturnType; + let configStore: ReturnType; try { - store = createConfigStore(normalizedPath); + configStore = createConfigStore(normalizedPath); } catch (error) { throw new ConfigParseError(normalizedPath, ensureError(error)); } try { - const result = ConfigValidator.safeParse(store.store); + const result = ConfigValidator.safeParse(configStore.store); if (!result.success) { throw result.error; }