From ee4101eab238e013969aa87d99690eed02b7ca2d Mon Sep 17 00:00:00 2001 From: Erik Verduin Date: Fri, 14 Aug 2026 11:13:59 -0500 Subject: [PATCH 1/3] build: improve install size Bundled tree-shaken valibot with package and moved to devDep. Removed config store in favor of hand-rolled alternative. Replaced json-c with a strip-json-comments equivalent. Should drop approx 2.78 MB of install size and down to 8 *total* dependencies from *21*. --- package-lock.json | 201 +------------------------- package.json | 7 +- src/branch.ts | 6 +- src/index.ts | 6 +- src/prompt-cache.ts | 49 +++++++ src/prompts/branch-runnable.ts | 4 +- src/prompts/runnable.ts | 4 +- src/utils.ts | 21 ++- src/utils/strip-json-comments.test.ts | 166 +++++++++++++++++++++ src/utils/strip-json-comments.ts | 122 ++++++++++++++++ tsdown.config.ts | 4 + 11 files changed, 371 insertions(+), 219 deletions(-) create mode 100644 src/prompt-cache.ts create mode 100644 src/utils/strip-json-comments.test.ts create mode 100644 src/utils/strip-json-comments.ts diff --git a/package-lock.json b/package-lock.json index 207e8cb..be559ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,7 @@ "@bomb.sh/args": "^0.3.1", "@clack/core": "^1.2.0", "@clack/prompts": "^1.2.0", - "configstore": "^8.0.0", - "jsonc-parser": "^3.3.1", - "picocolors": "^1.0.0", - "valibot": "^1.3.1" + "picocolors": "^1.0.0" }, "bin": { "bcommits": "dist/index.js", @@ -32,6 +29,7 @@ "tsdown": "^0.21.10", "tsx": "^3.12.3", "typescript": "^5.9.3", + "valibot": "1.3.1", "vitest": "^3.2.4" }, "engines": { @@ -2866,16 +2864,6 @@ "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/atomically": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", - "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", - "license": "MIT", - "dependencies": { - "stubborn-fs": "^2.0.0", - "when-exit": "^2.1.4" - } - }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -3225,61 +3213,6 @@ "proto-list": "~1.2.1" } }, - "node_modules/configstore": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-8.0.0.tgz", - "integrity": "sha512-U51WPZg+o6FDFhBCV6yYFEspGMCoHvCgJSGFw9AwsW2u75gYBPoDlYca5XfDs4TdMUu3/y0R6FFdvvgyH31mKQ==", - "license": "BSD-2-Clause", - "dependencies": { - "atomically": "^2.1.0", - "dot-prop": "^10.1.0", - "graceful-fs": "^4.2.11", - "is-safe-filename": "^0.1.0", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/configstore/node_modules/dot-prop": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", - "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", - "license": "MIT", - "dependencies": { - "type-fest": "^5.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/configstore/node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/configstore/node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/conventional-changelog-angular": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.1.0.tgz", @@ -4403,18 +4336,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-safe-filename": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", - "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4535,12 +4456,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "license": "MIT" - }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -8458,21 +8373,6 @@ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true }, - "node_modules/stubborn-fs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", - "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", - "license": "MIT", - "dependencies": { - "stubborn-utils": "^1.0.1" - } - }, - "node_modules/stubborn-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", - "license": "MIT" - }, "node_modules/super-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", @@ -9057,6 +8957,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "dev": true, "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -10335,12 +10236,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/when-exit": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", - "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10464,18 +10359,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12242,15 +12125,6 @@ "pathe": "^2.0.3" } }, - "atomically": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", - "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", - "requires": { - "stubborn-fs": "^2.0.0", - "when-exit": "^2.1.4" - } - }, "before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -12495,41 +12369,6 @@ "proto-list": "~1.2.1" } }, - "configstore": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-8.0.0.tgz", - "integrity": "sha512-U51WPZg+o6FDFhBCV6yYFEspGMCoHvCgJSGFw9AwsW2u75gYBPoDlYca5XfDs4TdMUu3/y0R6FFdvvgyH31mKQ==", - "requires": { - "atomically": "^2.1.0", - "dot-prop": "^10.1.0", - "graceful-fs": "^4.2.11", - "is-safe-filename": "^0.1.0", - "xdg-basedir": "^5.1.0" - }, - "dependencies": { - "dot-prop": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", - "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", - "requires": { - "type-fest": "^5.0.0" - } - }, - "graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", - "requires": { - "tagged-tag": "^1.0.0" - } - } - } - }, "conventional-changelog-angular": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.1.0.tgz", @@ -13258,11 +13097,6 @@ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true }, - "is-safe-filename": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", - "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==" - }, "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -13345,11 +13179,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" - }, "jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -15999,19 +15828,6 @@ } } }, - "stubborn-fs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", - "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", - "requires": { - "stubborn-utils": "^1.0.1" - } - }, - "stubborn-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==" - }, "super-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", @@ -16356,6 +16172,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "dev": true, "requires": {} }, "validate-npm-package-license": { @@ -16931,11 +16748,6 @@ "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", "dev": true }, - "when-exit": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", - "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==" - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -17012,11 +16824,6 @@ } } }, - "xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==" - }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index ad5ea70..a4f8dff 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "files": [ "dist", "LICENSE", + "THIRD_PARTY_NOTICES.md", "readme.md" ], "bin": { @@ -37,10 +38,7 @@ "@bomb.sh/args": "^0.3.1", "@clack/core": "^1.2.0", "@clack/prompts": "^1.2.0", - "configstore": "^8.0.0", - "jsonc-parser": "^3.3.1", - "picocolors": "^1.0.0", - "valibot": "^1.3.1" + "picocolors": "^1.0.0" }, "scripts": { "start": "tsx ./src/index.ts", @@ -59,6 +57,7 @@ "tsdown": "^0.21.10", "tsx": "^3.12.3", "typescript": "^5.9.3", + "valibot": "1.3.1", "vitest": "^3.2.4" }, "release": { diff --git a/src/branch.ts b/src/branch.ts index 57fad11..b9c50b2 100644 --- a/src/branch.ts +++ b/src/branch.ts @@ -1,6 +1,6 @@ #! /usr/bin/env node -import Configstore from "configstore"; +import { FilePromptCache, PromptCache } from "./prompt-cache"; import { chdir } from "process"; import { InferOutput, ValiError, parse } from "valibot"; import { BranchState, CommitState, Config } from "./valibot-state"; @@ -28,7 +28,7 @@ import * as p from "@clack/prompts"; type PromptCtor = new ( config: InferOutput, commit_state: InferOutput, - prompt_cache: Configstore, + prompt_cache: PromptCache, ) => BranchRunnable; const promptCtors: PromptCtor[] = [ @@ -82,7 +82,7 @@ async function main( } const prompt_cache = config.cache_last_value - ? new Configstore("better-commits") + ? new FilePromptCache() : NOOP_PROMPT_CACHE; const prompts_to_run = branch_flags.interactive ? promptCtors diff --git a/src/index.ts b/src/index.ts index 6a7717a..51e7a0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { ConfigSource, } from "./utils"; import { create_strict_commit_state } from "./utils/no-interactive-validation"; -import Configstore from "configstore"; +import { FilePromptCache, PromptCache } from "./prompt-cache"; import { CommitTypePrompt } from "./prompts/commit-type.prompt"; import { Runnable } from "./prompts/runnable"; import { CommitScopePrompt } from "./prompts/commit-scope.prompt"; @@ -29,7 +29,7 @@ import { print_help_text } from "./help"; type PromptCtor = new ( config: InferOutput, commit_state: InferOutput, - prompt_cache: Configstore, + prompt_cache: PromptCache, ) => Runnable; const promptCtors: PromptCtor[] = [ @@ -88,7 +88,7 @@ export async function main( } const prompt_cache = config.cache_last_value - ? new Configstore("better-commits") + ? new FilePromptCache() : NOOP_PROMPT_CACHE; const prompts_to_run = flags.interactive diff --git a/src/prompt-cache.ts b/src/prompt-cache.ts new file mode 100644 index 0000000..9924212 --- /dev/null +++ b/src/prompt-cache.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import { homedir } from "os"; +import path from "path"; + +export interface PromptCache { + get(key: string): string | undefined; + set(key: string, value: string): void; + clear(): void; +} + +export class FilePromptCache implements PromptCache { + readonly path = path.join( + process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), + "configstore", + "better-commits.json", + ); + + get(key: string): string | undefined { + return this.read()[key]; + } + + set(key: string, value: string): void { + this.write({ ...this.read(), [key]: value }); + } + + clear(): void { + this.write({}); + } + + private read(): Record { + try { + return JSON.parse(fs.readFileSync(this.path, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + if (error instanceof SyntaxError) { + this.write({}); + return {}; + } + throw error; + } + } + + private write(cache: Record): void { + fs.mkdirSync(path.dirname(this.path), { mode: 0o700, recursive: true }); + fs.writeFileSync(this.path, JSON.stringify(cache, null, "\t"), { + mode: 0o600, + }); + } +} diff --git a/src/prompts/branch-runnable.ts b/src/prompts/branch-runnable.ts index ec49270..dc848ad 100644 --- a/src/prompts/branch-runnable.ts +++ b/src/prompts/branch-runnable.ts @@ -1,12 +1,12 @@ import { InferOutput } from "valibot"; import { BranchState, Config } from "../valibot-state"; -import Configstore from "configstore"; +import { PromptCache } from "../prompt-cache"; export abstract class BranchRunnable { constructor( protected config: InferOutput, protected branch_state: InferOutput, - protected prompt_cache: Configstore, + protected prompt_cache: PromptCache, ) {} abstract run(): Promise; diff --git a/src/prompts/runnable.ts b/src/prompts/runnable.ts index bfaac5d..69b5a5e 100644 --- a/src/prompts/runnable.ts +++ b/src/prompts/runnable.ts @@ -1,12 +1,12 @@ import { InferOutput } from "valibot"; import { CommitState, Config } from "../valibot-state"; -import Configstore from "configstore"; +import { PromptCache } from "../prompt-cache"; export abstract class Runnable { constructor( protected config: InferOutput, protected commit_state: InferOutput, - protected prompt_cache: Configstore, + protected prompt_cache: PromptCache, ) {} abstract run(): Promise; diff --git a/src/utils.ts b/src/utils.ts index 472efdc..fec3970 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,14 +2,14 @@ import * as p from "@clack/prompts"; import { execSync } from "child_process"; import fs from "fs"; import { homedir } from "os"; -import { parse as parse_jsonc } from "jsonc-parser"; import color from "picocolors"; import { InferOutput, ValiError, parse } from "valibot"; import { Config } from "./valibot-state"; import { V_BRANCH_ACTIONS } from "./valibot-consts"; import { flags } from "./args"; -import Configstore from "configstore"; +import { PromptCache } from "./prompt-cache"; import { DEFAULT_CONFIG_TEMPLATE } from "./default-config-template"; +import { stripJsonComments } from "./utils/strip-json-comments"; const CONFIG_FILE_NAMES = [ ".better-commits.jsonc", @@ -44,11 +44,11 @@ export const BRANCH_ACTION_OPTIONS: { { value: "worktree", label: "Worktree" }, ]; -export const NOOP_PROMPT_CACHE = { +export const NOOP_PROMPT_CACHE: PromptCache = { get: () => "", - set: (key: string, value: string) => {}, + set: () => {}, clear: () => {}, -} as unknown as Configstore; +}; export type ConfigSource = "repository" | "global" | "none"; @@ -113,7 +113,12 @@ export function load_setup( function read_config_from_path(config_path: string) { let res = null; try { - res = parse_jsonc(fs.readFileSync(config_path, "utf8")); + const config_text = fs.readFileSync(config_path, "utf8"); + const jsonc = + config_text.charCodeAt(0) === 0xfeff + ? config_text.slice(1) + : config_text; + res = JSON.parse(stripJsonComments(jsonc, { trailingCommas: true })); } catch (err) { p.log.error( `Invalid JSON/JSONC config file at ${config_path}. Exiting.\n` + err, @@ -206,7 +211,7 @@ export function clean_commit_title(title: string): string { } export function get_value_from_cache( - config_store: Configstore, + config_store: PromptCache, key: string, ): string { try { @@ -221,7 +226,7 @@ export function get_value_from_cache( } export function set_value_cache( - config_store: Configstore, + config_store: PromptCache, key: string, value: string, ): void { diff --git a/src/utils/strip-json-comments.test.ts b/src/utils/strip-json-comments.test.ts new file mode 100644 index 0000000..d510b9d --- /dev/null +++ b/src/utils/strip-json-comments.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_CONFIG_TEMPLATE } from "../default-config-template"; +import { stripJsonComments } from "./strip-json-comments"; + +describe("stripJsonComments", () => { + it("removes single line comments", () => { + expect(stripJsonComments('{\n\t// comment\n\t"a":"b"\n}')).toBe( + '{\n\t \n\t"a":"b"\n}', + ); + }); + + it("removes single line comments at the end of a line", () => { + expect(stripJsonComments('{\n\t"a":"b" // comment\n}')).toBe( + '{\n\t"a":"b" \n}', + ); + }); + + it("removes multiline comments", () => { + expect(stripJsonComments('{\n\t/*\n\tcomment\n\t*/\n\t"a":"b"\n}')).toBe( + '{\n\t \n\t \n\t \n\t"a":"b"\n}', + ); + }); + + it("removes multiline comments at the end of a line", () => { + expect(stripJsonComments('{\n\t"a":"b" /* comment */\n}')).toBe( + '{\n\t"a":"b" \n}', + ); + }); + + it("removes comments preceded by whitespace", () => { + expect(stripJsonComments('{\n\t"a":"b" /* comment */\n}')).toBe( + '{\n\t"a":"b" \n}', + ); + }); + + it("removes multiple single line comments", () => { + expect( + stripJsonComments('/*comment*/\n{\n\t/*comment*/ "a":"b" //comment\n}'), + ).toBe(' \n{\n\t "a":"b" \n}'); + }); + + it("removes multiple multiline comments", () => { + expect( + stripJsonComments( + '/*\ncomment\ncomment*/\n{\n\t/*\n\tcomment\n\t*/\n\t"a":"b"\n}', + ), + ).toBe( + ' \n \n \n{\n\t \n\t \n\t \n\t"a":"b"\n}', + ); + }); + + it("removes comments with line breaks", () => { + expect(stripJsonComments('{"a":"b"/**/}')).toBe('{"a":"b" }'); + }); + + it("removes comments with multiple asterisks", () => { + expect(stripJsonComments('{"a":"b"/**\n\n**/}')).toBe( + '{"a":"b" \n\n }', + ); + }); + + it("does not strip comment markers within strings", () => { + expect(stripJsonComments('{"a":"b // comment"}')).toBe( + '{"a":"b // comment"}', + ); + expect(stripJsonComments('{"a":"b /* comment */"}')).toBe( + '{"a":"b /* comment */"}', + ); + expect(stripJsonComments('{"/*a":"b"}')).toBe('{"/*a":"b"}'); + expect(stripJsonComments('{"\\"/*a":"b"}')).toBe('{"\\"/*a":"b"}'); + }); + + it("handles escaped slashes at end of a string preceding a comment", () => { + expect(stripJsonComments('{"a":"b\\\\"/* comment */}')).toBe( + '{"a":"b\\\\" }', + ); + }); + + it("handles weird escaping", () => { + expect(stripJsonComments('{"a":"\\\\\\\\"/* comment */}')).toBe( + '{"a":"\\\\\\\\" }', + ); + }); + + it("handles new lines inside strings", () => { + expect(stripJsonComments('{"a":"\\\nfoo"}')).toBe('{"a":"\\\nfoo"}'); + }); + + it("handles CRLF line endings", () => { + expect(stripJsonComments('{\r\n\t// comment\r\n\t"a":"b"\r\n}')).toBe( + '{\r\n\t \r\n\t"a":"b"\r\n}', + ); + }); + + it("handles a lone carriage return terminating a line comment", () => { + expect(stripJsonComments('{\r\t// comment\r\t"a":"b"\r}')).toBe( + '{\r\t \r\t"a":"b"\r}', + ); + }); + + it("handles line comments that run to end of file with no trailing newline", () => { + expect(stripJsonComments('{"a":"b"}\n// comment')).toBe( + '{"a":"b"}\n ', + ); + }); + + it("removes trailing commas from arrays and objects with trailingCommas option", () => { + expect( + stripJsonComments('{"a":"b","c":"d",}', { trailingCommas: true }), + ).toBe('{"a":"b","c":"d" }'); + expect( + stripJsonComments('{"arr":[1,2,3,]}', { trailingCommas: true }), + ).toBe('{"arr":[1,2,3 ]}'); + }); + + it("removes trailing commas mixed with comments when trailingCommas is true", () => { + expect( + stripJsonComments('{"a":"b", // comment\n}', { trailingCommas: true }), + ).toBe('{"a":"b" \n}'); + }); + + it("does not remove trailing commas by default", () => { + const jsonWithTrailingComma = '{"a":"b",}'; + expect(stripJsonComments(jsonWithTrailingComma)).toBe( + jsonWithTrailingComma, + ); + }); + + it("throws / does not crash on malformed block comments and JSON.parse still rejects malformed input", () => { + const malformed = '{/* unterminated block comment\n"a":"b"}'; + const stripped = stripJsonComments(malformed); + expect(() => JSON.parse(stripped)).toThrow(); + }); + + it("supports the whitespace option (default true) preserving offsets", () => { + const input = '{\n\t// comment\n\t"a":"b"\n}'; + expect(stripJsonComments(input, { whitespace: true })).toBe( + '{\n\t \n\t"a":"b"\n}', + ); + }); + + it("supports whitespace: false to remove comments without preserving offsets", () => { + expect(stripJsonComments('{\n\t// comment\n\t"a":"b"\n}', { whitespace: false })).toBe( + '{\n\t\n\t"a":"b"\n}', + ); + }); + + it("does not strip non-breaking space, only regular whitespace/comments", () => { + const nbsp = "\u00A0"; + const input = `{"a":"b"${nbsp}}`; + expect(stripJsonComments(input)).toBe(input); + }); + + it("rejects malformed JSON even after stripping with trailingCommas enabled", () => { + const malformed = '{"a":"b",,}'; + const stripped = stripJsonComments(malformed, { trailingCommas: true }); + expect(() => JSON.parse(stripped)).toThrow(); + }); + + it("parses the project's DEFAULT_CONFIG_TEMPLATE after stripping comments and trailing commas", () => { + const stripped = stripJsonComments(DEFAULT_CONFIG_TEMPLATE, { + trailingCommas: true, + }); + expect(() => JSON.parse(stripped)).not.toThrow(); + }); +}); diff --git a/src/utils/strip-json-comments.ts b/src/utils/strip-json-comments.ts new file mode 100644 index 0000000..2ff3046 --- /dev/null +++ b/src/utils/strip-json-comments.ts @@ -0,0 +1,122 @@ +// Adapted from strip-json-comments v5.0.3 by Sindre Sorhus. +// Licensed under the MIT License. See THIRD_PARTY_NOTICES.md. + +const singleComment = Symbol("singleComment"); +const multiComment = Symbol("multiComment"); + +type Comment = typeof singleComment | typeof multiComment; +type Strip = (value: string, start: number, end?: number) => string; + +interface StripJsonCommentsOptions { + whitespace?: boolean; + trailingCommas?: boolean; +} + +const stripWithoutWhitespace: Strip = () => ""; + +// Preserve spaces, tabs, and line endings so JSON error positions remain useful. +const stripWithWhitespace: Strip = (value, start, end) => + value.slice(start, end).replace(/[^ \t\r\n]/g, " "); + +function isEscaped(jsonString: string, quotePosition: number): boolean { + let index = quotePosition - 1; + let backslashCount = 0; + + while (jsonString[index] === "\\") { + index -= 1; + backslashCount += 1; + } + + return Boolean(backslashCount % 2); +} + +export function stripJsonComments( + jsonString: string, + { whitespace = true, trailingCommas = false }: StripJsonCommentsOptions = {}, +): string { + if (typeof jsonString !== "string") { + throw new TypeError( + `Expected argument \`jsonString\` to be a \`string\`, got \`${typeof jsonString}\``, + ); + } + + const strip = whitespace ? stripWithWhitespace : stripWithoutWhitespace; + + let isInsideString = false; + let isInsideComment: Comment | false = false; + let offset = 0; + let buffer = ""; + let result = ""; + let commaIndex = -1; + + for (let index = 0; index < jsonString.length; index++) { + const currentCharacter = jsonString[index]; + const nextCharacter = jsonString[index + 1]; + + if (!isInsideComment && currentCharacter === '"') { + if (!isEscaped(jsonString, index)) { + isInsideString = !isInsideString; + } + } + + if (isInsideString) continue; + + if (!isInsideComment && currentCharacter + nextCharacter === "//") { + buffer += jsonString.slice(offset, index); + offset = index; + isInsideComment = singleComment; + index += 1; + } else if ( + isInsideComment === singleComment && + (currentCharacter === "\r" || currentCharacter === "\n") + ) { + isInsideComment = false; + buffer += strip(jsonString, offset, index); + offset = index; + } else if ( + !isInsideComment && + currentCharacter + nextCharacter === "/*" + ) { + buffer += jsonString.slice(offset, index); + offset = index; + isInsideComment = multiComment; + index += 1; + continue; + } else if ( + isInsideComment === multiComment && + currentCharacter + nextCharacter === "*/" + ) { + index += 1; + isInsideComment = false; + buffer += strip(jsonString, offset, index + 1); + offset = index + 1; + continue; + } else if (trailingCommas && !isInsideComment) { + if (commaIndex !== -1) { + if (currentCharacter === "}" || currentCharacter === "]") { + buffer += jsonString.slice(offset, index); + result += strip(buffer, 0, 1) + buffer.slice(1); + buffer = ""; + offset = index; + commaIndex = -1; + } else if (!/[ \t\r\n]/.test(currentCharacter)) { + buffer += jsonString.slice(offset, index); + offset = index; + commaIndex = -1; + } + } else if (currentCharacter === ",") { + result += buffer + jsonString.slice(offset, index); + buffer = ""; + offset = index; + commaIndex = index; + } + } + } + + const remaining = + isInsideComment === singleComment + ? strip(jsonString, offset) + : jsonString.slice(offset); + + return result + buffer + remaining; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 1b85935..50424be 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ format: ["esm"], minify: true, dts: false, + deps: { + alwaysBundle: ["valibot"], + onlyBundle: ["valibot"], + }, outDir: "./dist", outExtensions: () => ({ js: ".js" }), }); From 16172cea1b8b7c66f20a229a7ecb3947790910bb Mon Sep 17 00:00:00 2001 From: Erik Verduin Date: Fri, 14 Aug 2026 15:30:14 -0500 Subject: [PATCH 2/3] chore: address comments --- THIRD_PARTY_NOTICES.md | 31 +++++++++++ src/prompt-cache.test.ts | 91 ++++++++++++++++++++++++++++++++ src/prompt-cache.ts | 7 ++- src/utils.ts | 4 +- src/utils/strip-json-comments.ts | 7 +-- 5 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 src/prompt-cache.test.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..d1b0fb1 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,31 @@ +# Third-party notices + +better-commits adapts (strip-json-comments) or bundles (valibot) into its executable directly. + +## Valibot + +Valibot 1.3.1: https://github.com/open-circle/valibot + +Copyright (c) Fabian Hiller + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## strip-json-comments + +Adapted from strip-json-comments 5.0.3: https://github.com/sindresorhus/strip-json-comments + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/prompt-cache.test.ts b/src/prompt-cache.test.ts new file mode 100644 index 0000000..0db4d38 --- /dev/null +++ b/src/prompt-cache.test.ts @@ -0,0 +1,91 @@ +import fs from "fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("fs", () => ({ + default: { + readFileSync: mocked.readFileSync, + writeFileSync: mocked.writeFileSync, + mkdirSync: mocked.mkdirSync, + }, +})); + +describe("FilePromptCache", () => { + beforeEach(() => { + mocked.readFileSync.mockReset(); + mocked.writeFileSync.mockReset(); + mocked.mkdirSync.mockReset(); + }); + + it("returns the cached value for a key", async () => { + mocked.readFileSync.mockReturnValue(JSON.stringify({ foo: "bar" })); + + const { FilePromptCache } = await import("./prompt-cache"); + const cache = new FilePromptCache(); + + expect(cache.get("foo")).toBe("bar"); + }); + + it("returns undefined and resets the cache file when JSON is not read", async () => { + const error = new Error("no such file"); + (error as NodeJS.ErrnoException).code = "ENOENT"; + mocked.readFileSync.mockImplementation(() => { + throw error; + }); + + const { FilePromptCache } = await import("./prompt-cache"); + const cache = new FilePromptCache(); + + expect(cache.get("foo")).toBeUndefined(); + expect(mocked.writeFileSync).not.toHaveBeenCalled(); + }); + + it("resets the cache when the file contains invalid JSON", async () => { + mocked.readFileSync.mockReturnValue("not json"); + + const { FilePromptCache } = await import("./prompt-cache"); + const cache = new FilePromptCache(); + + expect(cache.get("foo")).toBeUndefined(); + expect(mocked.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify({}, null, "\t"), + { mode: 0o600 }, + ); + }); + + it.each([["null", "null"], ["an array", "[1,2,3]"], ["a string", '"oops"'], ["a number", "42"]])( + "resets the cache when the parsed JSON is %s", + async (_label, raw) => { + mocked.readFileSync.mockReturnValue(raw); + + const { FilePromptCache } = await import("./prompt-cache"); + const cache = new FilePromptCache(); + + expect(cache.get("foo")).toBeUndefined(); + expect(mocked.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify({}, null, "\t"), + { mode: 0o600 }, + ); + }, + ); + + it("rethrows unexpected read errors", async () => { + const error = new Error("permission denied"); + (error as NodeJS.ErrnoException).code = "EACCES"; + mocked.readFileSync.mockImplementation(() => { + throw error; + }); + + const { FilePromptCache } = await import("./prompt-cache"); + const cache = new FilePromptCache(); + + expect(() => cache.get("foo")).toThrow("permission denied"); + }); +}); diff --git a/src/prompt-cache.ts b/src/prompt-cache.ts index 9924212..20bc5ad 100644 --- a/src/prompt-cache.ts +++ b/src/prompt-cache.ts @@ -29,7 +29,12 @@ export class FilePromptCache implements PromptCache { private read(): Record { try { - return JSON.parse(fs.readFileSync(this.path, "utf8")); + const parsed = JSON.parse(fs.readFileSync(this.path, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + this.write({}); + return {}; + } + return parsed; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; if (error instanceof SyntaxError) { diff --git a/src/utils.ts b/src/utils.ts index fec3970..b334d88 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -218,7 +218,7 @@ export function get_value_from_cache( return config_store.get(key) ?? ""; } catch (err) { p.log.warn( - `Could not access ${key} from cache. Check that "~/.config" exists. Set "cache_last_value" to false to disable.`, + `Could not access ${key} from cache. Check that the cache directory (defaults to "~/.config", or "$XDG_CONFIG_HOME" if set) exists. Set "cache_last_value" to false to disable.`, ); } @@ -234,7 +234,7 @@ export function set_value_cache( config_store.set(key, value); } catch (err) { p.log.warn( - `Could not access ${key} from cache. Check that "~/.config" exists. Set "cache_last_value" to false to disable.`, + `Could not access ${key} from cache. Check that the cache directory (defaults to "~/.config", or "$XDG_CONFIG_HOME" if set) exists. Set "cache_last_value" to false to disable.`, ); } } diff --git a/src/utils/strip-json-comments.ts b/src/utils/strip-json-comments.ts index 2ff3046..2e8d39d 100644 --- a/src/utils/strip-json-comments.ts +++ b/src/utils/strip-json-comments.ts @@ -1,5 +1,5 @@ // Adapted from strip-json-comments v5.0.3 by Sindre Sorhus. -// Licensed under the MIT License. See THIRD_PARTY_NOTICES.md. +// Licensed under the MIT License const singleComment = Symbol("singleComment"); const multiComment = Symbol("multiComment"); @@ -73,10 +73,7 @@ export function stripJsonComments( isInsideComment = false; buffer += strip(jsonString, offset, index); offset = index; - } else if ( - !isInsideComment && - currentCharacter + nextCharacter === "/*" - ) { + } else if (!isInsideComment && currentCharacter + nextCharacter === "/*") { buffer += jsonString.slice(offset, index); offset = index; isInsideComment = multiComment; From fa01884bd9e1ab807db2634cb4e2e8751ba64d12 Mon Sep 17 00:00:00 2001 From: Erik Verduin Date: Fri, 14 Aug 2026 15:44:22 -0500 Subject: [PATCH 3/3] docs: update readme --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index b7cd5f5..cb7841b 100644 --- a/readme.md +++ b/readme.md @@ -25,6 +25,7 @@ A CLI for writing better commits, following the conventional commits specificati - Support for git emojis per commit-type - Configure globally or per repository - Config validation and error messaging +- Scriptable and works with agents via CLI flags - [Lightweight](https://bundlejs.com/?q=better-commits&treeshake=%5B*%5D) (34kb) As a side-effect of formatting messages