From 7df69551c3b3a41a960b534894f6d9eba5b99788 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Sat, 11 Jul 2026 17:27:19 -0300 Subject: [PATCH] fix: update cli-args and commit-message modules for consistency --- app/api/commit-message/route.ts | 23 +++++++ lib/cli-args.ts | 50 +++++++++++++++ lib/commit-message.ts | 5 +- lib/pr-message.ts | 1 + scripts/cli.ts | 57 ++++------------- test/cli-args.test.ts | 104 ++++++++++++++++++++++++++++++++ test/commit-message.test.ts | 96 +++++++++++++++++++++++++++++ 7 files changed, 288 insertions(+), 48 deletions(-) create mode 100644 lib/cli-args.ts create mode 100644 test/cli-args.test.ts create mode 100644 test/commit-message.test.ts diff --git a/app/api/commit-message/route.ts b/app/api/commit-message/route.ts index 82ae014..c34bf92 100644 --- a/app/api/commit-message/route.ts +++ b/app/api/commit-message/route.ts @@ -22,7 +22,30 @@ interface CommitRequestBody { language?: string; } +/** + * Same-origin guard: this route runs git against an arbitrary local path, so a + * malicious webpage must not be able to POST to it from the user's browser. + * Browsers always send `Origin` on cross-origin POSTs — reject when it doesn't + * match the request host. Requests without an Origin (curl, the CLI, same-app + * server calls) are allowed, so the UI and terminal workflows are unaffected. + */ +function isCrossOrigin(req: NextRequest): boolean { + const origin = req.headers.get("origin"); + if (!origin) return false; + const host = req.headers.get("host"); + if (!host) return true; + try { + return new URL(origin).host !== host; + } catch { + return true; + } +} + export async function POST(req: NextRequest) { + if (isCrossOrigin(req)) { + return NextResponse.json({ error: "Cross-origin requests are not allowed" }, { status: 403 }); + } + let body: CommitRequestBody; try { body = await req.json(); diff --git a/lib/cli-args.ts b/lib/cli-args.ts new file mode 100644 index 0000000..efdb8c8 --- /dev/null +++ b/lib/cli-args.ts @@ -0,0 +1,50 @@ +/** + * CLI argument helpers for the commit/push/PR token grammar. + * Extracted from scripts/cli.ts so they can be unit-tested (importing the CLI + * entrypoint would run main()). + */ + +/** Commands whose very name means "…and push" (no separate push token needed). */ +export const PUSH_ALIASES = new Set(["cnp"]); + +export function isPrToken(t: string | undefined): boolean { + const v = (t || "").toLowerCase(); + return v === "pr" || v === "pull" || v === "pull-request"; +} + +export function isPushToken(t: string | undefined): boolean { + const v = (t || "").toLowerCase(); + return v === "push" || v === "p"; +} + +export interface CommitPrArgs { + push: boolean; + wantPr: boolean; + prBase?: string; +} + +/** + * After a commit/push command, detect trailing `pr [base]`. + * Examples: cnp pr · cnp pr develop · commit push pr · commit pr main + */ +export function parseCommitPrArgs( + raw: string, + a1: string | undefined, + a2: string | undefined, + a3: string | undefined +): CommitPrArgs { + const pushByAlias = PUSH_ALIASES.has(raw); + // cnp [pr [base]] + if (pushByAlias) { + if (isPrToken(a1)) return { push: true, wantPr: true, prBase: a2 }; + return { push: true, wantPr: false }; + } + // commit push pr [base] | commit p pr [base] + if (isPushToken(a1)) { + if (isPrToken(a2)) return { push: true, wantPr: true, prBase: a3 }; + return { push: true, wantPr: false }; + } + // commit pr [base] (implies push — you can't open a remote PR without push) + if (isPrToken(a1)) return { push: true, wantPr: true, prBase: a2 }; + return { push: false, wantPr: false }; +} diff --git a/lib/commit-message.ts b/lib/commit-message.ts index 126ea2c..2280bbd 100644 --- a/lib/commit-message.ts +++ b/lib/commit-message.ts @@ -100,7 +100,8 @@ export function cleanMessage(raw: string): string { return stripEdges(lines[lines.length - 1]); } -function extractResponseText(data: unknown): string { +export function extractResponseText(data: unknown): string { + if (!data || typeof data !== "object") return ""; const response = data as { output_text?: unknown; output?: Array<{ content?: Array<{ text?: unknown }> }>; @@ -122,7 +123,7 @@ function extractResponseText(data: unknown): string { } /** Compact context: status + name-status + truncated patch (faster than raw 14k diffs). */ -function buildContext(status: string, nameStatus: string, diff: string, untracked: string): string { +export function buildContext(status: string, nameStatus: string, diff: string, untracked: string): string { const parts: string[] = []; const st = status.trim(); diff --git a/lib/pr-message.ts b/lib/pr-message.ts index 522e4d3..dc3a5d2 100644 --- a/lib/pr-message.ts +++ b/lib/pr-message.ts @@ -144,6 +144,7 @@ function openAiRequest( } function extractResponseText(data: unknown): string { + if (!data || typeof data !== "object") return ""; const response = data as { output_text?: unknown; output?: Array<{ content?: Array<{ text?: unknown }> }>; diff --git a/scripts/cli.ts b/scripts/cli.ts index 8e10001..e6c3d01 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -13,8 +13,8 @@ * commit-and-push cnp [pr] add . -> (AI) commit -> push [-> PR] * pr [base] pr [base] push branch -> AI title/body -> gh pr create * branch b checkout -b -> add -> commit -> push -u - * merge [dst] m [d] add -> commit -> checkout dst -> merge -> push - * save s add -> commit -> checkout main + * merge [dst] m [d] add -> commit -> checkout dst|default -> merge -> push + * save s add -> commit -> checkout default branch * checkout ck git checkout (sw alias) * remote r init -> remote add origin -> first push * restore [file] rs [file] git restore . (or one file) — destructive @@ -75,6 +75,7 @@ import { parseNpmLatestVersion, } from "../lib/update-check"; import { sanitizeBranchName } from "../lib/branch-name"; +import { PUSH_ALIASES, isPrToken, isPushToken, parseCommitPrArgs } from "../lib/cli-args"; import { runDoctorChecks } from "../lib/doctor"; import { APP_NAME, CLI_NAME, getPackageName, getVersion } from "../lib/version"; import { c, header, row, sym, visibleLength } from "../lib/ui"; @@ -344,37 +345,6 @@ async function ensureGitRepo(): Promise { } } -function isPrToken(t: string | undefined): boolean { - const v = (t || "").toLowerCase(); - return v === "pr" || v === "pull" || v === "pull-request"; -} - -/** - * After a commit/push command, detect trailing `pr [base]`. - * Examples: cnp pr · cnp pr develop · commit push pr · commit pr main - */ -function parseCommitPrArgs( - raw: string, - a1: string | undefined, - a2: string | undefined, - a3: string | undefined -): { push: boolean; wantPr: boolean; prBase?: string } { - const pushByAlias = PUSH_ALIASES.has(raw); - // cnp [pr [base]] - if (pushByAlias) { - if (isPrToken(a1)) return { push: true, wantPr: true, prBase: a2 }; - return { push: true, wantPr: false }; - } - // commit push pr [base] | commit p pr [base] - if (isPushToken(a1)) { - if (isPrToken(a2)) return { push: true, wantPr: true, prBase: a3 }; - return { push: true, wantPr: false }; - } - // commit pr [base] (implies push — you can't open a remote PR without push) - if (isPrToken(a1)) return { push: true, wantPr: true, prBase: a2 }; - return { push: false, wantPr: false }; -} - /** AI PR title/body, with sensible fallback if no key / API error. */ async function resolvePrContent(base: string, head: string): Promise { const { apiKey, model, language } = await ensureApiReady(); @@ -785,20 +755,12 @@ const SHORT_CMDS: Record = { onboard: "setup", }; -// Commands whose very name means "…and push" (no separate push token needed). -const PUSH_ALIASES = new Set(["cnp"]); - const rawCmd = (positional[0] || "").toLowerCase(); const cmd = SHORT_CMDS[rawCmd] || rawCmd; const arg1 = positional[1]; const arg2 = positional[2]; const arg3 = positional[3]; -function isPushToken(t: string | undefined): boolean { - const v = (t || "").toLowerCase(); - return v === "push" || v === "p"; -} - /** Where this CLI is installed (npm global prefix, local clone, etc.). */ function installPaths(): { packageRoot: string; @@ -1100,8 +1062,8 @@ function helpText(): string { ["commit-and-push [pr]", "cnp [pr]", "add . → commit → push [→ AI PR via gh]"], ["pr [base] [-y]", "pr [base]", "push → AI title/body → create PR (asks base)"], ["branch [-m]", "b [-m]", "new branch → add → commit → push -u"], - ["merge [dst] [-m]", "m [dst]", "commit → checkout dst|main → merge → push"], - ["save [-m]", "s [-m]", "commit current work, then checkout main"], + ["merge [dst] [-m]", "m [dst]", "commit → checkout dst|default → merge → push"], + ["save [-m]", "s [-m]", "commit current work, then checkout default branch"], ["checkout ", "ck ", "git checkout "], ["remote [-m]", "r [-m]", "git init → remote add origin → first push"], ["restore [file] [-y]", "rs [file] [-y]", "discard changes (all, or one file)"], @@ -1288,7 +1250,9 @@ async function main() { case "merge": { const source = resolveBranchName(arg1 || "", "source branch"); - const target = arg2 ? resolveBranchName(arg2, "target branch") : "main"; + const target = arg2 + ? resolveBranchName(arg2, "target branch") + : await detectDefaultBase(cwd); banner(`merge ${source} → ${target}`); const message = await resolveMessage(messageFlag, `merge: integrate ${source} into ${target}`); await runSteps(async () => { @@ -1304,7 +1268,8 @@ async function main() { } case "save": { - banner("save & return to main"); + const target = await detectDefaultBase(cwd); + banner(`save & return to ${target}`); const message = await resolveMessage(messageFlag, "wip: saving progress"); await runSteps(async () => { if (await hasChanges()) { @@ -1313,7 +1278,7 @@ async function main() { } else { log(` ${c.dim("nothing to commit — switching only")}`); } - await git(["checkout", "main"]); + await git(["checkout", target]); }); return; } diff --git a/test/cli-args.test.ts b/test/cli-args.test.ts new file mode 100644 index 0000000..526f0c6 --- /dev/null +++ b/test/cli-args.test.ts @@ -0,0 +1,104 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { isPrToken, isPushToken, parseCommitPrArgs } from "../lib/cli-args"; + +describe("isPrToken", () => { + it("accepts pr, pull, pull-request in any case", () => { + assert.equal(isPrToken("pr"), true); + assert.equal(isPrToken("PR"), true); + assert.equal(isPrToken("pull"), true); + assert.equal(isPrToken("pull-request"), true); + }); + + it("rejects other tokens and undefined", () => { + assert.equal(isPrToken("push"), false); + assert.equal(isPrToken("main"), false); + assert.equal(isPrToken(undefined), false); + assert.equal(isPrToken(""), false); + }); +}); + +describe("isPushToken", () => { + it("accepts push and p in any case", () => { + assert.equal(isPushToken("push"), true); + assert.equal(isPushToken("P"), true); + }); + + it("rejects other tokens and undefined", () => { + assert.equal(isPushToken("pr"), false); + assert.equal(isPushToken(undefined), false); + }); +}); + +describe("parseCommitPrArgs", () => { + it("commit → no push, no PR", () => { + assert.deepEqual(parseCommitPrArgs("commit", undefined, undefined, undefined), { + push: false, + wantPr: false, + }); + }); + + it("commit push / commit p → push only", () => { + assert.deepEqual(parseCommitPrArgs("commit", "push", undefined, undefined), { + push: true, + wantPr: false, + }); + assert.deepEqual(parseCommitPrArgs("c", "p", undefined, undefined), { + push: true, + wantPr: false, + }); + }); + + it("commit pr [base] → push implied, PR with optional base", () => { + assert.deepEqual(parseCommitPrArgs("commit", "pr", undefined, undefined), { + push: true, + wantPr: true, + prBase: undefined, + }); + assert.deepEqual(parseCommitPrArgs("commit", "pr", "main", undefined), { + push: true, + wantPr: true, + prBase: "main", + }); + }); + + it("commit push pr [base] → push and PR", () => { + assert.deepEqual(parseCommitPrArgs("commit", "push", "pr", "develop"), { + push: true, + wantPr: true, + prBase: "develop", + }); + assert.deepEqual(parseCommitPrArgs("commit", "p", "pr", undefined), { + push: true, + wantPr: true, + prBase: undefined, + }); + }); + + it("cnp alias → push without extra token", () => { + assert.deepEqual(parseCommitPrArgs("cnp", undefined, undefined, undefined), { + push: true, + wantPr: false, + }); + }); + + it("cnp pr [base] → push and PR", () => { + assert.deepEqual(parseCommitPrArgs("cnp", "pr", undefined, undefined), { + push: true, + wantPr: true, + prBase: undefined, + }); + assert.deepEqual(parseCommitPrArgs("cnp", "pr", "develop", undefined), { + push: true, + wantPr: true, + prBase: "develop", + }); + }); + + it("ignores non-pr tokens after cnp", () => { + assert.deepEqual(parseCommitPrArgs("cnp", "extra", undefined, undefined), { + push: true, + wantPr: false, + }); + }); +}); diff --git a/test/commit-message.test.ts b/test/commit-message.test.ts new file mode 100644 index 0000000..eb171a8 --- /dev/null +++ b/test/commit-message.test.ts @@ -0,0 +1,96 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildContext, cleanMessage, extractResponseText } from "../lib/commit-message"; + +describe("cleanMessage", () => { + it("returns a plain conventional commit unchanged", () => { + assert.equal(cleanMessage("feat: add login page"), "feat: add login page"); + assert.equal(cleanMessage("fix(auth): handle expired tokens"), "fix(auth): handle expired tokens"); + }); + + it("strips quotes, backticks, and trailing periods", () => { + assert.equal(cleanMessage('"feat: add login page"'), "feat: add login page"); + assert.equal(cleanMessage("`fix: correct typo`"), "fix: correct typo"); + assert.equal(cleanMessage("chore: bump deps."), "chore: bump deps"); + }); + + it("strips a leading label like 'commit message:'", () => { + assert.equal(cleanMessage("Commit message: feat: add search"), "feat: add search"); + assert.equal(cleanMessage("mensagem de commit: fix: corrige rota"), "fix: corrige rota"); + }); + + it("removes blocks from reasoning models", () => { + assert.equal( + cleanMessage("The diff adds a route…\nfeat: add api route"), + "feat: add api route" + ); + assert.equal(cleanMessage("fix: close tag leak"), "fix: close tag leak"); + }); + + it("picks the last conventional-commit line from multi-line output", () => { + const raw = "Here is a good message:\n\nfeat: add commit generation"; + assert.equal(cleanMessage(raw), "feat: add commit generation"); + }); + + it("falls back to the last line when nothing matches conventional commits", () => { + assert.equal(cleanMessage("some free-form text"), "some free-form text"); + }); + + it("returns empty for empty or whitespace input", () => { + assert.equal(cleanMessage(""), ""); + assert.equal(cleanMessage(" \n "), ""); + }); +}); + +describe("extractResponseText", () => { + it("prefers output_text (OpenAI responses API)", () => { + assert.equal(extractResponseText({ output_text: "feat: a" }), "feat: a"); + }); + + it("joins output[].content[].text parts", () => { + const data = { + output: [ + { content: [{ text: "feat: a" }, { text: "line 2" }] }, + { content: [{ text: "line 3" }] }, + ], + }; + assert.equal(extractResponseText(data), "feat: a\nline 2\nline 3"); + }); + + it("falls back to chat choices[0].message.content (OpenRouter)", () => { + const data = { choices: [{ message: { content: "fix: b" } }] }; + assert.equal(extractResponseText(data), "fix: b"); + }); + + it("returns empty string for unrecognized shapes", () => { + assert.equal(extractResponseText({}), ""); + assert.equal(extractResponseText(null), ""); + assert.equal(extractResponseText({ choices: [{ message: { content: 42 } }] }), ""); + }); +}); + +describe("buildContext", () => { + it("includes only non-empty sections, labeled", () => { + const out = buildContext("M file.ts", "M\tfile.ts", "diff body", ""); + assert.match(out, /^STATUS:\nM file\.ts/); + assert.match(out, /FILES:\nM\tfile\.ts/); + assert.match(out, /DIFF:\ndiff body/); + assert.doesNotMatch(out, /UNTRACKED:/); + }); + + it("includes untracked files when present", () => { + const out = buildContext("", "", "", "new-file.ts"); + assert.equal(out, "UNTRACKED:\nnew-file.ts"); + }); + + it("returns empty string when everything is empty", () => { + assert.equal(buildContext("", " ", "\n", ""), ""); + }); + + it("truncates very large diffs and marks the cut", () => { + const bigDiff = "x".repeat(10_000); + const out = buildContext("", "", bigDiff, ""); + assert.ok(out.length < bigDiff.length); + assert.match(out, /…\(truncated\)$/); + }); +});