From aea022ff8df03455849a7bed118ddb8480458201 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 17:36:17 -0300 Subject: [PATCH 1/8] Show CLI help when no command is provided --- src/cli/parser.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 67f1fe8..5384309 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -15,26 +15,26 @@ type CliCommand = | { type: "help" }; const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) => { - const cmd = args[0] || "generate"; + const cmd = args[0] || "-h"; switch (cmd) { case "generate": - return D.succeed({ type: "generate" as const }); + return D.succeed({ type: "generate" }); case "setup": case "login": - return D.succeed({ type: "setup" as const }); + return D.succeed({ type: "setup" }); case "doctor": - return D.succeed({ type: "doctor" as const }); + return D.succeed({ type: "doctor" }); case "model": - return D.succeed({ type: "model" as const }); + return D.succeed({ type: "model" }); case "effort": - return D.succeed({ type: "effort" as const }); + return D.succeed({ type: "effort" }); case "--version": case "-v": - return D.succeed({ type: "version" as const }); + return D.succeed({ type: "version" }); case "--help": case "-h": - return D.succeed({ type: "help" as const }); + return D.succeed({ type: "help" }); default: return D.fail(`Unknown command: ${cmd}`); } From 060003305e97eacb7bcb77b9fb3074ecedcd9012 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 17:57:52 -0300 Subject: [PATCH 2/8] Always log commit failure messages --- src/cli/commit.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cli/commit.ts b/src/cli/commit.ts index 6cdbdca..9d09f88 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -47,9 +47,7 @@ class Commit { .chain(() => this.diff()) .chain((diff) => this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => this.interact(diff, message))) .mapRej((e) => { - if (e instanceof Error) { - p.log.error(color.red(e.message)); - } + p.log.error(color.red(e.message)); return e; }); } From 8c20dadd221af4c6fa65f29f14520d086f3e4227 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 18:23:34 -0300 Subject: [PATCH 3/8] Add update command and update availability banner - Add `commit update` to install the latest package version with the detected package manager. - Check cached npm registry metadata on startup and show an update banner when a newer version is available. - Suppress update checks in CI, non-interactive output, or when `NO_UPDATE_NOTIFIER=true`. - Add interactive shell execution for update installs and simplify `--version` output. --- index.ts | 20 ++++++----- src/cli/parser.ts | 11 +++--- src/cli/show-update-banner.ts | 19 ++++++++++ src/cli/update.ts | 33 +++++++++++++++++ src/infra/shell.ts | 20 ++++++++--- src/infra/ui/update-banner.ts | 16 +++++++++ src/infra/version-check.ts | 67 +++++++++++++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 src/cli/show-update-banner.ts create mode 100644 src/cli/update.ts create mode 100644 src/infra/ui/update-banner.ts create mode 100644 src/infra/version-check.ts diff --git a/index.ts b/index.ts index 9e38aed..bd9ae49 100755 --- a/index.ts +++ b/index.ts @@ -3,24 +3,28 @@ import { Setup } from "@/cli/setup"; import { Doctor } from "@/cli/doctor"; import { ModelCommand } from "@/cli/model"; import { EffortCommand } from "@/cli/effort"; +import { Update } from "@/cli/update"; import { parseArgs, showHelp, showVersion } from "@/cli/parser"; import { Future } from "@/libs/future"; +import { absurd } from "@/libs/types"; +import { checkUpdate } from "@/cli/show-update-banner"; import color from "picocolors"; const main = () => { const args = process.argv.slice(2); - const actionFuture = parseArgs(args).either( + checkUpdate(); + + const action = parseArgs(args).either( (err): Future => { console.error(color.red(err.message)); - showHelp(); return Future.reject(err); }, (command): Future => { switch (command.type) { case "generate": - return Commit.create().chain((flow) => flow.run()); + return Commit.create().chain((c) => c.run()); case "setup": return Setup.create().chain((s) => s.run()); case "doctor": @@ -29,21 +33,21 @@ const main = () => { return ModelCommand.create().chain((m) => m.run()); case "effort": return EffortCommand.create().chain((e) => e.run()); + case "update": + return Update.create().run(); case "version": showVersion(); return Future.resolve(undefined); case "help": showHelp(); return Future.resolve(undefined); - default: { - const _exhaustiveCheck: never = command; - return Future.reject(new Error(`Unhandled command: ${JSON.stringify(_exhaustiveCheck)}`)); - } + default: + absurd(command, `Unhandled command type: ${command}`); } } ); - actionFuture.fork( + action.fork( (_) => process.exit(1), () => process.exit(0) ); diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 5384309..a2e8e75 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -11,6 +11,7 @@ type CliCommand = | { type: "doctor" } | { type: "model" } | { type: "effort" } + | { type: "update" } | { type: "version" } | { type: "help" }; @@ -29,6 +30,8 @@ const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) return D.succeed({ type: "model" }); case "effort": return D.succeed({ type: "effort" }); + case "update": + return D.succeed({ type: "update" }); case "--version": case "-v": return D.succeed({ type: "version" }); @@ -53,14 +56,10 @@ Commands: doctor Check installation and environment model Select a different AI model effort Adjust the reasoning effort for the current model + update Install the latest version from npm --version, -v Show version --help, -h Show help `); }; -const showVersion = (): void => { - const start = performance.now(); - console.log(`commit-tools ${packageVersion} (node)`); - const elapsed = performance.now() - start; - console.log(`Done in ${elapsed.toLocaleString()}ms`); -}; +const showVersion = (): void => console.log(packageVersion); diff --git a/src/cli/show-update-banner.ts b/src/cli/show-update-banner.ts new file mode 100644 index 0000000..58aa190 --- /dev/null +++ b/src/cli/show-update-banner.ts @@ -0,0 +1,19 @@ +export { checkUpdate }; + +import { checkForUpdate, compareVersions } from "@/infra/version-check"; +import { renderUpdateBanner } from "@/infra/ui/update-banner"; +import { version } from "@/package.json"; + +const isOptedOut = (): boolean => process.env["NO_UPDATE_NOTIFIER"] === "true"; +const isCi = (): boolean => Boolean(process.env["CI"]); +const isNonInteractive = (): boolean => !process.stdout.isTTY; + +const shouldSuppress = (): boolean => isOptedOut() || isCi() || isNonInteractive(); + +const checkUpdate = (): void => { + if (shouldSuppress()) return; + const current = version; + checkForUpdate().maybe(undefined, (latest) => { + if (compareVersions(latest, current) > 0) renderUpdateBanner({ current, latest }); + }); +}; diff --git a/src/cli/update.ts b/src/cli/update.ts new file mode 100644 index 0000000..acfcbc6 --- /dev/null +++ b/src/cli/update.ts @@ -0,0 +1,33 @@ +export { Update }; + +import * as p from "@clack/prompts"; +import color from "picocolors"; + +import { Future } from "@/libs/future"; +import { execBinInteractive } from "@/infra/shell"; + +const PACKAGE_NAME = "@rafaeelricco/commit-tools"; + +type PackageManager = { name: "pnpm" | "yarn" | "npm"; cmd: string; args: string[] }; + +const detectPackageManager = (binPath: string): PackageManager => { + const path = binPath.toLowerCase(); + if (path.includes("pnpm")) return { name: "pnpm", cmd: "pnpm", args: ["add", "-g", `${PACKAGE_NAME}@latest`] }; + if (path.includes(".yarn") || path.includes("/yarn/") || path.includes("\\yarn\\")) + return { name: "yarn", cmd: "yarn", args: ["global", "add", `${PACKAGE_NAME}@latest`] }; + return { name: "npm", cmd: "npm", args: ["install", "-g", `${PACKAGE_NAME}@latest`] }; +}; + +class Update { + private constructor() {} + + static create(): Update { + return new Update(); + } + + run(): Future { + const pm = detectPackageManager(process.argv[1] ?? ""); + p.note(`Running: ${color.cyan(`${pm.cmd} ${pm.args.join(" ")}`)}`, "Updating commit-tools"); + return execBinInteractive(pm.cmd, pm.args).mapRej((err) => new Error(`Update failed: ${err.message}`)); + } +} diff --git a/src/infra/shell.ts b/src/infra/shell.ts index 012f305..c6c937d 100644 --- a/src/infra/shell.ts +++ b/src/infra/shell.ts @@ -1,4 +1,4 @@ -export { execBin, type CommandOutput, type CommandFailure, type ExecResult }; +export { execBin, execBinInteractive, type CommandOutput, type CommandFailure, type ExecResult }; import { Future } from "@/libs/future"; import { Failure, type Result, Success } from "@/libs/result"; @@ -8,13 +8,13 @@ type CommandOutput = Readonly<{ stdout: string; stderr: string }>; type CommandFailure = Readonly<{ output: CommandOutput; error: Error }>; type ExecResult = Result; +const exitCodeError = (exitCode: number | null, signal: NodeJS.Signals | null): Error => + new Error(`Command failed with exit code ${exitCode}${signal ? ` and signal ${signal}` : ""}`); + const commandResult = (output: CommandOutput, exitCode: number | null, signal: NodeJS.Signals | null): ExecResult => exitCode === 0 ? Success(output) - : Failure({ - output, - error: new Error(`Command failed with exit code ${exitCode}${signal ? ` and signal ${signal}` : ""}`) - }); + : Failure({ output, error: exitCodeError(exitCode, signal) }); const execBin = (bin: string, args: string[]): Future => Future.create((reject, resolve) => { @@ -31,3 +31,13 @@ const execBin = (bin: string, args: string[]): Future => return () => proc.kill(); }); + +const execBinInteractive = (bin: string, args: string[]): Future => + Future.create((reject, resolve) => { + const proc = spawn(bin, args, { stdio: "inherit", shell: process.platform === "win32" }); + + proc.on("error", (err) => reject(new Error(`Failed to start process: ${err.message}`))); + proc.on("close", (exitCode, signal) => (exitCode === 0 ? resolve(undefined) : reject(exitCodeError(exitCode, signal)))); + + return () => proc.kill(); + }); diff --git a/src/infra/ui/update-banner.ts b/src/infra/ui/update-banner.ts new file mode 100644 index 0000000..5928845 --- /dev/null +++ b/src/infra/ui/update-banner.ts @@ -0,0 +1,16 @@ +export { renderUpdateBanner }; + +import * as p from "@clack/prompts"; +import color from "picocolors"; + +type UpdateInfo = { current: string; latest: string }; + +const renderUpdateBanner = (info: UpdateInfo): void => { + const body = [ + `${color.dim(info.current)} ${color.dim("→")} ${color.green(info.latest)}`, + ``, + `Run ${color.cyan("commit update")} to install the latest version.`, + color.dim(`Changelog: https://npm.im/@rafaeelricco/commit-tools`) + ].join("\n"); + p.note(body, "Update available"); +}; diff --git a/src/infra/version-check.ts b/src/infra/version-check.ts new file mode 100644 index 0000000..0856d13 --- /dev/null +++ b/src/infra/version-check.ts @@ -0,0 +1,67 @@ +export { checkForUpdate, compareVersions }; + +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { CONFIG_DIR } from "@/infra/storage/config"; +import { Just, Nothing, type Maybe } from "@/libs/maybe"; + +const CACHE_FILE = resolve(CONFIG_DIR, "version-check.json"); +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const REGISTRY_URL = "https://registry.npmjs.org/@rafaeelricco/commit-tools/latest"; + +type CachedCheck = { checkedAt: number; latestVersion: string }; + +const loadCache = (): Maybe => { + try { + const raw = readFileSync(CACHE_FILE, "utf-8"); + const parsed = JSON.parse(raw); + if (typeof parsed?.checkedAt === "number" && typeof parsed?.latestVersion === "string") { + return Just({ checkedAt: parsed.checkedAt, latestVersion: parsed.latestVersion }); + } + return Nothing(); + } catch { + return Nothing(); + } +}; + +const compareVersions = (a: string, b: string): number => { + const parse = (v: string): number[] => (v.split("-")[0] ?? "").split(".").map((n) => parseInt(n, 10) || 0); + const pa = parse(a); + const pb = parse(b); + for (let i = 0; i < 3; i++) { + const da = pa[i] ?? 0; + const db = pb[i] ?? 0; + if (da > db) return 1; + if (da < db) return -1; + } + return 0; +}; + +const refreshCacheInBackground = (): void => { + const script = [ + `fetch(${JSON.stringify(REGISTRY_URL)})`, + `.then(r => r.ok ? r.json() : Promise.reject())`, + `.then(j => require("fs").writeFileSync(${JSON.stringify(CACHE_FILE)},`, + `JSON.stringify({ checkedAt: Date.now(), latestVersion: j.version })))`, + `.catch(() => {});` + ].join(""); + try { + const child = spawn(process.execPath, ["-e", script], { + detached: true, + stdio: "ignore", + windowsHide: true + }); + child.unref(); + child.on("error", () => {}); + } catch { + // best-effort; never let a refresh failure surface + } +}; + +const checkForUpdate = (): Maybe => { + const cached = loadCache(); + const isStale = cached.maybe(true, (c) => Date.now() - c.checkedAt > CHECK_INTERVAL_MS); + if (isStale) refreshCacheInBackground(); + return cached.map((c) => c.latestVersion); +}; From e891771708ebb5f15982d1c4a977c064fbc22826 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 18:29:20 -0300 Subject: [PATCH 4/8] Update package version to 0.2.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e23573e..30eba3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rafaeelricco/commit-tools", - "version": "0.2.5", + "version": "0.2.6", "type": "module", "bin": { "commit": "./dist/index.js" From 59b4f3dbf12563187a39f53dcd117b03daa4ad3f Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 18:35:11 -0300 Subject: [PATCH 5/8] Ensure version cache directory exists before writing --- src/infra/version-check.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/infra/version-check.ts b/src/infra/version-check.ts index 0856d13..6c1834f 100644 --- a/src/infra/version-check.ts +++ b/src/infra/version-check.ts @@ -42,8 +42,10 @@ const refreshCacheInBackground = (): void => { const script = [ `fetch(${JSON.stringify(REGISTRY_URL)})`, `.then(r => r.ok ? r.json() : Promise.reject())`, - `.then(j => require("fs").writeFileSync(${JSON.stringify(CACHE_FILE)},`, - `JSON.stringify({ checkedAt: Date.now(), latestVersion: j.version })))`, + `.then(j => { const fs = require("fs");`, + `fs.mkdirSync(${JSON.stringify(CONFIG_DIR)}, { recursive: true });`, + `fs.writeFileSync(${JSON.stringify(CACHE_FILE)},`, + `JSON.stringify({ checkedAt: Date.now(), latestVersion: j.version })); })`, `.catch(() => {});` ].join(""); try { From 4b07b19e7194d52fae53bb2d2b833befe6bb25af Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 18:42:22 -0300 Subject: [PATCH 6/8] Reject unsupported modern Yarn global updates - Check the Yarn major version before selecting the Yarn global update command. - Allow Yarn 1 global installs and reject modern Yarn with npm or pnpm install guidance. - Preserve pnpm and npm update commands and existing interactive execution flow. --- src/cli/update.ts | 62 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/src/cli/update.ts b/src/cli/update.ts index acfcbc6..88908af 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -4,18 +4,56 @@ import * as p from "@clack/prompts"; import color from "picocolors"; import { Future } from "@/libs/future"; -import { execBinInteractive } from "@/infra/shell"; +import { execBin, execBinInteractive } from "@/infra/shell"; const PACKAGE_NAME = "@rafaeelricco/commit-tools"; type PackageManager = { name: "pnpm" | "yarn" | "npm"; cmd: string; args: string[] }; +type ShellCommand = { cmd: string; args: string[] }; -const detectPackageManager = (binPath: string): PackageManager => { +const updateCommand = `${PACKAGE_NAME}@latest`; +const pnpmPackageManager: PackageManager = { name: "pnpm", cmd: "pnpm", args: ["add", "-g", updateCommand] }; +const yarnPackageManager: PackageManager = { name: "yarn", cmd: "yarn", args: ["global", "add", updateCommand] }; +const npmPackageManager: PackageManager = { name: "npm", cmd: "npm", args: ["install", "-g", updateCommand] }; + +const isYarnPath = (path: string): boolean => path.includes(".yarn") || path.includes("/yarn/") || path.includes("\\yarn\\"); + +const yarnVersionCommand = (): ShellCommand => + process.platform === "win32" ? { cmd: "cmd", args: ["/d", "/s", "/c", "yarn --version"] } : { cmd: "yarn", args: ["--version"] }; + +const parseMajorVersion = (version: string): number | undefined => { + const major = version.trim().match(/^(\d+)\./)?.[1]; + return major ? Number(major) : undefined; +}; + +const unsupportedYarnError = (): Error => + new Error( + [ + "Modern Yarn does not support global installs.", + `Install the latest commit-tools with ${color.cyan(`npm install -g ${updateCommand}`)} or ${color.cyan(`pnpm add -g ${updateCommand}`)}.` + ].join("\n") + ); + +const resolveYarnPackageManager = (): Future => { + const versionCommand = yarnVersionCommand(); + return execBin(versionCommand.cmd, versionCommand.args) + .chain((result) => + result.either( + (): Future => Future.reject(unsupportedYarnError()), + ({ stdout }): Future => { + const major = parseMajorVersion(stdout); + return major === 1 ? Future.resolve(yarnPackageManager) : Future.reject(unsupportedYarnError()); + } + ) + ) + .chainRej((): Future => Future.reject(unsupportedYarnError())); +}; + +const detectPackageManager = (binPath: string): Future => { const path = binPath.toLowerCase(); - if (path.includes("pnpm")) return { name: "pnpm", cmd: "pnpm", args: ["add", "-g", `${PACKAGE_NAME}@latest`] }; - if (path.includes(".yarn") || path.includes("/yarn/") || path.includes("\\yarn\\")) - return { name: "yarn", cmd: "yarn", args: ["global", "add", `${PACKAGE_NAME}@latest`] }; - return { name: "npm", cmd: "npm", args: ["install", "-g", `${PACKAGE_NAME}@latest`] }; + if (path.includes("pnpm")) return Future.resolve(pnpmPackageManager); + if (isYarnPath(path)) return resolveYarnPackageManager(); + return Future.resolve(npmPackageManager); }; class Update { @@ -26,8 +64,14 @@ class Update { } run(): Future { - const pm = detectPackageManager(process.argv[1] ?? ""); - p.note(`Running: ${color.cyan(`${pm.cmd} ${pm.args.join(" ")}`)}`, "Updating commit-tools"); - return execBinInteractive(pm.cmd, pm.args).mapRej((err) => new Error(`Update failed: ${err.message}`)); + return detectPackageManager(process.argv[1] ?? "") + .chainRej((err): Future => { + p.note(err.message, "Yarn global installs unsupported"); + return Future.reject(err); + }) + .chain((pm): Future => { + p.note(`Running: ${color.cyan(`${pm.cmd} ${pm.args.join(" ")}`)}`, "Updating commit-tools"); + return execBinInteractive(pm.cmd, pm.args).mapRej((err) => new Error(`Update failed: ${err.message}`)); + }); } } From ffb594576c36064188730a2cfda80cad0d98ea90 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sun, 26 Apr 2026 11:55:13 -0300 Subject: [PATCH 7/8] Log CLI update failures before rejecting --- src/cli/update.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cli/update.ts b/src/cli/update.ts index 88908af..b513b8b 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -71,7 +71,12 @@ class Update { }) .chain((pm): Future => { p.note(`Running: ${color.cyan(`${pm.cmd} ${pm.args.join(" ")}`)}`, "Updating commit-tools"); - return execBinInteractive(pm.cmd, pm.args).mapRej((err) => new Error(`Update failed: ${err.message}`)); + return execBinInteractive(pm.cmd, pm.args) + .mapRej((err) => new Error(`Update failed: ${err.message}`)) + .chainRej((err): Future => { + p.log.error(color.red(err.message)); + return Future.reject(err); + }); }); } } From 0522b35f641d7655423c63147bf5b56c341235d2 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sun, 26 Apr 2026 12:07:53 -0300 Subject: [PATCH 8/8] Run update checks only for notifier commands --- index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index bd9ae49..61b0b16 100755 --- a/index.ts +++ b/index.ts @@ -4,24 +4,26 @@ import { Doctor } from "@/cli/doctor"; import { ModelCommand } from "@/cli/model"; import { EffortCommand } from "@/cli/effort"; import { Update } from "@/cli/update"; -import { parseArgs, showHelp, showVersion } from "@/cli/parser"; +import { type CliCommand, parseArgs, showHelp, showVersion } from "@/cli/parser"; import { Future } from "@/libs/future"; import { absurd } from "@/libs/types"; import { checkUpdate } from "@/cli/show-update-banner"; import color from "picocolors"; +const NOTIFIER_COMMANDS = new Set(["generate", "setup", "doctor", "model", "effort"]); + const main = () => { const args = process.argv.slice(2); - checkUpdate(); - const action = parseArgs(args).either( (err): Future => { console.error(color.red(err.message)); return Future.reject(err); }, (command): Future => { + if (NOTIFIER_COMMANDS.has(command.type)) checkUpdate(); + switch (command.type) { case "generate": return Commit.create().chain((c) => c.run());