diff --git a/index.ts b/index.ts index 9e38aed..61b0b16 100755 --- a/index.ts +++ b/index.ts @@ -3,24 +3,30 @@ import { Setup } from "@/cli/setup"; import { Doctor } from "@/cli/doctor"; import { ModelCommand } from "@/cli/model"; import { EffortCommand } from "@/cli/effort"; -import { parseArgs, showHelp, showVersion } from "@/cli/parser"; +import { Update } from "@/cli/update"; +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); - const actionFuture = parseArgs(args).either( + const action = parseArgs(args).either( (err): Future => { console.error(color.red(err.message)); - showHelp(); return Future.reject(err); }, (command): Future => { + if (NOTIFIER_COMMANDS.has(command.type)) checkUpdate(); + 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 +35,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/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" 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; }); } diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 67f1fe8..a2e8e75 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -11,30 +11,33 @@ type CliCommand = | { type: "doctor" } | { type: "model" } | { type: "effort" } + | { type: "update" } | { type: "version" } | { 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 "update": + return D.succeed({ type: "update" }); 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}`); } @@ -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..b513b8b --- /dev/null +++ b/src/cli/update.ts @@ -0,0 +1,82 @@ +export { Update }; + +import * as p from "@clack/prompts"; +import color from "picocolors"; + +import { Future } from "@/libs/future"; +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 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 Future.resolve(pnpmPackageManager); + if (isYarnPath(path)) return resolveYarnPackageManager(); + return Future.resolve(npmPackageManager); +}; + +class Update { + private constructor() {} + + static create(): Update { + return new Update(); + } + + run(): Future { + 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}`)) + .chainRej((err): Future => { + p.log.error(color.red(err.message)); + return Future.reject(err); + }); + }); + } +} 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..6c1834f --- /dev/null +++ b/src/infra/version-check.ts @@ -0,0 +1,69 @@ +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 => { 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 { + 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); +};