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
23 changes: 23 additions & 0 deletions app/api/commit-message/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
50 changes: 50 additions & 0 deletions lib/cli-args.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
5 changes: 3 additions & 2 deletions lib/commit-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> }>;
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions lib/pr-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> }>;
Expand Down
57 changes: 11 additions & 46 deletions scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> b <name> checkout -b -> add -> commit -> push -u
* merge <src> [dst] m <src> [d] add -> commit -> checkout dst -> merge -> push
* save s add -> commit -> checkout main
* merge <src> [dst] m <src> [d] add -> commit -> checkout dst|default -> merge -> push
* save s add -> commit -> checkout default branch
* checkout <branch> ck <branch> git checkout <branch> (sw alias)
* remote <url> r <url> init -> remote add origin -> first push
* restore [file] rs [file] git restore . (or one file) — destructive
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -344,37 +345,6 @@ async function ensureGitRepo(): Promise<void> {
}
}

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<PrContent> {
const { apiKey, model, language } = await ensureApiReady();
Expand Down Expand Up @@ -785,20 +755,12 @@ const SHORT_CMDS: Record<string, string> = {
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;
Expand Down Expand Up @@ -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 <name> [-m]", "b <name> [-m]", "new branch → add → commit → push -u"],
["merge <src> [dst] [-m]", "m <src> [dst]", "commit → checkout dst|main → merge → push"],
["save [-m]", "s [-m]", "commit current work, then checkout main"],
["merge <src> [dst] [-m]", "m <src> [dst]", "commit → checkout dst|default → merge → push"],
["save [-m]", "s [-m]", "commit current work, then checkout default branch"],
["checkout <branch>", "ck <branch>", "git checkout <branch>"],
["remote <url> [-m]", "r <url> [-m]", "git init → remote add origin → first push"],
["restore [file] [-y]", "rs [file] [-y]", "discard changes (all, or one file)"],
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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()) {
Expand All @@ -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;
}
Expand Down
104 changes: 104 additions & 0 deletions test/cli-args.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading