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..4eb1241 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, ConfigParseError } 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,54 +61,143 @@ 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(); + }); + + 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(); }); }); diff --git a/src/cli/main.ts b/src/cli/main.ts index 37bfc71..a77aa21 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(normalizedPath); + 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..1fafe6e 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2,11 +2,24 @@ 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"; 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 +291,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,32 +337,46 @@ 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}"); - - await expect(loadConfig("/path/to/config.json")).rejects.toThrow( - ConfigParseError, - ); + const error = new SyntaxError("Invalid JSON"); + vi.mocked(createConfigStore).mockImplementation(() => { + throw error; + }); + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => true, + } as never); - 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", }); }); - 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.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, + ConfigAccessError, ); await expect(loadConfig("/path/to/config.json")).rejects.toMatchObject({ @@ -346,13 +384,35 @@ describe("config", () => { }); }); - it("should throw ConfigParseError for Zod validation errors", async () => { - const invalidConfig = JSON.stringify({ - rulesSource: "/path/to/rules", - projects: [], // Empty projects array + it("should throw ConfigAccessError 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(ConfigAccessError); + await expect(promise).rejects.toMatchObject({ + path: "/path/to/config.json", }); + }); - vi.mocked(fs.readFile).mockResolvedValue(invalidConfig); + it("should throw ConfigParseError for Zod validation errors", async () => { + const store = { + path: "/path/to/config.json", + store: { + rulesSource: "/path/to/rules", + projects: [], + }, + }; + + 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 +420,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..8a1eef4 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,25 +1,43 @@ 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"); - /** * Built-in default configuration file path, ignoring env overrides. */ -export const BUILTIN_DEFAULT_CONFIG_PATH = defaultConfigPath; +const configPaths = envPaths("sync-rules", { suffix: "" }); +export const BUILTIN_DEFAULT_CONFIG_PATH = path.resolve( + configPaths.config, + "config.json", +); /** * 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) - : defaultConfigPath; +const environmentConfigPath = process.env.SYNC_RULES_CONFIG?.trim(); +export const DEFAULT_CONFIG_PATH = environmentConfigPath + ? normalizePath(environmentConfigPath) + : 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, + projectName: "sync-rules", + projectSuffix: "", + }); +} diff --git a/src/config/loader.createSampleConfig.test.ts b/src/config/loader.createSampleConfig.test.ts index 148d478..da61084 100644 --- a/src/config/loader.createSampleConfig.test.ts +++ b/src/config/loader.createSampleConfig.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fs from "node:fs/promises"; vi.mock("node:fs/promises", () => ({ mkdir: vi.fn(), @@ -10,39 +11,43 @@ describe("createSampleConfig", () => { vi.clearAllMocks(); }); - it("createSampleConfig uses atomic 'wx' when force=false", async () => { - const fs = await import("node:fs/promises"); + it("writes a sample config with exclusive create by default", async () => { const { createSampleConfig } = await import("./loader.js"); - vi.mocked(fs.writeFile).mockResolvedValue(); + vi.mocked(fs.mkdir).mockResolvedValue(void 0); + vi.mocked(fs.writeFile).mockResolvedValue(void 0); 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.any(String), + { 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"'); }); - it("createSampleConfig overwrites file when force=true", async () => { - const fs = await import("node:fs/promises"); + it("overwrites file when force=true", async () => { const { createSampleConfig } = await import("./loader.js"); - vi.mocked(fs.writeFile).mockResolvedValue(); + vi.mocked(fs.mkdir).mockResolvedValue(void 0); + vi.mocked(fs.writeFile).mockResolvedValue(void 0); await createSampleConfig("/tmp/config.json", true); expect(fs.writeFile).toHaveBeenCalledWith( "/tmp/config.json", expect.any(String), - { encoding: "utf8", flag: "w" }, + { flag: "w" }, ); }); 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.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( @@ -56,12 +61,12 @@ describe("createSampleConfig", () => { }); it("non-EEXIST errors are wrapped with normalized path context", async () => { - const fs = await import("node:fs/promises"); const { createSampleConfig } = await import("./loader.js"); + vi.mocked(fs.mkdir).mockResolvedValue(void 0); const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }); vi.mocked(fs.writeFile).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..c0bece0 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,28 +1,30 @@ -import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { mkdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; -import { parseConfig } from "./config.js"; +import { Config as ConfigValidator } 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 { + ConfigAccessError, ConfigNotFoundError, ConfigParseError, 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 */ -const SAMPLE_CONFIG = `{ - "global": ["global-rules/*.md"], - "projects": [ +const SAMPLE_CONFIG = { + rulesSource: "/path/to/rules", + 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 @@ -40,14 +42,9 @@ export async function createSampleConfig( try { await mkdir(configDirectory, { recursive: true }); - - // 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, - }); + 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 (isNodeError(error_) && error_.code === "EEXIST" && !force) { @@ -69,22 +66,44 @@ 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 { +export async function loadConfig(configPath: string): Promise { const normalizedPath = normalizePath(configPath); - 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") { + // 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); } - // Handle other errors (permissions, invalid JSON, parsing errors, etc.) + throw new ConfigAccessError(normalizedPath, ensureError(error)); + } + + let configStore: ReturnType; + try { + configStore = createConfigStore(normalizedPath); + } catch (error) { + throw new ConfigParseError(normalizedPath, ensureError(error)); + } + + try { + const result = ConfigValidator.safeParse(configStore.store); + if (!result.success) { + throw result.error; + } + return result.data; + } 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..89ba4df 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.`, ); @@ -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. */ diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 73c4c48..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); } @@ -16,18 +16,28 @@ 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 { - const full = path.resolve(baseDirectory, relativePath); - const relative_ = path.relative(baseDirectory, full); - if (relative_.startsWith("..") || path.isAbsolute(relativePath)) { - throw new Error( - `Refusing to write outside ${baseDirectory}: ${relativePath}`, - ); + const base = path.resolve(baseDirectory); + if (path.isAbsolute(relativePath)) { + throw new Error(`Refusing to write outside ${base}: ${relativePath}`); + } + + 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 ${base}: ${relativePath}`); + } + if (relative_ === ".." || relative_.startsWith(`..${path.sep}`)) { + 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"), + ); + }); }); });