From 48ab18068f272067161c8d4761c2876c36d9d9a1 Mon Sep 17 00:00:00 2001 From: JFusco Date: Thu, 2 Apr 2026 08:13:25 -0400 Subject: [PATCH] fix(lib): Update README and CLI for improved clarity The README and CLI documentation have been revised to enhance clarity regarding the setup process. The instructions now specify that the setup should be performed from the app directory where `@verndale/ai-commit` is installed, rather than the git repository root. This change aims to prevent confusion, especially in monorepo setups. Additionally, the CLI command descriptions have been updated to better reflect the behavior of the `init` command and its flags. These improvements will help users understand the expected outcomes and the structure of the environment files more clearly. No issue references detected. --- README.md | 29 ++++++++++------- bin/cli.js | 76 ++++++++++++++++++++++++++++++++----------- lib/core/git.js | 50 ++++++++++++++++++++++++++++ lib/init-env.js | 15 ++++++--- lib/init-paths.js | 65 ++++++++++++++++++++++++++++++++++++ lib/init-workspace.js | 13 ++++++-- 6 files changed, 211 insertions(+), 37 deletions(-) create mode 100644 lib/init-paths.js diff --git a/README.md b/README.md index 31b55e6..c4ab47d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ pnpm add -D @verndale/ai-commit ## Setup -Do these **in order** from your **git repository root** (the directory that contains `package.json`). +Do these **in order** from the **app directory** where **`@verndale/ai-commit`** is installed — the folder that contains **`package.json`** (in a monorepo, that is often **not** the git repository root). Init updates **`.env`** / the example file there and installs hooks at the **git root** (with a `cd` in hook scripts when those paths differ). ### 1. Install the package @@ -41,10 +41,13 @@ pnpm exec ai-commit init | Action | Detail | | --- | --- | -| Env files | Merges **`.env`** and **`.env-example`**; creates **`.env-example`** from the bundled template if missing. Template reference: [`.env-example`](.env-example). | -| Husky | Runs **`npx husky@9 init`** if Husky is not present. | -| `package.json` | Adds missing **`commit`**, **`prepare`**, **`husky`** entries when the file exists. | -| Hooks | Writes **`.husky`** hook files. | +| Roots | **Package root** — walks up from the current directory toward the git root and uses the first directory that has **`package.json`** (if none, uses cwd). **Git root** — `git rev-parse --show-toplevel`. Env files and **`package.json`** use the package root; hooks use the git root. | +| Env files | Merges **`.env`** and the **example env file** (see below). Keys already set in **`.env.local`** are treated as satisfied for the **`.env`** merge only (same as runtime load order). Init does not write **`.env.local`**. | +| Example file | Uses **`.env.example`** if it exists; else **`.env-example`** if it exists; else creates **`.env.example`**. If both dot forms exist, init uses **`.env.example`** and prints a warning. The **bundled** template in the package remains [`.env-example`](.env-example) (hyphen). | +| Husky | Runs **`npx husky@9 init`** at the **git root** if **`husky.sh`** is missing under the resolved hooks directory. | +| Hooks directory | **`core.hooksPath`** relative to the git root when set; otherwise **`/.husky`**. Falls back to **`.husky`** at the git root with a warning if the config path is invalid or outside the repo. | +| `package.json` | Adds missing **`commit`**, **`prepare`**, **`husky`** entries when **`package.json`** exists at the package root. | +| Hooks | Writes **`prepare-commit-msg`** and **`commit-msg`** in the hooks directory. If package root ≠ git root, each hook **`cd`s** into the package directory before **`pnpm exec ai-commit`** / **`npx`**. | If **`package.json`** changed, run **`pnpm install`** (or `npm install`) again. @@ -59,17 +62,19 @@ Set **`OPENAI_API_KEY`** in **`.env`** and/or **`.env.local`**. Duplicate keys: | Flag | Use when | | --- | --- | | *(none)* | Full setup: env files + Husky + hooks + `package.json` updates (when applicable). | -| `--env-only` | You only want env / **`.env-example`** updates—no Git hooks. | +| `--env-only` | You only want env / example-file updates—no Git hooks. | | `--husky` | Hooks + Husky only; skips **`package.json`** changes. Combine with **`--workspace`** if you need **`package.json`** merged again. | -| `--force` | Replace **`.env`** and **`.env-example`** with the bundled template **(destructive)** and/or overwrite existing Husky hook files. | +| `--force` | Replace **`.env`** and the resolved example file (see table above) with the bundled template **(destructive)** and/or overwrite existing Husky hook files. | **Edge cases** | Situation | Behavior | | --- | --- | -| Not in a git repo | Init updates env files only and reports that Git/Husky were skipped. | -| Template filename | The published file is **`.env-example`** (hyphen), not **`.env.example`**. | -| Without **`--force`** | Missing **`.env-example`** is created; otherwise missing ai-commit keys are **appended** to **`.env`** (and the example file) without wiping the file. | +| Not in a git repo | Init updates env files only (under cwd) and reports that Git/Husky were skipped. | +| Monorepo (package not at git root) | Run **`ai-commit init`** from the app folder that has **`package.json`** and the dependency. Hooks live at the repo root; hook scripts change into the package directory before running **`ai-commit`**. | +| **`.env.local`** | **`OPENAI_API_KEY`** / **`COMMIT_AI_MODEL`** there count as already present when merging **`.env`**, so init will not add duplicate placeholders for those keys. | +| Bundled vs consumer example name | The npm package ships **`.env-example`** (hyphen) as the template source; the file init merges into on disk follows **`.env.example`** first, then **`.env-example`**, then default **`.env.example`**. | +| Without **`--force`** | Missing example file is created (**`.env.example`** when neither exists); otherwise missing ai-commit keys are **appended** to **`.env`** and the example file without wiping them. | --- @@ -124,7 +129,7 @@ pnpm exec ai-commit init --force | Command | Purpose | | --- | --- | | **`ai-commit run`** | Build a message from the staged diff and run **`git commit`**. | -| **`ai-commit init`** | Env merge (including **`.env-example`**), Husky if needed, **`package.json`** when present, hooks. See [flags](#init-flags-and-shortcuts). | +| **`ai-commit init`** | Env merge (**`.env`** + resolved example file; **`.env.local`** satisfies keys for the **`.env`** merge only), Husky at git root if needed, **`package.json`** at package root, hooks in **`core.hooksPath`** or **`.husky`**. See [flags](#init-flags-and-shortcuts). | | **`ai-commit prepare-commit-msg [source]`** | Hook: fill an empty message; skips `merge` / `squash`. | | **`ai-commit lint --edit `** | Hook: commitlint with this package’s default config. | @@ -164,7 +169,7 @@ pnpm exec ai-commit prepare-commit-msg "$1" "$2" pnpm exec ai-commit lint --edit "$1" ``` -Hooks from **`init`** use **`pnpm exec ai-commit`** when **`pnpm-lock.yaml`** exists; otherwise **`npx --no ai-commit`**. Edit the files if you use another runner. +Hooks from **`init`** use **`pnpm exec ai-commit`** when **`pnpm-lock.yaml`** exists in the **package root**; otherwise **`npx --no ai-commit`**. In a monorepo, generated hooks **`cd`** from the git root into that package directory first. Edit the files if you use another runner. **Already using Husky?** If **`.husky/_/husky.sh`** exists, **`init`** does not run **`npx husky@9 init`**. **`package.json`** is only amended for missing **`commit`**, **`prepare`**, or **`devDependencies.husky`**. Existing **`.husky/prepare-commit-msg`** and **`.husky/commit-msg`** are not overwritten unless you use **`ai-commit init --force`**. diff --git a/bin/cli.js b/bin/cli.js index 2e49738..def5551 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -11,10 +11,13 @@ const { generateAndValidate } = require("../lib/core/generate.js"); const { assertInGitRepo, isInGitRepo, + getGitRoot, + resolveGitHooksDir, hasStagedChanges, commitFromFile, } = require("../lib/core/git.js"); -const { mergeAiCommitEnvFile } = require("../lib/init-env.js"); +const { mergeAiCommitEnvFile, parseDotenvAssignedKeys } = require("../lib/init-env.js"); +const { resolveEnvExamplePath, findPackageRoot } = require("../lib/init-paths.js"); const { detectPackageExec, hookScript, @@ -42,7 +45,7 @@ Usage: Commands: run Generate a message from the staged diff and run git commit. - init Merge env, then Husky + package.json + hooks (from a git repo). \`--env-only\` stops after env files. \`--husky\` skips package.json. \`--force\` replaces \`.env\` / \`.env-example\` / hooks. + init Merge env, then Husky + package.json + hooks (from a git repo). \`--env-only\` stops after env files. \`--husky\` skips package.json. \`--force\` replaces \`.env\` / example env file / hooks (example path: existing \`.env.example\` or \`.env-example\`, default \`.env.example\`). prepare-commit-msg Git hook: fill an empty commit message file (merge/squash skipped). lint Run commitlint with the package default config (for commit-msg hook). @@ -86,14 +89,40 @@ function cmdInit(argv) { const cwd = process.cwd(); /** Full package.json merge: default on, or `--workspace`; off for `--husky` alone (legacy). */ const mergePackageJson = !husky || workspace; - const examplePath = path.join(__dirname, "..", ".env-example"); + const bundledExamplePath = path.join(__dirname, "..", ".env-example"); - if (!fs.existsSync(examplePath)) { + if (!fs.existsSync(bundledExamplePath)) { throw new Error("Missing bundled .env-example (corrupt install?)."); } - const envDest = path.join(cwd, ".env"); - const envResult = mergeAiCommitEnvFile(envDest, examplePath, { force }); + const inGit = isInGitRepo(cwd); + const gitRoot = inGit ? getGitRoot(cwd) : null; + const packageRoot = findPackageRoot(cwd, gitRoot); + + const extraAssignedKeys = new Set(); + const envLocalPath = path.join(packageRoot, ".env.local"); + if (fs.existsSync(envLocalPath)) { + const localContent = fs.readFileSync(envLocalPath, "utf8"); + for (const k of parseDotenvAssignedKeys(localContent)) { + extraAssignedKeys.add(k); + } + } + + if ( + inGit && + gitRoot && + path.resolve(packageRoot) !== path.resolve(gitRoot) + ) { + process.stdout.write( + `Note: env files are updated under ${packageRoot}; Git hooks use the repository root ${gitRoot}.\n`, + ); + } + + const envDest = path.join(packageRoot, ".env"); + const envResult = mergeAiCommitEnvFile(envDest, bundledExamplePath, { + force, + extraAssignedKeys, + }); const envRel = path.relative(cwd, envDest) || ".env"; switch (envResult.kind) { case "replaced": @@ -114,9 +143,9 @@ function cmdInit(argv) { break; } - const envExampleDest = path.join(cwd, ".env-example"); - const exResult = mergeAiCommitEnvFile(envExampleDest, examplePath, { force }); - const exRel = path.relative(cwd, envExampleDest) || ".env-example"; + const envExampleDest = resolveEnvExamplePath(packageRoot); + const exResult = mergeAiCommitEnvFile(envExampleDest, bundledExamplePath, { force }); + const exRel = path.relative(cwd, envExampleDest) || path.basename(envExampleDest); switch (exResult.kind) { case "replaced": process.stdout.write(`Replaced ${exRel} with bundled template (--force).\n`); @@ -140,17 +169,24 @@ function cmdInit(argv) { return; } - if (!isInGitRepo(cwd)) { + if (!inGit) { process.stdout.write( - "Not a git repository (or git unavailable); skipped Husky and package.json. Re-run from a repo root for hooks and scripts.\n", + "Not a git repository (or git unavailable); skipped Husky and package.json hooks. Run init from your app directory inside a git repo (with package.json there) for full setup.\n", + ); + return; + } + if (!gitRoot) { + process.stderr.write( + "warning: could not resolve git repository root; skipped Husky and hooks.\n", ); return; } - const huskyHelper = path.join(cwd, ".husky", "_", "husky.sh"); + let { dir: huskyDir } = resolveGitHooksDir(gitRoot); + const huskyHelper = path.join(huskyDir, "_", "husky.sh"); if (!fs.existsSync(huskyHelper)) { - const r = runHuskyInit(cwd); + const r = runHuskyInit(gitRoot); if (!r.ok) { process.stderr.write( r.error @@ -160,14 +196,15 @@ function cmdInit(argv) { process.exit(1); } process.stdout.write("Ran `npx husky@9 init`.\n"); + huskyDir = resolveGitHooksDir(gitRoot).dir; } else { process.stdout.write( - "Husky already initialized (found .husky/_/husky.sh); skipped `npx husky@9 init`.\n", + `Husky already initialized (found ${path.join(huskyDir, "_", "husky.sh")}); skipped \`npx husky@9 init\`.\n`, ); } if (mergePackageJson) { - const pkgPath = path.join(cwd, "package.json"); + const pkgPath = path.join(packageRoot, "package.json"); if (fs.existsSync(pkgPath)) { const { changed } = mergePackageJsonForAiCommit(pkgPath); if (changed) { @@ -177,16 +214,17 @@ function cmdInit(argv) { } warnIfPrepareMissingHusky(pkgPath); } else { - process.stdout.write("No package.json in this directory; skipped package.json merge (hooks still written).\n"); + process.stdout.write( + "No package.json found walking up to the git root; skipped package.json merge (hooks still written).\n", + ); } } - const huskyDir = path.join(cwd, ".husky"); if (!fs.existsSync(huskyDir)) { fs.mkdirSync(huskyDir, { recursive: true }); } - const execPrefix = detectPackageExec(cwd); + const execPrefix = detectPackageExec(packageRoot); const preparePath = path.join(huskyDir, "prepare-commit-msg"); const commitMsgPath = path.join(huskyDir, "commit-msg"); @@ -194,7 +232,7 @@ function cmdInit(argv) { [preparePath, "prepare-commit-msg"], [commitMsgPath, "commit-msg"], ]) { - const body = hookScript(execPrefix, hookKind); + const body = hookScript(packageRoot, gitRoot, execPrefix, hookKind); if (fs.existsSync(hookPath) && !force) { process.stderr.write(`Skipped ${path.relative(cwd, hookPath)} (already exists). Use --force to overwrite.\n`); } else { diff --git a/lib/core/git.js b/lib/core/git.js index e7a09a6..0aab69c 100644 --- a/lib/core/git.js +++ b/lib/core/git.js @@ -1,5 +1,6 @@ "use strict"; +const path = require("path"); const { execFileSync } = require("child_process"); /** Large staged diffs must not throw ENOBUFS (align with generous buffer in prior tooling). */ @@ -31,6 +32,53 @@ function isInGitRepo(cwd = process.cwd()) { } } +/** + * @param {string} cwd + * @returns {string | null} Absolute git root, or null if not in a repo / git unavailable + */ +function getGitRoot(cwd = process.cwd()) { + try { + return execGit(["rev-parse", "--show-toplevel"], { cwd }).trim(); + } catch { + return null; + } +} + +/** + * Resolve the directory where Git runs hooks (`core.hooksPath` relative to git root, or `.husky`). + * Falls back to `/.husky` with a warning if `core.hooksPath` is missing, empty, or outside the repo. + * @param {string} gitRoot + * @returns {{ dir: string, warned: boolean }} + */ +function resolveGitHooksDir(gitRoot) { + const defaultDir = path.join(gitRoot, ".husky"); + let raw = ""; + try { + raw = execGit(["config", "--get", "core.hooksPath"], { cwd: gitRoot }).trim(); + } catch { + return { dir: defaultDir, warned: false }; + } + if (!raw) { + return { dir: defaultDir, warned: false }; + } + const unquoted = raw.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"); + const hooksPath = unquoted; + const resolved = path.isAbsolute(hooksPath) + ? path.normalize(hooksPath) + : path.resolve(gitRoot, hooksPath); + const rootResolved = path.resolve(gitRoot); + const hooksResolved = path.resolve(resolved); + const rel = path.relative(rootResolved, hooksResolved); + const outside = rel.startsWith("..") || path.isAbsolute(rel); + if (outside) { + process.stderr.write( + "warning: core.hooksPath points outside the repository or is invalid; using .husky at the git root.\n", + ); + return { dir: defaultDir, warned: true }; + } + return { dir: resolved, warned: false }; +} + function assertInGitRepo(cwd = process.cwd()) { if (!isInGitRepo(cwd)) { const err = new Error("Not a git repository (or git not available)."); @@ -97,6 +145,8 @@ module.exports = { DIFF_EXCLUDE_PATHSPECS, execGit, isInGitRepo, + getGitRoot, + resolveGitHooksDir, assertInGitRepo, getStagedDiff, getChangedFiles, diff --git a/lib/init-env.js b/lib/init-env.js index 560f81e..a44a0e9 100644 --- a/lib/init-env.js +++ b/lib/init-env.js @@ -139,11 +139,18 @@ function injectAiCommitDocsForExistingKeys(content) { /** * Build text to append so OPENAI_API_KEY / COMMIT_AI_MODEL placeholders exist. * Returns null if nothing to add. + * Keys listed in `extraAssignedKeys` (e.g. from `.env.local`) count as already satisfied. * @param {string} existing + * @param {Set | undefined} [extraAssignedKeys] * @returns {string | null} */ -function buildAiCommitEnvAppend(existing) { +function buildAiCommitEnvAppend(existing, extraAssignedKeys) { const keys = parseDotenvAssignedKeys(existing); + if (extraAssignedKeys && extraAssignedKeys.size > 0) { + for (const k of extraAssignedKeys) { + keys.add(k); + } + } const hasCommitPlaceholder = keys.has("COMMIT_AI_MODEL") || /^\s*#\s*COMMIT_AI_MODEL\s*=/m.test(existing) || @@ -186,11 +193,11 @@ function buildAiCommitEnvAppend(existing) { * Merge bundled ai-commit env keys into a file. Never removes existing lines. * @param {string} destPath * @param {string} bundledPath - * @param {{ force?: boolean }} [options] + * @param {{ force?: boolean, extraAssignedKeys?: Set }} [options] * @returns {{ kind: 'replaced' | 'wrote' | 'merged' | 'unchanged' }} */ function mergeAiCommitEnvFile(destPath, bundledPath, options = {}) { - const { force = false } = options; + const { force = false, extraAssignedKeys } = options; const bundled = fs.readFileSync(bundledPath, "utf8"); if (force) { @@ -209,7 +216,7 @@ function mergeAiCommitEnvFile(destPath, bundledPath, options = {}) { } let text = injectAiCommitDocsForExistingKeys(existing); - const append = buildAiCommitEnvAppend(text); + const append = buildAiCommitEnvAppend(text, extraAssignedKeys); if (append !== null) { const sep = text.endsWith("\n") ? "" : "\n"; text = `${text}${sep}${append}`; diff --git a/lib/init-paths.js b/lib/init-paths.js new file mode 100644 index 0000000..5787bf6 --- /dev/null +++ b/lib/init-paths.js @@ -0,0 +1,65 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +/** + * Pick the consumer example env file: prefer `.env.example`, else `.env-example`, else default `.env.example`. + * If both dot forms exist, merges target `.env.example` and emits a one-line stderr warning. + * @param {string} dir Absolute or resolved directory (e.g. package root) + * @returns {string} Destination path for the example/template merge + */ +function resolveEnvExamplePath(dir) { + const dotExample = path.join(dir, ".env.example"); + const dotHyphen = path.join(dir, ".env-example"); + const hasExample = fs.existsSync(dotExample); + const hasHyphen = fs.existsSync(dotHyphen); + if (hasExample && hasHyphen) { + process.stderr.write( + "warning: both .env.example and .env-example exist; using .env.example. Remove or consolidate the other file if redundant.\n", + ); + } + if (hasExample) { + return dotExample; + } + if (hasHyphen) { + return dotHyphen; + } + return dotExample; +} + +/** + * Walk from `cwd` up toward `gitRoot` (inclusive); first directory with `package.json` wins. + * If `gitRoot` is null, returns `cwd` (no upward walk). + * If none found before/at git root, returns `cwd`. + * @param {string} cwd + * @param {string | null} gitRoot + * @returns {string} + */ +function findPackageRoot(cwd, gitRoot) { + const cwdResolved = path.resolve(cwd); + if (!gitRoot) { + return cwdResolved; + } + const rootResolved = path.resolve(gitRoot); + let dir = cwdResolved; + for (;;) { + if (fs.existsSync(path.join(dir, "package.json"))) { + return dir; + } + if (dir === rootResolved) { + break; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + return cwdResolved; +} + +module.exports = { + resolveEnvExamplePath, + findPackageRoot, +}; diff --git a/lib/init-workspace.js b/lib/init-workspace.js index e2f8afd..600c2b3 100644 --- a/lib/init-workspace.js +++ b/lib/init-workspace.js @@ -18,18 +18,27 @@ function detectPackageExec(cwd) { } /** + * @param {string} packageRoot Absolute package directory (where lockfile / ai-commit dep live) + * @param {string} gitRoot Absolute git repository root * @param {string} execPrefix from detectPackageExec * @param {"prepare-commit-msg" | "commit-msg"} hook */ -function hookScript(execPrefix, hook) { +function hookScript(packageRoot, gitRoot, execPrefix, hook) { const cmd = hook === "prepare-commit-msg" ? `${execPrefix} ai-commit prepare-commit-msg "$1" "$2"` : `${execPrefix} ai-commit lint --edit "$1"`; + const pkgNorm = path.resolve(packageRoot); + const gitNorm = path.resolve(gitRoot); + let cdBlock = ""; + if (pkgNorm !== gitNorm) { + const rel = path.relative(gitNorm, pkgNorm).split(path.sep).join("/"); + cdBlock = `root="$(git rev-parse --show-toplevel)"\ncd "$root/${rel}"\n`; + } return `#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -${cmd} +${cdBlock}${cmd} `; }