@lifeaitools/clauth 1.30.23 → 1.30.25
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/.clauth-skill/SKILL.md +306 -275
- package/.clauth-skill/references/operator-guide.md +175 -148
- package/README.md +363 -315
- package/cli/api.classify.test.js +75 -75
- package/cli/assets/codevelop/launcher-active.cmd.template +20 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +7 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +48 -48
- package/cli/assets/watchdog.ps1 +42 -42
- package/cli/commands/agent-cron.js +396 -396
- package/cli/commands/agent-pool.js +1962 -1962
- package/cli/commands/codevelop.js +1190 -1190
- package/cli/commands/doctor.js +302 -302
- package/cli/commands/install.js +10 -10
- package/cli/commands/invite.js +175 -175
- package/cli/commands/join.js +179 -179
- package/cli/commands/npm.js +182 -182
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/scrub.js +327 -327
- package/cli/commands/scrub.test.js +115 -115
- package/cli/commands/serve.js +381 -98
- package/cli/commands/watchdog.js +209 -209
- package/cli/conf-path.js +21 -21
- package/cli/enrollment-script.js +82 -82
- package/cli/fingerprint.js +143 -143
- package/cli/index.js +1073 -1053
- package/cli/lib/fs-git.js +282 -282
- package/cli/ops/coolify-adapter.js +80 -0
- package/cli/ops/deployment-adapter.js +63 -0
- package/cli/ops/job-store.js +116 -0
- package/cli/ops/operation-policy.js +51 -0
- package/cli/ops/pm2-adapter.js +128 -0
- package/cli/ops/serialized-executor.js +9 -0
- package/cli/recovery.js +101 -101
- package/cli/studio-debug.js +1095 -1095
- package/cli/supervisor-registry.js +594 -589
- package/cli/supervisor-registry.test.js +397 -397
- package/cli/supervisor-ui.test.js +5 -83
- package/cli/watchdog-registry.js +237 -209
- package/cli/watchdog-registry.test.js +112 -89
- package/install.ps1 +21 -21
- package/package.json +4 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/supabase/migrations/001_clauth_schema.sql +12 -12
- package/supabase/migrations/003_clauth_config.sql +13 -13
- package/supabase/migrations/003_machine_enrollments.sql +39 -39
- package/cli/served-script-syntax.test.mjs +0 -54
package/cli/lib/fs-git.js
CHANGED
|
@@ -1,282 +1,282 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* fs-git.js — pure git operations behind the FS-MCP git verbs.
|
|
3
|
-
*
|
|
4
|
-
* No dependency on the vault, MCP transport, or mount resolution. The serve.js
|
|
5
|
-
* handlers resolve the mount + ACL, fetch the push token from the vault, then
|
|
6
|
-
* call these functions. Keeping the git logic here makes it directly testable
|
|
7
|
-
* against real repositories (see test-fs-git.mjs).
|
|
8
|
-
*
|
|
9
|
-
* Each high-level function returns { error } (→ caller emits an MCP error) or
|
|
10
|
-
* { result } (→ caller emits the JSON result). Fatal/unexpected git failures
|
|
11
|
-
* throw and are caught by the caller.
|
|
12
|
-
*
|
|
13
|
-
* NON-BLOCKING: every git invocation uses async spawn, never spawnSync, so the
|
|
14
|
-
* single-threaded clauth daemon keeps serving requests during a network push.
|
|
15
|
-
*/
|
|
16
|
-
import { spawn } from "child_process";
|
|
17
|
-
import fs from "fs";
|
|
18
|
-
import path from "path";
|
|
19
|
-
|
|
20
|
-
// Branches the git verbs refuse to push to directly — humans promote these.
|
|
21
|
-
export const FS_GIT_PROTECTED_BRANCHES = new Set(["main", "master", "production", "prod"]);
|
|
22
|
-
|
|
23
|
-
// Async git runner. opts.label replaces args in error text (to hide auth
|
|
24
|
-
// headers); opts.scrub redacts a substring from error detail before throwing.
|
|
25
|
-
export function runGitAsync(cwd, args, opts = {}) {
|
|
26
|
-
return new Promise((resolve, reject) => {
|
|
27
|
-
const proc = spawn("git", args, { cwd, windowsHide: true });
|
|
28
|
-
let out = "", err = "";
|
|
29
|
-
proc.stdout.on("data", (d) => { out += d.toString(); });
|
|
30
|
-
proc.stderr.on("data", (d) => { err += d.toString(); });
|
|
31
|
-
proc.on("error", reject);
|
|
32
|
-
proc.on("close", (code) => {
|
|
33
|
-
if (code !== 0) {
|
|
34
|
-
let detail = (err || out).trim();
|
|
35
|
-
if (opts.scrub) detail = detail.split(opts.scrub).join("***");
|
|
36
|
-
const what = opts.label || `git ${args.join(" ")}`;
|
|
37
|
-
return reject(new Error(`${what} failed${detail ? `: ${detail}` : ""}`));
|
|
38
|
-
}
|
|
39
|
-
resolve(out.trim());
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Push with an inline GitHub token injected as a one-shot Authorization header.
|
|
45
|
-
// Token is NEVER persisted to git config, NEVER placed in the remote URL, and
|
|
46
|
-
// is scrubbed from any error text.
|
|
47
|
-
export function runGitPushAuthed(cwd, token, remote, refspec, extraArgs = []) {
|
|
48
|
-
const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
49
|
-
const args = ["-c", `http.extraheader=AUTHORIZATION: basic ${basic}`, "push", ...extraArgs, remote, refspec];
|
|
50
|
-
return runGitAsync(cwd, args, { label: `git push ${remote} ${refspec}`, scrub: basic });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Detect an in-progress merge or rebase in the repo at cwd.
|
|
54
|
-
export async function gitInProgressState(cwd) {
|
|
55
|
-
let gitDir;
|
|
56
|
-
try { gitDir = await runGitAsync(cwd, ["rev-parse", "--git-dir"]); } catch { return { merge: false, rebase: false }; }
|
|
57
|
-
const gd = path.isAbsolute(gitDir) ? gitDir : path.join(cwd, gitDir);
|
|
58
|
-
const merge = fs.existsSync(path.join(gd, "MERGE_HEAD"));
|
|
59
|
-
const rebase = fs.existsSync(path.join(gd, "rebase-merge")) || fs.existsSync(path.join(gd, "rebase-apply"));
|
|
60
|
-
return { merge, rebase };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function normalizeRepoPath(p) {
|
|
64
|
-
if (!p || typeof p !== "string") return null;
|
|
65
|
-
const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
66
|
-
const parts = normalized.split("/").filter(Boolean);
|
|
67
|
-
if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
|
|
68
|
-
return parts.join("/");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function aheadBehind(repoRoot) {
|
|
72
|
-
try {
|
|
73
|
-
const counts = await runGitAsync(repoRoot, ["rev-list", "--left-right", "--count", "@{u}...HEAD"]);
|
|
74
|
-
const [b, a] = counts.split(/\s+/);
|
|
75
|
-
return { behind: Number(b), ahead: Number(a) };
|
|
76
|
-
} catch {
|
|
77
|
-
return { behind: null, ahead: null };
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Current git state of the repo in one call. */
|
|
82
|
-
export async function repoStatus(repoRoot) {
|
|
83
|
-
const topLevel = path.normalize(await runGitAsync(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
84
|
-
const branch = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
85
|
-
const head = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
|
|
86
|
-
const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
87
|
-
let subject = ""; try { subject = await runGitAsync(repoRoot, ["log", "-1", "--pretty=%s"]); } catch {}
|
|
88
|
-
const porcelain = await runGitAsync(repoRoot, ["status", "--porcelain"]);
|
|
89
|
-
const dirty = porcelain ? porcelain.split("\n").map((l) => l.trim()).filter(Boolean) : [];
|
|
90
|
-
const stagedRaw = await runGitAsync(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
91
|
-
let upstream = null;
|
|
92
|
-
try { upstream = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); } catch {}
|
|
93
|
-
const { ahead, behind } = await aheadBehind(repoRoot);
|
|
94
|
-
const prog = await gitInProgressState(repoRoot);
|
|
95
|
-
return {
|
|
96
|
-
result: {
|
|
97
|
-
branch, head, head_short: headShort, subject,
|
|
98
|
-
clean: dirty.length === 0,
|
|
99
|
-
dirty_count: dirty.length,
|
|
100
|
-
dirty_paths: dirty.slice(0, 100),
|
|
101
|
-
staged_paths: stagedRaw ? stagedRaw.split("\n").filter(Boolean) : [],
|
|
102
|
-
upstream, ahead, behind,
|
|
103
|
-
merge_in_progress: prog.merge,
|
|
104
|
-
rebase_in_progress: prog.rebase,
|
|
105
|
-
repo_root: topLevel,
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Safely switch to (or create) a branch. */
|
|
111
|
-
export async function useBranch(repoRoot, branch, create) {
|
|
112
|
-
branch = (branch || "").trim();
|
|
113
|
-
if (!branch) return { error: "branch is required" };
|
|
114
|
-
const prog = await gitInProgressState(repoRoot);
|
|
115
|
-
if (prog.merge || prog.rebase) return { error: "Refused: a merge or rebase is in progress. Finish or abort it before switching branches." };
|
|
116
|
-
let exists = true;
|
|
117
|
-
try { await runGitAsync(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]); } catch { exists = false; }
|
|
118
|
-
if (create && exists) return { error: `Branch already exists: ${branch}. Call again with create=false to switch to it.` };
|
|
119
|
-
if (!create && !exists) return { error: `Branch does not exist: ${branch}. Call again with create=true to create it from HEAD.` };
|
|
120
|
-
if (create) {
|
|
121
|
-
await runGitAsync(repoRoot, ["switch", "-c", branch]);
|
|
122
|
-
} else {
|
|
123
|
-
try {
|
|
124
|
-
await runGitAsync(repoRoot, ["switch", branch]);
|
|
125
|
-
} catch (e) {
|
|
126
|
-
return { error: `Cannot switch to ${branch}: ${e.message}. Commit your changes with fs_commit first — your working tree was left untouched.` };
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
130
|
-
return { result: { status: "ok", branch, created: !!create, head_short: headShort } };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Stage + commit (+ push by default).
|
|
135
|
-
* opts: { message, paths?, push=true, remote="origin", token?, tokenError?, authorName?, authorEmail? }
|
|
136
|
-
* token/tokenError are supplied by the caller after a vault lookup; commit
|
|
137
|
-
* happens first, then push, so the no-token case still preserves the commit.
|
|
138
|
-
*/
|
|
139
|
-
export async function commit(repoRoot, opts = {}) {
|
|
140
|
-
const message = (opts.message || "").trim();
|
|
141
|
-
if (!message && !opts.dryRun) return { error: "message is required" };
|
|
142
|
-
const push = opts.push !== false;
|
|
143
|
-
const remote = opts.remote || "origin";
|
|
144
|
-
|
|
145
|
-
const topLevel = path.normalize(await runGitAsync(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
146
|
-
if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
|
|
147
|
-
return { error: `Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})` };
|
|
148
|
-
}
|
|
149
|
-
const prog = await gitInProgressState(repoRoot);
|
|
150
|
-
if (prog.merge || prog.rebase) return { error: "Refused: a merge or rebase is in progress. Resolve conflicts and finish it before committing." };
|
|
151
|
-
const branch = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
152
|
-
if (branch === "HEAD") return { error: "Refused: detached HEAD. Use fs_use_branch to get on a branch first." };
|
|
153
|
-
|
|
154
|
-
// Optional optimistic-concurrency guard: refuse if the base moved under us.
|
|
155
|
-
if (opts.expectedHead) {
|
|
156
|
-
const cur = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
|
|
157
|
-
if (cur !== opts.expectedHead) {
|
|
158
|
-
return { error: `Base moved: expected HEAD ${opts.expectedHead} but current is ${cur}. Re-read the files and retry so you don't commit on top of a base that changed.` };
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Normalize explicit paths once (used by dry-run preview and staging).
|
|
163
|
-
let pathArgs = [];
|
|
164
|
-
if (Array.isArray(opts.paths) && opts.paths.length > 0) {
|
|
165
|
-
if (opts.paths.length > 100) return { error: "Too many paths: max 100 per commit" };
|
|
166
|
-
for (const p of opts.paths) {
|
|
167
|
-
const n = normalizeRepoPath(p);
|
|
168
|
-
if (!n) return { error: `Invalid repo path: ${p}` };
|
|
169
|
-
pathArgs.push(n);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Dry run: report what WOULD be committed/pushed without mutating anything.
|
|
174
|
-
if (opts.dryRun) {
|
|
175
|
-
const tail = pathArgs.length ? ["--", ...pathArgs] : [];
|
|
176
|
-
// Mirror `git add -A`: tracked changes (diff vs HEAD) ∪ untracked files.
|
|
177
|
-
const tracked = await runGitAsync(repoRoot, ["diff", "--name-only", "HEAD", ...tail]);
|
|
178
|
-
const untracked = await runGitAsync(repoRoot, ["ls-files", "--others", "--exclude-standard", ...tail]);
|
|
179
|
-
const files = [...new Set(
|
|
180
|
-
[...(tracked ? tracked.split("\n") : []), ...(untracked ? untracked.split("\n") : [])]
|
|
181
|
-
.map((s) => s.trim()).filter(Boolean)
|
|
182
|
-
)];
|
|
183
|
-
const { ahead, behind } = await aheadBehind(repoRoot);
|
|
184
|
-
const isProtected = FS_GIT_PROTECTED_BRANCHES.has(branch.toLowerCase());
|
|
185
|
-
return { result: { status: "dry_run", branch, would_commit: files, file_count: files.length, would_push: push && !isProtected, protected_branch: isProtected, target: push && !isProtected ? `${remote}/${branch}` : null, ahead, behind } };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Stage
|
|
189
|
-
if (pathArgs.length) {
|
|
190
|
-
await runGitAsync(repoRoot, ["add", "--", ...pathArgs]);
|
|
191
|
-
} else {
|
|
192
|
-
await runGitAsync(repoRoot, ["add", "-A"]);
|
|
193
|
-
}
|
|
194
|
-
const stagedRaw = await runGitAsync(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
195
|
-
if (!stagedRaw) {
|
|
196
|
-
const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
197
|
-
return { result: { status: "nothing_to_commit", branch, head_short: headShort, message: "No changes in the requested scope; nothing was committed." } };
|
|
198
|
-
}
|
|
199
|
-
const committedFiles = stagedRaw.split("\n").filter(Boolean);
|
|
200
|
-
|
|
201
|
-
await runGitAsync(repoRoot, [
|
|
202
|
-
"-c", `user.name=${opts.authorName || "clauth-fs"}`,
|
|
203
|
-
"-c", `user.email=${opts.authorEmail || "fs@clauth.local"}`,
|
|
204
|
-
"commit", "-m", message,
|
|
205
|
-
]);
|
|
206
|
-
const commitSha = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
|
|
207
|
-
const commitShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
208
|
-
const base = { branch, commit: commitSha, commit_short: commitShort, files: committedFiles };
|
|
209
|
-
|
|
210
|
-
if (!push) {
|
|
211
|
-
return { result: { status: "committed_local", ...base, pushed: false, message: `Committed ${committedFiles.length} file(s) locally as ${commitShort}.` } };
|
|
212
|
-
}
|
|
213
|
-
if (FS_GIT_PROTECTED_BRANCHES.has(branch.toLowerCase())) {
|
|
214
|
-
return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "protected_branch", message: `Commit saved locally as ${commitShort}. Push refused: '${branch}' is protected — a human promotes it. Use fs_use_branch to move to a feature/develop branch to push directly.` } };
|
|
215
|
-
}
|
|
216
|
-
if (!opts.token) {
|
|
217
|
-
return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "no_token", message: `Commit saved locally as ${commitShort}. Push blocked: ${opts.tokenError || "no github token available"}.` } };
|
|
218
|
-
}
|
|
219
|
-
const token = opts.token.trim();
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
await runGitPushAuthed(repoRoot, token, remote, `HEAD:${branch}`);
|
|
223
|
-
} catch (e1) {
|
|
224
|
-
// Branch likely diverged — integrate then retry once.
|
|
225
|
-
try { await runGitAsync(repoRoot, ["fetch", "--no-tags", remote, branch]); } catch { /* branch may not exist on remote yet */ }
|
|
226
|
-
try {
|
|
227
|
-
await runGitAsync(repoRoot, ["rebase", `${remote}/${branch}`]);
|
|
228
|
-
} catch {
|
|
229
|
-
try { await runGitAsync(repoRoot, ["rebase", "--abort"]); } catch {}
|
|
230
|
-
return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "diverged", message: `Commit saved locally as ${commitShort}. Push blocked: '${branch}' diverged from ${remote} and auto-rebase hit conflicts (rebase aborted, tree restored). A human should reconcile.` } };
|
|
231
|
-
}
|
|
232
|
-
try {
|
|
233
|
-
await runGitPushAuthed(repoRoot, token, remote, `HEAD:${branch}`);
|
|
234
|
-
} catch (e2) {
|
|
235
|
-
const sha2 = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
236
|
-
return { result: { status: "committed_local_not_pushed", ...base, commit_short: sha2, pushed: false, push_blocked: "push_failed", message: `Commit saved and rebased locally (${sha2}) but the push still failed: ${e2.message}` } };
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const finalSha = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
|
|
241
|
-
const finalShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
242
|
-
const { ahead, behind } = await aheadBehind(repoRoot);
|
|
243
|
-
return { result: { status: "committed_and_pushed", branch, commit: finalSha, commit_short: finalShort, files: committedFiles, pushed: true, remote, ahead, behind, message: `Committed and pushed ${committedFiles.length} file(s) to ${remote}/${branch} as ${finalShort}.` } };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Unified diff of the working tree (or index) — self-verify before pushing, or
|
|
248
|
-
* compare against a remote ref to catch staleness.
|
|
249
|
-
* opts: { paths?, ref?, staged? }
|
|
250
|
-
* - default: working tree vs HEAD (everything uncommitted)
|
|
251
|
-
* - ref:"origin/x": working tree vs that ref (remote compare / staleness)
|
|
252
|
-
* - staged:true: index vs HEAD (only what's staged)
|
|
253
|
-
* Patch is capped; `truncated` flags when the cap was hit.
|
|
254
|
-
*/
|
|
255
|
-
export async function diff(repoRoot, opts = {}) {
|
|
256
|
-
const MAX = 60000;
|
|
257
|
-
let pathArgs = [];
|
|
258
|
-
if (Array.isArray(opts.paths) && opts.paths.length > 0) {
|
|
259
|
-
for (const p of opts.paths) {
|
|
260
|
-
const n = normalizeRepoPath(p);
|
|
261
|
-
if (!n) return { error: `Invalid repo path: ${p}` };
|
|
262
|
-
pathArgs.push(n);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
const flags = [];
|
|
266
|
-
if (opts.staged) flags.push("--cached");
|
|
267
|
-
const ref = opts.ref || (opts.staged ? null : "HEAD");
|
|
268
|
-
if (ref) flags.push(ref);
|
|
269
|
-
const tail = pathArgs.length ? ["--", ...pathArgs] : [];
|
|
270
|
-
|
|
271
|
-
// Validate ref up front so a typo yields a clean error, not a raw git failure.
|
|
272
|
-
if (ref && ref !== "HEAD") {
|
|
273
|
-
try { await runGitAsync(repoRoot, ["rev-parse", "--verify", "--quiet", ref + "^{commit}"]); }
|
|
274
|
-
catch { return { error: `Unknown ref: ${opts.ref} (try fetching it first, e.g. origin/<branch>)` }; }
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const stat = await runGitAsync(repoRoot, ["diff", ...flags, "--stat", ...tail]);
|
|
278
|
-
let patch = await runGitAsync(repoRoot, ["diff", ...flags, ...tail]);
|
|
279
|
-
let truncated = false;
|
|
280
|
-
if (patch.length > MAX) { patch = patch.slice(0, MAX) + "\n... (diff truncated at 60KB)"; truncated = true; }
|
|
281
|
-
return { result: { compared_to: ref || "index", staged: !!opts.staged, stat: stat || "(no differences)", patch: patch || "(no differences)", truncated } };
|
|
282
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* fs-git.js — pure git operations behind the FS-MCP git verbs.
|
|
3
|
+
*
|
|
4
|
+
* No dependency on the vault, MCP transport, or mount resolution. The serve.js
|
|
5
|
+
* handlers resolve the mount + ACL, fetch the push token from the vault, then
|
|
6
|
+
* call these functions. Keeping the git logic here makes it directly testable
|
|
7
|
+
* against real repositories (see test-fs-git.mjs).
|
|
8
|
+
*
|
|
9
|
+
* Each high-level function returns { error } (→ caller emits an MCP error) or
|
|
10
|
+
* { result } (→ caller emits the JSON result). Fatal/unexpected git failures
|
|
11
|
+
* throw and are caught by the caller.
|
|
12
|
+
*
|
|
13
|
+
* NON-BLOCKING: every git invocation uses async spawn, never spawnSync, so the
|
|
14
|
+
* single-threaded clauth daemon keeps serving requests during a network push.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "child_process";
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
import path from "path";
|
|
19
|
+
|
|
20
|
+
// Branches the git verbs refuse to push to directly — humans promote these.
|
|
21
|
+
export const FS_GIT_PROTECTED_BRANCHES = new Set(["main", "master", "production", "prod"]);
|
|
22
|
+
|
|
23
|
+
// Async git runner. opts.label replaces args in error text (to hide auth
|
|
24
|
+
// headers); opts.scrub redacts a substring from error detail before throwing.
|
|
25
|
+
export function runGitAsync(cwd, args, opts = {}) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const proc = spawn("git", args, { cwd, windowsHide: true });
|
|
28
|
+
let out = "", err = "";
|
|
29
|
+
proc.stdout.on("data", (d) => { out += d.toString(); });
|
|
30
|
+
proc.stderr.on("data", (d) => { err += d.toString(); });
|
|
31
|
+
proc.on("error", reject);
|
|
32
|
+
proc.on("close", (code) => {
|
|
33
|
+
if (code !== 0) {
|
|
34
|
+
let detail = (err || out).trim();
|
|
35
|
+
if (opts.scrub) detail = detail.split(opts.scrub).join("***");
|
|
36
|
+
const what = opts.label || `git ${args.join(" ")}`;
|
|
37
|
+
return reject(new Error(`${what} failed${detail ? `: ${detail}` : ""}`));
|
|
38
|
+
}
|
|
39
|
+
resolve(out.trim());
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Push with an inline GitHub token injected as a one-shot Authorization header.
|
|
45
|
+
// Token is NEVER persisted to git config, NEVER placed in the remote URL, and
|
|
46
|
+
// is scrubbed from any error text.
|
|
47
|
+
export function runGitPushAuthed(cwd, token, remote, refspec, extraArgs = []) {
|
|
48
|
+
const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
49
|
+
const args = ["-c", `http.extraheader=AUTHORIZATION: basic ${basic}`, "push", ...extraArgs, remote, refspec];
|
|
50
|
+
return runGitAsync(cwd, args, { label: `git push ${remote} ${refspec}`, scrub: basic });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Detect an in-progress merge or rebase in the repo at cwd.
|
|
54
|
+
export async function gitInProgressState(cwd) {
|
|
55
|
+
let gitDir;
|
|
56
|
+
try { gitDir = await runGitAsync(cwd, ["rev-parse", "--git-dir"]); } catch { return { merge: false, rebase: false }; }
|
|
57
|
+
const gd = path.isAbsolute(gitDir) ? gitDir : path.join(cwd, gitDir);
|
|
58
|
+
const merge = fs.existsSync(path.join(gd, "MERGE_HEAD"));
|
|
59
|
+
const rebase = fs.existsSync(path.join(gd, "rebase-merge")) || fs.existsSync(path.join(gd, "rebase-apply"));
|
|
60
|
+
return { merge, rebase };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeRepoPath(p) {
|
|
64
|
+
if (!p || typeof p !== "string") return null;
|
|
65
|
+
const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
66
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
67
|
+
if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
|
|
68
|
+
return parts.join("/");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function aheadBehind(repoRoot) {
|
|
72
|
+
try {
|
|
73
|
+
const counts = await runGitAsync(repoRoot, ["rev-list", "--left-right", "--count", "@{u}...HEAD"]);
|
|
74
|
+
const [b, a] = counts.split(/\s+/);
|
|
75
|
+
return { behind: Number(b), ahead: Number(a) };
|
|
76
|
+
} catch {
|
|
77
|
+
return { behind: null, ahead: null };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Current git state of the repo in one call. */
|
|
82
|
+
export async function repoStatus(repoRoot) {
|
|
83
|
+
const topLevel = path.normalize(await runGitAsync(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
84
|
+
const branch = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
85
|
+
const head = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
|
|
86
|
+
const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
87
|
+
let subject = ""; try { subject = await runGitAsync(repoRoot, ["log", "-1", "--pretty=%s"]); } catch {}
|
|
88
|
+
const porcelain = await runGitAsync(repoRoot, ["status", "--porcelain"]);
|
|
89
|
+
const dirty = porcelain ? porcelain.split("\n").map((l) => l.trim()).filter(Boolean) : [];
|
|
90
|
+
const stagedRaw = await runGitAsync(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
91
|
+
let upstream = null;
|
|
92
|
+
try { upstream = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); } catch {}
|
|
93
|
+
const { ahead, behind } = await aheadBehind(repoRoot);
|
|
94
|
+
const prog = await gitInProgressState(repoRoot);
|
|
95
|
+
return {
|
|
96
|
+
result: {
|
|
97
|
+
branch, head, head_short: headShort, subject,
|
|
98
|
+
clean: dirty.length === 0,
|
|
99
|
+
dirty_count: dirty.length,
|
|
100
|
+
dirty_paths: dirty.slice(0, 100),
|
|
101
|
+
staged_paths: stagedRaw ? stagedRaw.split("\n").filter(Boolean) : [],
|
|
102
|
+
upstream, ahead, behind,
|
|
103
|
+
merge_in_progress: prog.merge,
|
|
104
|
+
rebase_in_progress: prog.rebase,
|
|
105
|
+
repo_root: topLevel,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Safely switch to (or create) a branch. */
|
|
111
|
+
export async function useBranch(repoRoot, branch, create) {
|
|
112
|
+
branch = (branch || "").trim();
|
|
113
|
+
if (!branch) return { error: "branch is required" };
|
|
114
|
+
const prog = await gitInProgressState(repoRoot);
|
|
115
|
+
if (prog.merge || prog.rebase) return { error: "Refused: a merge or rebase is in progress. Finish or abort it before switching branches." };
|
|
116
|
+
let exists = true;
|
|
117
|
+
try { await runGitAsync(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]); } catch { exists = false; }
|
|
118
|
+
if (create && exists) return { error: `Branch already exists: ${branch}. Call again with create=false to switch to it.` };
|
|
119
|
+
if (!create && !exists) return { error: `Branch does not exist: ${branch}. Call again with create=true to create it from HEAD.` };
|
|
120
|
+
if (create) {
|
|
121
|
+
await runGitAsync(repoRoot, ["switch", "-c", branch]);
|
|
122
|
+
} else {
|
|
123
|
+
try {
|
|
124
|
+
await runGitAsync(repoRoot, ["switch", branch]);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { error: `Cannot switch to ${branch}: ${e.message}. Commit your changes with fs_commit first — your working tree was left untouched.` };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
130
|
+
return { result: { status: "ok", branch, created: !!create, head_short: headShort } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Stage + commit (+ push by default).
|
|
135
|
+
* opts: { message, paths?, push=true, remote="origin", token?, tokenError?, authorName?, authorEmail? }
|
|
136
|
+
* token/tokenError are supplied by the caller after a vault lookup; commit
|
|
137
|
+
* happens first, then push, so the no-token case still preserves the commit.
|
|
138
|
+
*/
|
|
139
|
+
export async function commit(repoRoot, opts = {}) {
|
|
140
|
+
const message = (opts.message || "").trim();
|
|
141
|
+
if (!message && !opts.dryRun) return { error: "message is required" };
|
|
142
|
+
const push = opts.push !== false;
|
|
143
|
+
const remote = opts.remote || "origin";
|
|
144
|
+
|
|
145
|
+
const topLevel = path.normalize(await runGitAsync(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
146
|
+
if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
|
|
147
|
+
return { error: `Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})` };
|
|
148
|
+
}
|
|
149
|
+
const prog = await gitInProgressState(repoRoot);
|
|
150
|
+
if (prog.merge || prog.rebase) return { error: "Refused: a merge or rebase is in progress. Resolve conflicts and finish it before committing." };
|
|
151
|
+
const branch = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
152
|
+
if (branch === "HEAD") return { error: "Refused: detached HEAD. Use fs_use_branch to get on a branch first." };
|
|
153
|
+
|
|
154
|
+
// Optional optimistic-concurrency guard: refuse if the base moved under us.
|
|
155
|
+
if (opts.expectedHead) {
|
|
156
|
+
const cur = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
|
|
157
|
+
if (cur !== opts.expectedHead) {
|
|
158
|
+
return { error: `Base moved: expected HEAD ${opts.expectedHead} but current is ${cur}. Re-read the files and retry so you don't commit on top of a base that changed.` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Normalize explicit paths once (used by dry-run preview and staging).
|
|
163
|
+
let pathArgs = [];
|
|
164
|
+
if (Array.isArray(opts.paths) && opts.paths.length > 0) {
|
|
165
|
+
if (opts.paths.length > 100) return { error: "Too many paths: max 100 per commit" };
|
|
166
|
+
for (const p of opts.paths) {
|
|
167
|
+
const n = normalizeRepoPath(p);
|
|
168
|
+
if (!n) return { error: `Invalid repo path: ${p}` };
|
|
169
|
+
pathArgs.push(n);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Dry run: report what WOULD be committed/pushed without mutating anything.
|
|
174
|
+
if (opts.dryRun) {
|
|
175
|
+
const tail = pathArgs.length ? ["--", ...pathArgs] : [];
|
|
176
|
+
// Mirror `git add -A`: tracked changes (diff vs HEAD) ∪ untracked files.
|
|
177
|
+
const tracked = await runGitAsync(repoRoot, ["diff", "--name-only", "HEAD", ...tail]);
|
|
178
|
+
const untracked = await runGitAsync(repoRoot, ["ls-files", "--others", "--exclude-standard", ...tail]);
|
|
179
|
+
const files = [...new Set(
|
|
180
|
+
[...(tracked ? tracked.split("\n") : []), ...(untracked ? untracked.split("\n") : [])]
|
|
181
|
+
.map((s) => s.trim()).filter(Boolean)
|
|
182
|
+
)];
|
|
183
|
+
const { ahead, behind } = await aheadBehind(repoRoot);
|
|
184
|
+
const isProtected = FS_GIT_PROTECTED_BRANCHES.has(branch.toLowerCase());
|
|
185
|
+
return { result: { status: "dry_run", branch, would_commit: files, file_count: files.length, would_push: push && !isProtected, protected_branch: isProtected, target: push && !isProtected ? `${remote}/${branch}` : null, ahead, behind } };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Stage
|
|
189
|
+
if (pathArgs.length) {
|
|
190
|
+
await runGitAsync(repoRoot, ["add", "--", ...pathArgs]);
|
|
191
|
+
} else {
|
|
192
|
+
await runGitAsync(repoRoot, ["add", "-A"]);
|
|
193
|
+
}
|
|
194
|
+
const stagedRaw = await runGitAsync(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
195
|
+
if (!stagedRaw) {
|
|
196
|
+
const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
197
|
+
return { result: { status: "nothing_to_commit", branch, head_short: headShort, message: "No changes in the requested scope; nothing was committed." } };
|
|
198
|
+
}
|
|
199
|
+
const committedFiles = stagedRaw.split("\n").filter(Boolean);
|
|
200
|
+
|
|
201
|
+
await runGitAsync(repoRoot, [
|
|
202
|
+
"-c", `user.name=${opts.authorName || "clauth-fs"}`,
|
|
203
|
+
"-c", `user.email=${opts.authorEmail || "fs@clauth.local"}`,
|
|
204
|
+
"commit", "-m", message,
|
|
205
|
+
]);
|
|
206
|
+
const commitSha = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
|
|
207
|
+
const commitShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
208
|
+
const base = { branch, commit: commitSha, commit_short: commitShort, files: committedFiles };
|
|
209
|
+
|
|
210
|
+
if (!push) {
|
|
211
|
+
return { result: { status: "committed_local", ...base, pushed: false, message: `Committed ${committedFiles.length} file(s) locally as ${commitShort}.` } };
|
|
212
|
+
}
|
|
213
|
+
if (FS_GIT_PROTECTED_BRANCHES.has(branch.toLowerCase())) {
|
|
214
|
+
return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "protected_branch", message: `Commit saved locally as ${commitShort}. Push refused: '${branch}' is protected — a human promotes it. Use fs_use_branch to move to a feature/develop branch to push directly.` } };
|
|
215
|
+
}
|
|
216
|
+
if (!opts.token) {
|
|
217
|
+
return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "no_token", message: `Commit saved locally as ${commitShort}. Push blocked: ${opts.tokenError || "no github token available"}.` } };
|
|
218
|
+
}
|
|
219
|
+
const token = opts.token.trim();
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await runGitPushAuthed(repoRoot, token, remote, `HEAD:${branch}`);
|
|
223
|
+
} catch (e1) {
|
|
224
|
+
// Branch likely diverged — integrate then retry once.
|
|
225
|
+
try { await runGitAsync(repoRoot, ["fetch", "--no-tags", remote, branch]); } catch { /* branch may not exist on remote yet */ }
|
|
226
|
+
try {
|
|
227
|
+
await runGitAsync(repoRoot, ["rebase", `${remote}/${branch}`]);
|
|
228
|
+
} catch {
|
|
229
|
+
try { await runGitAsync(repoRoot, ["rebase", "--abort"]); } catch {}
|
|
230
|
+
return { result: { status: "committed_local_not_pushed", ...base, pushed: false, push_blocked: "diverged", message: `Commit saved locally as ${commitShort}. Push blocked: '${branch}' diverged from ${remote} and auto-rebase hit conflicts (rebase aborted, tree restored). A human should reconcile.` } };
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await runGitPushAuthed(repoRoot, token, remote, `HEAD:${branch}`);
|
|
234
|
+
} catch (e2) {
|
|
235
|
+
const sha2 = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
236
|
+
return { result: { status: "committed_local_not_pushed", ...base, commit_short: sha2, pushed: false, push_blocked: "push_failed", message: `Commit saved and rebased locally (${sha2}) but the push still failed: ${e2.message}` } };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const finalSha = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
|
|
241
|
+
const finalShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
|
|
242
|
+
const { ahead, behind } = await aheadBehind(repoRoot);
|
|
243
|
+
return { result: { status: "committed_and_pushed", branch, commit: finalSha, commit_short: finalShort, files: committedFiles, pushed: true, remote, ahead, behind, message: `Committed and pushed ${committedFiles.length} file(s) to ${remote}/${branch} as ${finalShort}.` } };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Unified diff of the working tree (or index) — self-verify before pushing, or
|
|
248
|
+
* compare against a remote ref to catch staleness.
|
|
249
|
+
* opts: { paths?, ref?, staged? }
|
|
250
|
+
* - default: working tree vs HEAD (everything uncommitted)
|
|
251
|
+
* - ref:"origin/x": working tree vs that ref (remote compare / staleness)
|
|
252
|
+
* - staged:true: index vs HEAD (only what's staged)
|
|
253
|
+
* Patch is capped; `truncated` flags when the cap was hit.
|
|
254
|
+
*/
|
|
255
|
+
export async function diff(repoRoot, opts = {}) {
|
|
256
|
+
const MAX = 60000;
|
|
257
|
+
let pathArgs = [];
|
|
258
|
+
if (Array.isArray(opts.paths) && opts.paths.length > 0) {
|
|
259
|
+
for (const p of opts.paths) {
|
|
260
|
+
const n = normalizeRepoPath(p);
|
|
261
|
+
if (!n) return { error: `Invalid repo path: ${p}` };
|
|
262
|
+
pathArgs.push(n);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const flags = [];
|
|
266
|
+
if (opts.staged) flags.push("--cached");
|
|
267
|
+
const ref = opts.ref || (opts.staged ? null : "HEAD");
|
|
268
|
+
if (ref) flags.push(ref);
|
|
269
|
+
const tail = pathArgs.length ? ["--", ...pathArgs] : [];
|
|
270
|
+
|
|
271
|
+
// Validate ref up front so a typo yields a clean error, not a raw git failure.
|
|
272
|
+
if (ref && ref !== "HEAD") {
|
|
273
|
+
try { await runGitAsync(repoRoot, ["rev-parse", "--verify", "--quiet", ref + "^{commit}"]); }
|
|
274
|
+
catch { return { error: `Unknown ref: ${opts.ref} (try fetching it first, e.g. origin/<branch>)` }; }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const stat = await runGitAsync(repoRoot, ["diff", ...flags, "--stat", ...tail]);
|
|
278
|
+
let patch = await runGitAsync(repoRoot, ["diff", ...flags, ...tail]);
|
|
279
|
+
let truncated = false;
|
|
280
|
+
if (patch.length > MAX) { patch = patch.slice(0, MAX) + "\n... (diff truncated at 60KB)"; truncated = true; }
|
|
281
|
+
return { result: { compared_to: ref || "index", staged: !!opts.staged, stat: stat || "(no differences)", patch: patch || "(no differences)", truncated } };
|
|
282
|
+
}
|