Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CliCommand["type"]>(["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<Error, void> => {
console.error(color.red(err.message));
showHelp();
return Future.reject(err);
},
(command): Future<Error, void> => {
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":
Expand All @@ -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)
);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rafaeelricco/commit-tools",
"version": "0.2.5",
"version": "0.2.6",
"type": "module",
"bin": {
"commit": "./dist/index.js"
Expand Down
4 changes: 1 addition & 3 deletions src/cli/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Expand Down
27 changes: 13 additions & 14 deletions src/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,33 @@ type CliCommand =
| { type: "doctor" }
| { type: "model" }
| { type: "effort" }
| { type: "update" }
| { type: "version" }
| { type: "help" };

const cliCommandDecoder: D.Decoder<CliCommand> = 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}`);
}
Expand All @@ -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);
19 changes: 19 additions & 0 deletions src/cli/show-update-banner.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
};
82 changes: 82 additions & 0 deletions src/cli/update.ts
Original file line number Diff line number Diff line change
@@ -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<Error, PackageManager> => {
const versionCommand = yarnVersionCommand();
return execBin(versionCommand.cmd, versionCommand.args)
.chain((result) =>
result.either(
(): Future<Error, PackageManager> => Future.reject(unsupportedYarnError()),
({ stdout }): Future<Error, PackageManager> => {
const major = parseMajorVersion(stdout);
return major === 1 ? Future.resolve(yarnPackageManager) : Future.reject(unsupportedYarnError());
}
)
)
.chainRej((): Future<Error, PackageManager> => Future.reject(unsupportedYarnError()));
};

const detectPackageManager = (binPath: string): Future<Error, PackageManager> => {
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<Error, void> {
return detectPackageManager(process.argv[1] ?? "")
.chainRej((err): Future<Error, PackageManager> => {
p.note(err.message, "Yarn global installs unsupported");
return Future.reject(err);
})
.chain((pm): Future<Error, void> => {
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<Error, void> => {
p.log.error(color.red(err.message));
return Future.reject(err);
});
});
}
}
20 changes: 15 additions & 5 deletions src/infra/shell.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -8,13 +8,13 @@ type CommandOutput = Readonly<{ stdout: string; stderr: string }>;
type CommandFailure = Readonly<{ output: CommandOutput; error: Error }>;
type ExecResult = Result<CommandFailure, CommandOutput>;

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<CommandFailure, CommandOutput>(output)
: Failure<CommandFailure, CommandOutput>({
output,
error: new Error(`Command failed with exit code ${exitCode}${signal ? ` and signal ${signal}` : ""}`)
});
: Failure<CommandFailure, CommandOutput>({ output, error: exitCodeError(exitCode, signal) });

const execBin = (bin: string, args: string[]): Future<Error, ExecResult> =>
Future.create<Error, ExecResult>((reject, resolve) => {
Expand All @@ -31,3 +31,13 @@ const execBin = (bin: string, args: string[]): Future<Error, ExecResult> =>

return () => proc.kill();
});

const execBinInteractive = (bin: string, args: string[]): Future<Error, void> =>
Future.create<Error, void>((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();
});
16 changes: 16 additions & 0 deletions src/infra/ui/update-banner.ts
Original file line number Diff line number Diff line change
@@ -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");
};
69 changes: 69 additions & 0 deletions src/infra/version-check.ts
Original file line number Diff line number Diff line change
@@ -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<CachedCheck> => {
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<string> => {
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);
};