@cruxy/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -13
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +0 -1
- package/dist/agent/loop.js +0 -1
- package/dist/agent/prompts.d.ts +0 -2
- package/dist/agent/prompts.js +3 -3
- package/dist/approval/classify.d.ts +18 -0
- package/dist/approval/classify.js +162 -0
- package/dist/approval/index.d.ts +5 -0
- package/dist/approval/index.js +5 -0
- package/dist/approval/policy.d.ts +37 -0
- package/dist/approval/policy.js +81 -0
- package/dist/approval/prompt.d.ts +33 -0
- package/dist/approval/prompt.js +212 -0
- package/dist/approval/service.d.ts +36 -0
- package/dist/approval/service.js +37 -0
- package/dist/approval/types.d.ts +64 -0
- package/dist/approval/types.js +1 -0
- package/dist/cli/commands/pr.d.ts +8 -0
- package/dist/cli/commands/pr.js +87 -0
- package/dist/cli/commands/run.js +10 -10
- package/dist/cli/program.js +2 -0
- package/dist/cli/repl.js +1 -1
- package/dist/config/schema.d.ts +38 -9
- package/dist/config/schema.js +13 -4
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +83 -0
- package/dist/errors/types.d.ts +5 -0
- package/dist/errors/types.js +11 -0
- package/dist/tools/create-pull-request.d.ts +24 -0
- package/dist/tools/create-pull-request.js +83 -0
- package/dist/tools/file/apply-patch.js +3 -3
- package/dist/tools/file/edit-file.js +6 -3
- package/dist/tools/file/write-file.js +6 -3
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/registry.js +2 -0
- package/dist/tools/shell/run-command.js +11 -3
- package/dist/tools/types.d.ts +25 -6
- package/dist/vcs/auth.d.ts +22 -0
- package/dist/vcs/auth.js +29 -0
- package/dist/vcs/generate.d.ts +72 -0
- package/dist/vcs/generate.js +265 -0
- package/dist/vcs/git.d.ts +52 -0
- package/dist/vcs/git.js +152 -0
- package/dist/vcs/github.d.ts +44 -0
- package/dist/vcs/github.js +145 -0
- package/dist/vcs/guidance.d.ts +20 -0
- package/dist/vcs/guidance.js +76 -0
- package/dist/vcs/index.d.ts +7 -0
- package/dist/vcs/index.js +7 -0
- package/dist/vcs/service.d.ts +53 -0
- package/dist/vcs/service.js +79 -0
- package/dist/vcs/types.d.ts +57 -0
- package/dist/vcs/types.js +6 -0
- package/package.json +1 -1
- package/dist/agent/approval.d.ts +0 -41
- package/dist/agent/approval.js +0 -179
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a diff + session context into the PR publish content (C.15): a
|
|
3
|
+
* conventional-commit subject, a structured body, a branch name, and the PR
|
|
4
|
+
* title/body. The repo's commit rules (the C.18 git-commit skill + the
|
|
5
|
+
* commitlint scope list) are honored two ways: they're handed to the LLM in the
|
|
6
|
+
* prompt, **and** every field is run through deterministic normalizers so the
|
|
7
|
+
* lowercase-subject rule (and scope allow-list) hold even if the model slips.
|
|
8
|
+
*
|
|
9
|
+
* Secrets never leave: the diff is redacted before the LLM sees it, and the
|
|
10
|
+
* generated bodies are redacted again (defense-in-depth).
|
|
11
|
+
*/
|
|
12
|
+
/** Conventional-commit types accepted by `@commitlint/config-conventional`. */
|
|
13
|
+
export const CONVENTIONAL_TYPES = [
|
|
14
|
+
"feat",
|
|
15
|
+
"fix",
|
|
16
|
+
"chore",
|
|
17
|
+
"docs",
|
|
18
|
+
"refactor",
|
|
19
|
+
"test",
|
|
20
|
+
"perf",
|
|
21
|
+
"build",
|
|
22
|
+
"ci",
|
|
23
|
+
"style",
|
|
24
|
+
"revert",
|
|
25
|
+
];
|
|
26
|
+
const SUBJECT_MAX = 72;
|
|
27
|
+
// ── secret redaction ────────────────────────────────────────────────────────────
|
|
28
|
+
const REDACTED = "«redacted secret»";
|
|
29
|
+
/** Ordered redaction patterns. Each match's secret span is replaced. */
|
|
30
|
+
const SECRET_PATTERNS = [
|
|
31
|
+
// PEM private key blocks.
|
|
32
|
+
{
|
|
33
|
+
re: /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/g,
|
|
34
|
+
replace: () => REDACTED,
|
|
35
|
+
},
|
|
36
|
+
// GitHub tokens (PAT, OAuth, app, refresh) + fine-grained PATs.
|
|
37
|
+
{ re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, replace: () => REDACTED },
|
|
38
|
+
{ re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replace: () => REDACTED },
|
|
39
|
+
// Slack tokens.
|
|
40
|
+
{ re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replace: () => REDACTED },
|
|
41
|
+
// AWS access key id.
|
|
42
|
+
{ re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: () => REDACTED },
|
|
43
|
+
// Bearer tokens in headers/snippets.
|
|
44
|
+
{
|
|
45
|
+
re: /\bBearer\s+[A-Za-z0-9._-]{20,}/g,
|
|
46
|
+
replace: () => `Bearer ${REDACTED}`,
|
|
47
|
+
},
|
|
48
|
+
// Generic `key = value` / `key: "value"` secret assignments.
|
|
49
|
+
{
|
|
50
|
+
re: /\b((?:api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key|client[_-]?secret|auth[_-]?token)\s*[:=]\s*)(['"]?)([A-Za-z0-9_./+=-]{12,})\2/gi,
|
|
51
|
+
replace: (m) => `${m[1]}${m[2]}${REDACTED}${m[2]}`,
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
/** Strip anything that looks like a credential from `text`. */
|
|
55
|
+
export function redactSecrets(text) {
|
|
56
|
+
let out = text;
|
|
57
|
+
for (const { re, replace } of SECRET_PATTERNS) {
|
|
58
|
+
out = out.replace(re, (...args) => {
|
|
59
|
+
// String.replace passes (match, ...groups, offset, string); rebuild an
|
|
60
|
+
// exec-style array so the replacer can index capture groups.
|
|
61
|
+
const groups = args.slice(0, -2);
|
|
62
|
+
const m = groups;
|
|
63
|
+
return replace(m);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
// ── conventional-commit normalization ────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Force `raw` into a valid conventional-commit subject: a known `type`, an
|
|
71
|
+
* allow-listed scope (dropped if it isn't), a **lowercase** first word, no
|
|
72
|
+
* trailing period, clamped to 72 chars. This is the deterministic guarantee that
|
|
73
|
+
* the commitlint `subject-case` + `scope-enum` rules pass.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeSubject(raw, scopes = []) {
|
|
76
|
+
const cleaned = raw.replace(/\s+/g, " ").trim();
|
|
77
|
+
const m = /^([a-zA-Z]+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/.exec(cleaned);
|
|
78
|
+
let type = "chore";
|
|
79
|
+
let scope;
|
|
80
|
+
let desc = cleaned;
|
|
81
|
+
if (m) {
|
|
82
|
+
type = m[1].toLowerCase();
|
|
83
|
+
scope = m[2]?.trim() || undefined;
|
|
84
|
+
desc = m[4]?.trim() || "";
|
|
85
|
+
}
|
|
86
|
+
if (!CONVENTIONAL_TYPES.includes(type)) {
|
|
87
|
+
// Unknown type ⇒ fold the original text into a chore subject.
|
|
88
|
+
type = "chore";
|
|
89
|
+
desc = m ? desc : cleaned;
|
|
90
|
+
}
|
|
91
|
+
// Drop a scope the repo doesn't allow (scope is optional, so this still passes).
|
|
92
|
+
if (scope && scopes.length > 0 && !scopes.includes(scope)) {
|
|
93
|
+
scope = undefined;
|
|
94
|
+
}
|
|
95
|
+
desc = desc.replace(/\.+$/, "").trim();
|
|
96
|
+
if (desc === "")
|
|
97
|
+
desc = "update";
|
|
98
|
+
desc = lowercaseLead(desc);
|
|
99
|
+
const prefix = scope ? `${type}(${scope}): ` : `${type}: `;
|
|
100
|
+
const budget = Math.max(1, SUBJECT_MAX - prefix.length);
|
|
101
|
+
if (desc.length > budget)
|
|
102
|
+
desc = desc.slice(0, budget).trimEnd();
|
|
103
|
+
return `${prefix}${desc}`;
|
|
104
|
+
}
|
|
105
|
+
/** Lowercase the first alphabetic character (the commitlint subject-case rule). */
|
|
106
|
+
function lowercaseLead(text) {
|
|
107
|
+
const i = text.search(/[A-Za-z]/);
|
|
108
|
+
if (i === -1)
|
|
109
|
+
return text;
|
|
110
|
+
return text.slice(0, i) + text[i].toLowerCase() + text.slice(i + 1);
|
|
111
|
+
}
|
|
112
|
+
/** A safe branch name `type/slug` derived from a conventional subject. */
|
|
113
|
+
export function slugifyBranch(subject) {
|
|
114
|
+
const m = /^([a-z]+)(?:\([^)]*\))?:\s*(.*)$/.exec(subject);
|
|
115
|
+
const type = m ? m[1] : "chore";
|
|
116
|
+
const desc = (m ? m[2] : subject) || "change";
|
|
117
|
+
const slug = desc
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
120
|
+
.replace(/^-+|-+$/g, "")
|
|
121
|
+
.slice(0, 40)
|
|
122
|
+
.replace(/-+$/g, "");
|
|
123
|
+
return `${type}/${slug || "change"}`;
|
|
124
|
+
}
|
|
125
|
+
/** Assemble a structured PR body: what changed · why · verification. */
|
|
126
|
+
export function assembleBody(parts) {
|
|
127
|
+
const sections = [`## What changed\n\n${parts.what.trim()}`];
|
|
128
|
+
if (parts.why?.trim())
|
|
129
|
+
sections.push(`## Why\n\n${parts.why.trim()}`);
|
|
130
|
+
if (parts.verification?.trim())
|
|
131
|
+
sections.push(`## Verification\n\n${parts.verification.trim()}`);
|
|
132
|
+
return sections.join("\n\n");
|
|
133
|
+
}
|
|
134
|
+
// ── deterministic fill (the tool path — no LLM) ───────────────────────────────────
|
|
135
|
+
/** Count `diff --git` headers to summarize a change set without an LLM. */
|
|
136
|
+
function changedFileCount(diff) {
|
|
137
|
+
const matches = diff.match(/^diff --git /gm);
|
|
138
|
+
const untracked = diff.match(/^\+ /gm); // from diffAgainst's untracked list
|
|
139
|
+
return (matches?.length ?? 0) + (untracked?.length ?? 0);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Build the publish content deterministically (used by the `create_pull_request`
|
|
143
|
+
* tool, where the agent itself authored the title/body). Missing fields are
|
|
144
|
+
* derived; every field is still normalized + redacted.
|
|
145
|
+
*/
|
|
146
|
+
export function fillContent(input) {
|
|
147
|
+
const n = changedFileCount(input.diff);
|
|
148
|
+
const rawSubject = input.title?.trim() || `chore: update ${n} file${n === 1 ? "" : "s"}`;
|
|
149
|
+
return finalize(rawSubject, input.body, input);
|
|
150
|
+
}
|
|
151
|
+
// ── LLM generation (the command path) ─────────────────────────────────────────────
|
|
152
|
+
/**
|
|
153
|
+
* Generate publish content from the diff with the model, honoring the commit
|
|
154
|
+
* rules, then normalize + redact. Resilient: if the model's reply isn't the
|
|
155
|
+
* expected JSON, the first line becomes the subject and the rest the body.
|
|
156
|
+
*/
|
|
157
|
+
export async function generateWithLlm(provider, input) {
|
|
158
|
+
const redactedDiff = redactSecrets(input.diff);
|
|
159
|
+
const system = buildSystemPrompt(input);
|
|
160
|
+
const user = buildUserPrompt({ ...input, diff: redactedDiff });
|
|
161
|
+
let text = "";
|
|
162
|
+
for await (const ev of provider.stream({
|
|
163
|
+
system,
|
|
164
|
+
messages: [{ role: "user", content: user }],
|
|
165
|
+
})) {
|
|
166
|
+
if (ev.type === "text_delta")
|
|
167
|
+
text += ev.text;
|
|
168
|
+
else if (ev.type === "error")
|
|
169
|
+
throw ev.error;
|
|
170
|
+
}
|
|
171
|
+
const parsed = parseGenerated(text);
|
|
172
|
+
const rawSubject = parsed.commitSubject || parsed.prTitle || "";
|
|
173
|
+
const body = parsed.prBody || parsed.commitBody;
|
|
174
|
+
return finalize(rawSubject, body, input, {
|
|
175
|
+
branchName: parsed.branchName,
|
|
176
|
+
commitBody: parsed.commitBody,
|
|
177
|
+
prBody: parsed.prBody,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/** Shared final assembly: normalize the subject, redact bodies, pick a branch. */
|
|
181
|
+
function finalize(rawSubject, rawBody, input, extra = {}) {
|
|
182
|
+
const commitSubject = normalizeSubject(rawSubject, input.scopes);
|
|
183
|
+
const body = redactSecrets((rawBody ?? extra.prBody ?? "").trim() ||
|
|
184
|
+
assembleBody({
|
|
185
|
+
what: rawSubject || commitSubject,
|
|
186
|
+
verification: "`pnpm -r typecheck && pnpm lint && pnpm -r test`",
|
|
187
|
+
}));
|
|
188
|
+
const commitBody = redactSecrets((extra.commitBody ?? rawBody ?? "").trim());
|
|
189
|
+
const branchName = input.currentBranch ??
|
|
190
|
+
sanitizeBranch(extra.branchName) ??
|
|
191
|
+
slugifyBranch(commitSubject);
|
|
192
|
+
return {
|
|
193
|
+
branchName,
|
|
194
|
+
commitSubject,
|
|
195
|
+
commitBody,
|
|
196
|
+
prTitle: commitSubject,
|
|
197
|
+
prBody: body,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** Accept a model-proposed branch only if it's a plausible ref; else null. */
|
|
201
|
+
function sanitizeBranch(name) {
|
|
202
|
+
if (!name)
|
|
203
|
+
return null;
|
|
204
|
+
const clean = name.trim();
|
|
205
|
+
if (clean === "" || /\s/.test(clean) || clean.length > 80)
|
|
206
|
+
return null;
|
|
207
|
+
return /^[A-Za-z0-9._/-]+$/.test(clean) ? clean : null;
|
|
208
|
+
}
|
|
209
|
+
/** Parse the model reply: a JSON object if present, else first-line/rest. */
|
|
210
|
+
export function parseGenerated(text) {
|
|
211
|
+
const obj = extractJsonObject(text);
|
|
212
|
+
if (obj)
|
|
213
|
+
return obj;
|
|
214
|
+
const trimmed = text.trim();
|
|
215
|
+
const nl = trimmed.indexOf("\n");
|
|
216
|
+
return nl === -1
|
|
217
|
+
? { commitSubject: trimmed }
|
|
218
|
+
: {
|
|
219
|
+
commitSubject: trimmed.slice(0, nl),
|
|
220
|
+
prBody: trimmed.slice(nl + 1).trim(),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** Pull the first balanced `{…}` out of `text` and JSON-parse it. */
|
|
224
|
+
function extractJsonObject(text) {
|
|
225
|
+
const start = text.indexOf("{");
|
|
226
|
+
const end = text.lastIndexOf("}");
|
|
227
|
+
if (start === -1 || end <= start)
|
|
228
|
+
return null;
|
|
229
|
+
try {
|
|
230
|
+
const obj = JSON.parse(text.slice(start, end + 1));
|
|
231
|
+
return obj && typeof obj === "object" ? obj : null;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function buildSystemPrompt(input) {
|
|
238
|
+
const lines = [
|
|
239
|
+
"You write the pull request for a code change: a conventional-commit subject, a structured PR body, and a branch name.",
|
|
240
|
+
"Respond with ONLY a JSON object — no markdown fences, no prose — with these string fields:",
|
|
241
|
+
'{ "branchName", "commitSubject", "commitBody", "prTitle", "prBody" }.',
|
|
242
|
+
"",
|
|
243
|
+
"Rules for commitSubject (and prTitle, which must equal it):",
|
|
244
|
+
"- Conventional Commits: `type(scope): subject` (scope optional).",
|
|
245
|
+
"- The subject MUST NOT start with a capital letter (commitlint subject-case).",
|
|
246
|
+
"- Imperative mood, no trailing period, ≤ 72 characters.",
|
|
247
|
+
];
|
|
248
|
+
if (input.scopes.length > 0) {
|
|
249
|
+
lines.push(`- If you use a scope, it must be one of: ${input.scopes.join(", ")}.`);
|
|
250
|
+
}
|
|
251
|
+
lines.push("", "prBody: GitHub-flavored markdown with sections '## What changed', '## Why', '## Verification'.", "branchName: short kebab-case `type/slug`, no spaces.", "Never include secrets, tokens, or credentials in any field.");
|
|
252
|
+
if (input.skillBody) {
|
|
253
|
+
lines.push("", "Repository commit conventions (authoritative):", input.skillBody);
|
|
254
|
+
}
|
|
255
|
+
return lines.join("\n");
|
|
256
|
+
}
|
|
257
|
+
function buildUserPrompt(input) {
|
|
258
|
+
const lines = [`Base branch: ${input.base}`];
|
|
259
|
+
if (input.currentBranch)
|
|
260
|
+
lines.push(`Current branch: ${input.currentBranch}`);
|
|
261
|
+
if (input.sessionSummary)
|
|
262
|
+
lines.push("", "What I did this session:", input.sessionSummary);
|
|
263
|
+
lines.push("", "Diff:", "```diff", input.diff || "(no textual diff)", "```");
|
|
264
|
+
return lines.join("\n");
|
|
265
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface GitResult {
|
|
2
|
+
readonly ok: boolean;
|
|
3
|
+
readonly stdout: string;
|
|
4
|
+
readonly stderr: string;
|
|
5
|
+
readonly code: number | null;
|
|
6
|
+
}
|
|
7
|
+
/** Run `git <args>` in `cwd`, capturing stdout/stderr/exit. Never throws. */
|
|
8
|
+
export declare function runGitCapture(args: string[], cwd: string): GitResult;
|
|
9
|
+
/** The current branch name, or `null` when detached / not a repo. */
|
|
10
|
+
export declare function currentBranch(cwd: string): string | null;
|
|
11
|
+
/** The default set of branch names cruxy will never write to directly. */
|
|
12
|
+
export declare const DEFAULT_PROTECTED_BRANCHES: readonly ["main", "master"];
|
|
13
|
+
/**
|
|
14
|
+
* Is `branch` protected? The built-in `main`/`master` plus any names from config.
|
|
15
|
+
* Case-insensitive so `Main`/`MASTER` are caught too.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isProtectedBranch(branch: string, extra?: readonly string[]): boolean;
|
|
18
|
+
export interface EnsureBranchResult {
|
|
19
|
+
/** The branch we ended up on (always a non-protected branch). */
|
|
20
|
+
readonly branch: string;
|
|
21
|
+
/** Whether we created/switched (true) or were already on a feature branch. */
|
|
22
|
+
readonly switched: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Guarantee we're on a non-protected feature branch before any commit/push. If
|
|
26
|
+
* already on a feature branch, stay there. If on a protected branch, switch to
|
|
27
|
+
* `desired` (creating it if needed). Throws if `desired` is itself protected.
|
|
28
|
+
*/
|
|
29
|
+
export declare function ensureFeatureBranch(cwd: string, desired: string, protectedExtra?: readonly string[]): EnsureBranchResult;
|
|
30
|
+
/** Stage every change in the working tree (`git add -A`). */
|
|
31
|
+
export declare function stageAll(cwd: string): void;
|
|
32
|
+
/** Whether the working tree (staged or not) has any changes to commit. */
|
|
33
|
+
export declare function hasChanges(cwd: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Commit the staged changes with a conventional message. Refuses on a protected
|
|
36
|
+
* branch (last-line guard) and **never** passes `--no-verify`, so the commitlint
|
|
37
|
+
* `commit-msg` hook runs. Subject and body go as separate `-m` flags.
|
|
38
|
+
*/
|
|
39
|
+
export declare function commit(cwd: string, subject: string, body: string, protectedExtra?: readonly string[]): void;
|
|
40
|
+
/**
|
|
41
|
+
* Push `branch` to `origin`, setting upstream. **Never** `--force`, **never**
|
|
42
|
+
* `--no-verify` — the pre-push hook (build · typecheck · lint · test + main
|
|
43
|
+
* guard) runs and a failure is surfaced verbatim. Refuses on a protected branch.
|
|
44
|
+
*/
|
|
45
|
+
export declare function push(cwd: string, branch: string, protectedExtra?: readonly string[]): void;
|
|
46
|
+
/**
|
|
47
|
+
* The change set for content generation: the working-tree diff relative to
|
|
48
|
+
* `base` (covers committed-on-branch *and* uncommitted edits), plus the names of
|
|
49
|
+
* untracked files (which a plain `git diff` omits). Falls back to the diff vs
|
|
50
|
+
* `HEAD` when `base` isn't reachable (shallow clone / brand-new repo).
|
|
51
|
+
*/
|
|
52
|
+
export declare function diffAgainst(cwd: string, base: string): string;
|
package/dist/vcs/git.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { gitProtectedBranch, gitPushFailed } from "../errors/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Typed git wrappers for the PR flow (C.15). Unlike the read-only helpers in
|
|
5
|
+
* `utils/git.ts`, these run *mutating* commands and must surface failures loudly,
|
|
6
|
+
* so {@link runGitCapture} returns stdout **and** stderr + exit code rather than
|
|
7
|
+
* collapsing everything to `null`.
|
|
8
|
+
*
|
|
9
|
+
* Safety rails are baked in here (defense-in-depth, independent of the gate):
|
|
10
|
+
* • never commit or push on a protected branch,
|
|
11
|
+
* • never `--force`, never `--no-verify` (the husky hooks are load-bearing).
|
|
12
|
+
*/
|
|
13
|
+
/** Hard ceiling per git invocation. Push runs the pre-push verify hook, which
|
|
14
|
+
* builds + tests the whole repo, so it gets a generous timeout. */
|
|
15
|
+
const GIT_TIMEOUT_MS = 10 * 60_000;
|
|
16
|
+
/** Run `git <args>` in `cwd`, capturing stdout/stderr/exit. Never throws. */
|
|
17
|
+
export function runGitCapture(args, cwd) {
|
|
18
|
+
const res = spawnSync("git", args, {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
timeout: GIT_TIMEOUT_MS,
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
// Push must reach the network and run the pre-push hook; don't cap output.
|
|
24
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
25
|
+
});
|
|
26
|
+
const stdout = typeof res.stdout === "string" ? res.stdout : "";
|
|
27
|
+
const stderr = typeof res.stderr === "string" ? res.stderr : "";
|
|
28
|
+
if (res.error) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
stdout,
|
|
32
|
+
stderr: stderr || res.error.message,
|
|
33
|
+
code: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { ok: res.status === 0, stdout, stderr, code: res.status };
|
|
37
|
+
}
|
|
38
|
+
/** The current branch name, or `null` when detached / not a repo. */
|
|
39
|
+
export function currentBranch(cwd) {
|
|
40
|
+
const res = runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
return null;
|
|
43
|
+
const branch = res.stdout.trim();
|
|
44
|
+
return branch === "" || branch === "HEAD" ? null : branch;
|
|
45
|
+
}
|
|
46
|
+
/** The default set of branch names cruxy will never write to directly. */
|
|
47
|
+
export const DEFAULT_PROTECTED_BRANCHES = ["main", "master"];
|
|
48
|
+
/**
|
|
49
|
+
* Is `branch` protected? The built-in `main`/`master` plus any names from config.
|
|
50
|
+
* Case-insensitive so `Main`/`MASTER` are caught too.
|
|
51
|
+
*/
|
|
52
|
+
export function isProtectedBranch(branch, extra = []) {
|
|
53
|
+
const all = new Set([...DEFAULT_PROTECTED_BRANCHES, ...extra].map((b) => b.toLowerCase()));
|
|
54
|
+
return all.has(branch.trim().toLowerCase());
|
|
55
|
+
}
|
|
56
|
+
/** Does a local branch already exist? */
|
|
57
|
+
function branchExists(cwd, branch) {
|
|
58
|
+
return runGitCapture(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd).ok;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Guarantee we're on a non-protected feature branch before any commit/push. If
|
|
62
|
+
* already on a feature branch, stay there. If on a protected branch, switch to
|
|
63
|
+
* `desired` (creating it if needed). Throws if `desired` is itself protected.
|
|
64
|
+
*/
|
|
65
|
+
export function ensureFeatureBranch(cwd, desired, protectedExtra = []) {
|
|
66
|
+
const current = currentBranch(cwd);
|
|
67
|
+
if (current && !isProtectedBranch(current, protectedExtra)) {
|
|
68
|
+
return { branch: current, switched: false };
|
|
69
|
+
}
|
|
70
|
+
if (isProtectedBranch(desired, protectedExtra)) {
|
|
71
|
+
// Refusing to "switch" onto another protected branch — that's not a fix.
|
|
72
|
+
throw gitProtectedBranch(desired);
|
|
73
|
+
}
|
|
74
|
+
const args = branchExists(cwd, desired)
|
|
75
|
+
? ["switch", desired]
|
|
76
|
+
: ["switch", "-c", desired];
|
|
77
|
+
const res = runGitCapture(args, cwd);
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
throw gitPushFailed(desired, res.stderr || `git ${args.join(" ")} failed`);
|
|
80
|
+
}
|
|
81
|
+
return { branch: desired, switched: true };
|
|
82
|
+
}
|
|
83
|
+
/** Stage every change in the working tree (`git add -A`). */
|
|
84
|
+
export function stageAll(cwd) {
|
|
85
|
+
const res = runGitCapture(["add", "-A"], cwd);
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
throw gitPushFailed(currentBranch(cwd) ?? "(unknown)", res.stderr || "git add -A failed");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Whether the working tree (staged or not) has any changes to commit. */
|
|
91
|
+
export function hasChanges(cwd) {
|
|
92
|
+
return runGitCapture(["status", "--porcelain"], cwd).stdout.trim() !== "";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Commit the staged changes with a conventional message. Refuses on a protected
|
|
96
|
+
* branch (last-line guard) and **never** passes `--no-verify`, so the commitlint
|
|
97
|
+
* `commit-msg` hook runs. Subject and body go as separate `-m` flags.
|
|
98
|
+
*/
|
|
99
|
+
export function commit(cwd, subject, body, protectedExtra = []) {
|
|
100
|
+
const branch = currentBranch(cwd);
|
|
101
|
+
if (branch && isProtectedBranch(branch, protectedExtra)) {
|
|
102
|
+
throw gitProtectedBranch(branch);
|
|
103
|
+
}
|
|
104
|
+
const args = ["commit", "-m", subject];
|
|
105
|
+
if (body.trim() !== "")
|
|
106
|
+
args.push("-m", body);
|
|
107
|
+
const res = runGitCapture(args, cwd);
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
// A rejected commit is almost always the commitlint hook — surface it as a
|
|
110
|
+
// push failure carrying git's own stderr (we never bypass the hook).
|
|
111
|
+
throw gitPushFailed(branch ?? "(unknown)", res.stderr || "git commit failed");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Push `branch` to `origin`, setting upstream. **Never** `--force`, **never**
|
|
116
|
+
* `--no-verify` — the pre-push hook (build · typecheck · lint · test + main
|
|
117
|
+
* guard) runs and a failure is surfaced verbatim. Refuses on a protected branch.
|
|
118
|
+
*/
|
|
119
|
+
export function push(cwd, branch, protectedExtra = []) {
|
|
120
|
+
if (isProtectedBranch(branch, protectedExtra)) {
|
|
121
|
+
throw gitProtectedBranch(branch);
|
|
122
|
+
}
|
|
123
|
+
const res = runGitCapture(["push", "-u", "origin", branch], cwd);
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
throw gitPushFailed(branch, res.stderr || "git push failed");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Does a commit-ish (branch, ref, SHA) resolve in this repo? */
|
|
129
|
+
function revExists(cwd, rev) {
|
|
130
|
+
return runGitCapture(["rev-parse", "--verify", "--quiet", rev], cwd).ok;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The change set for content generation: the working-tree diff relative to
|
|
134
|
+
* `base` (covers committed-on-branch *and* uncommitted edits), plus the names of
|
|
135
|
+
* untracked files (which a plain `git diff` omits). Falls back to the diff vs
|
|
136
|
+
* `HEAD` when `base` isn't reachable (shallow clone / brand-new repo).
|
|
137
|
+
*/
|
|
138
|
+
export function diffAgainst(cwd, base) {
|
|
139
|
+
const ref = revExists(cwd, base) ? base : "HEAD";
|
|
140
|
+
const tracked = revExists(cwd, ref)
|
|
141
|
+
? runGitCapture(["diff", ref], cwd).stdout
|
|
142
|
+
: "";
|
|
143
|
+
const untracked = runGitCapture(["ls-files", "--others", "--exclude-standard"], cwd).stdout.trim();
|
|
144
|
+
const parts = [tracked.trimEnd()];
|
|
145
|
+
if (untracked !== "") {
|
|
146
|
+
parts.push(`\n# untracked files (new):\n${untracked
|
|
147
|
+
.split("\n")
|
|
148
|
+
.map((f) => `+ ${f}`)
|
|
149
|
+
.join("\n")}`);
|
|
150
|
+
}
|
|
151
|
+
return parts.filter((p) => p.trim() !== "").join("\n");
|
|
152
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ForgeProvider, PullRequestResult, PullRequestSpec, RepoInfo } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* GitHub implementation of {@link ForgeProvider} (C.15) — the only forge that
|
|
4
|
+
* ships today. It talks to the REST API directly (no octokit dependency) and
|
|
5
|
+
* keeps `fetch` injectable for tests. Everything GitHub-specific lives here;
|
|
6
|
+
* nothing above {@link ForgeProvider} knows it's GitHub.
|
|
7
|
+
*/
|
|
8
|
+
export interface GitHubProviderOptions {
|
|
9
|
+
/** The resolved forge token (see `auth.ts`). */
|
|
10
|
+
token: string;
|
|
11
|
+
/** Injected fetch (tests / advanced); defaults to global `fetch`. */
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
}
|
|
14
|
+
export declare class GitHubProvider implements ForgeProvider {
|
|
15
|
+
readonly id = "github";
|
|
16
|
+
private readonly token;
|
|
17
|
+
private readonly fetchImpl;
|
|
18
|
+
constructor(opts: GitHubProviderOptions);
|
|
19
|
+
getRepoInfo(cwd: string): Promise<RepoInfo>;
|
|
20
|
+
createPullRequest(repo: RepoInfo, spec: PullRequestSpec): Promise<PullRequestResult>;
|
|
21
|
+
/** Look up an open PR for `owner:head`, or `null` if there isn't one. */
|
|
22
|
+
private findOpenPr;
|
|
23
|
+
private fetchDefaultBranch;
|
|
24
|
+
/** Issue an authenticated REST call, resolving the API base from the host. */
|
|
25
|
+
private api;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse a git remote URL into `{ host, owner, repo }`, supporting the https,
|
|
29
|
+
* `scp`-style ssh, and `ssh://` forms. Returns `null` for anything we can't
|
|
30
|
+
* confidently parse as `host/owner/repo`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseRemote(url: string): {
|
|
33
|
+
host: string;
|
|
34
|
+
owner: string;
|
|
35
|
+
repo: string;
|
|
36
|
+
} | null;
|
|
37
|
+
/**
|
|
38
|
+
* Construct the forge provider for a token. GitHub today; the GitLab/Bitbucket
|
|
39
|
+
* branch slots in here later (e.g. switch on a configured provider id) without
|
|
40
|
+
* touching the orchestrator.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createForgeProvider(token: string, opts?: {
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
}): ForgeProvider;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { forgeApi, forgeAuth, usageError } from "../errors/index.js";
|
|
2
|
+
import { runGitCapture } from "./git.js";
|
|
3
|
+
export class GitHubProvider {
|
|
4
|
+
id = "github";
|
|
5
|
+
token;
|
|
6
|
+
fetchImpl;
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
this.token = opts.token;
|
|
9
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
10
|
+
}
|
|
11
|
+
async getRepoInfo(cwd) {
|
|
12
|
+
const remote = runGitCapture(["remote", "get-url", "origin"], cwd);
|
|
13
|
+
if (!remote.ok || remote.stdout.trim() === "") {
|
|
14
|
+
throw usageError("no `origin` remote is configured for this repository", [
|
|
15
|
+
"add one with `git remote add origin <url>`",
|
|
16
|
+
"cruxy opens pull requests against the `origin` remote",
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
const parsed = parseRemote(remote.stdout.trim());
|
|
20
|
+
if (!parsed) {
|
|
21
|
+
throw usageError(`could not parse the origin remote as a GitHub repository: ${remote.stdout.trim()}`, ["ensure `origin` points at a github.com (or GitHub Enterprise) repo"]);
|
|
22
|
+
}
|
|
23
|
+
// Best-effort: resolve the default branch so the PR base can default to it.
|
|
24
|
+
const defaultBranch = await this.fetchDefaultBranch(parsed).catch(() => undefined);
|
|
25
|
+
return { ...parsed, defaultBranch };
|
|
26
|
+
}
|
|
27
|
+
async createPullRequest(repo, spec) {
|
|
28
|
+
const res = await this.api(repo, `/repos/${repo.owner}/${repo.repo}/pulls`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
title: spec.title,
|
|
32
|
+
body: spec.body,
|
|
33
|
+
head: spec.head,
|
|
34
|
+
base: spec.base,
|
|
35
|
+
draft: spec.draft ?? false,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
if (res.status === 201) {
|
|
39
|
+
const pr = (await res.json());
|
|
40
|
+
return { url: pr.html_url, number: pr.number, alreadyExists: false };
|
|
41
|
+
}
|
|
42
|
+
if (res.status === 401 || res.status === 403) {
|
|
43
|
+
throw forgeAuth(repo.host);
|
|
44
|
+
}
|
|
45
|
+
// A PR for this head already exists — find and return it instead of failing.
|
|
46
|
+
if (res.status === 422) {
|
|
47
|
+
const existing = await this.findOpenPr(repo, spec.head).catch(() => null);
|
|
48
|
+
if (existing) {
|
|
49
|
+
return {
|
|
50
|
+
url: existing.html_url,
|
|
51
|
+
number: existing.number,
|
|
52
|
+
alreadyExists: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
throw forgeApi("GitHub rejected the pull request (422) and no existing PR was found", await safeBody(res), { status: 422, owner: repo.owner, repo: repo.repo });
|
|
56
|
+
}
|
|
57
|
+
throw forgeApi(`GitHub returned HTTP ${res.status} while opening the pull request`, await safeBody(res), { status: res.status, owner: repo.owner, repo: repo.repo });
|
|
58
|
+
}
|
|
59
|
+
/** Look up an open PR for `owner:head`, or `null` if there isn't one. */
|
|
60
|
+
async findOpenPr(repo, head) {
|
|
61
|
+
const q = `head=${encodeURIComponent(`${repo.owner}:${head}`)}&state=open`;
|
|
62
|
+
const res = await this.api(repo, `/repos/${repo.owner}/${repo.repo}/pulls?${q}`, { method: "GET" });
|
|
63
|
+
if (!res.ok)
|
|
64
|
+
return null;
|
|
65
|
+
const prs = (await res.json());
|
|
66
|
+
return prs.length > 0 ? prs[0] : null;
|
|
67
|
+
}
|
|
68
|
+
async fetchDefaultBranch(repo) {
|
|
69
|
+
const res = await this.api(repo, `/repos/${repo.owner}/${repo.repo}`, {
|
|
70
|
+
method: "GET",
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
return undefined;
|
|
74
|
+
const body = (await res.json());
|
|
75
|
+
return body.default_branch;
|
|
76
|
+
}
|
|
77
|
+
/** Issue an authenticated REST call, resolving the API base from the host. */
|
|
78
|
+
api(repo, path, init) {
|
|
79
|
+
return this.fetchImpl(`${apiBase(repo.host)}${path}`, {
|
|
80
|
+
...init,
|
|
81
|
+
headers: {
|
|
82
|
+
accept: "application/vnd.github+json",
|
|
83
|
+
authorization: `Bearer ${this.token}`,
|
|
84
|
+
"x-github-api-version": "2022-11-28",
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
"user-agent": "cruxy-cli",
|
|
87
|
+
...init.headers,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** The REST API base for a host: github.com → api.github.com; GHE → /api/v3. */
|
|
93
|
+
function apiBase(host) {
|
|
94
|
+
return host === "github.com"
|
|
95
|
+
? "https://api.github.com"
|
|
96
|
+
: `https://${host}/api/v3`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Parse a git remote URL into `{ host, owner, repo }`, supporting the https,
|
|
100
|
+
* `scp`-style ssh, and `ssh://` forms. Returns `null` for anything we can't
|
|
101
|
+
* confidently parse as `host/owner/repo`.
|
|
102
|
+
*/
|
|
103
|
+
export function parseRemote(url) {
|
|
104
|
+
const trimmed = url.trim().replace(/\.git$/, "");
|
|
105
|
+
// scp-style: git@github.com:owner/repo
|
|
106
|
+
const scp = /^[^@]+@([^:]+):(.+)$/.exec(trimmed);
|
|
107
|
+
if (scp)
|
|
108
|
+
return splitOwnerRepo(scp[1], scp[2]);
|
|
109
|
+
// url-style: https://… , ssh://… , git://…
|
|
110
|
+
try {
|
|
111
|
+
const u = new URL(trimmed);
|
|
112
|
+
return splitOwnerRepo(u.host, u.pathname.replace(/^\//, ""));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function splitOwnerRepo(host, ownerRepo) {
|
|
119
|
+
const parts = ownerRepo.split("/").filter((p) => p !== "");
|
|
120
|
+
if (parts.length < 2)
|
|
121
|
+
return null;
|
|
122
|
+
// Owner is the first segment, repo the last (handles GHE org/sub paths simply).
|
|
123
|
+
const owner = parts[0];
|
|
124
|
+
const repo = parts[parts.length - 1];
|
|
125
|
+
if (!owner || !repo)
|
|
126
|
+
return null;
|
|
127
|
+
return { host, owner, repo };
|
|
128
|
+
}
|
|
129
|
+
/** Read a response body for error context without throwing on a parse failure. */
|
|
130
|
+
async function safeBody(res) {
|
|
131
|
+
try {
|
|
132
|
+
return await res.text();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return `HTTP ${res.status}`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Construct the forge provider for a token. GitHub today; the GitLab/Bitbucket
|
|
140
|
+
* branch slots in here later (e.g. switch on a configured provider id) without
|
|
141
|
+
* touching the orchestrator.
|
|
142
|
+
*/
|
|
143
|
+
export function createForgeProvider(token, opts = {}) {
|
|
144
|
+
return new GitHubProvider({ token, fetchImpl: opts.fetchImpl });
|
|
145
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repo's commit conventions, gathered best-effort so PR generation honors
|
|
3
|
+
* them automatically (C.15): the C.18 git-commit skill body and the commitlint
|
|
4
|
+
* scope allow-list. Both are optional — a repo may have neither — so every lookup
|
|
5
|
+
* degrades to a sensible empty default rather than failing the PR flow.
|
|
6
|
+
*/
|
|
7
|
+
export interface CommitGuidance {
|
|
8
|
+
/** The git-commit SKILL.md body, embedded verbatim into the LLM prompt. */
|
|
9
|
+
skillBody?: string;
|
|
10
|
+
/** commitlint `scope-enum` values; empty ⇒ no scope constraint. */
|
|
11
|
+
scopes: string[];
|
|
12
|
+
}
|
|
13
|
+
/** Load the git-commit skill body + commitlint scopes for `cwd`. Never throws. */
|
|
14
|
+
export declare function loadCommitGuidance(cwd: string): Promise<CommitGuidance>;
|
|
15
|
+
/**
|
|
16
|
+
* Extract the `scope-enum` allow-list from a repo's commitlint config. Supports
|
|
17
|
+
* the JS/CJS module forms (imported) and JSON. Any failure ⇒ `[]` (no
|
|
18
|
+
* constraint), so a malformed or absent config never blocks a PR.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadCommitlintScopes(cwd: string): Promise<string[]>;
|