@lifeaitools/clauth 1.7.1 → 1.7.2
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 +43 -26
- package/cli/commands/serve.js +74 -6
- package/cli/lib/fs-git.js +70 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,20 +72,20 @@ clauth get github
|
|
|
72
72
|
```
|
|
73
73
|
clauth install Provision Supabase + install Claude skill
|
|
74
74
|
clauth setup Register this machine with the vault
|
|
75
|
-
clauth status All services + state
|
|
76
|
-
clauth search <query> Find services by name, project, description, or redacted address
|
|
77
|
-
clauth test Verify connection
|
|
75
|
+
clauth status All services + state
|
|
76
|
+
clauth search <query> Find services by name, project, description, or redacted address
|
|
77
|
+
clauth test Verify connection
|
|
78
78
|
|
|
79
79
|
clauth write key <service> Store a credential
|
|
80
80
|
clauth write pw Change password
|
|
81
81
|
clauth enable <svc|all> Activate service
|
|
82
82
|
clauth disable <svc|all> Suspend service
|
|
83
|
-
clauth get <service> Retrieve a key
|
|
84
|
-
clauth npm whoami Verify npm token without PowerShell secret plumbing
|
|
85
|
-
clauth npm sync-github-secret LIFEAI/rdc-skills
|
|
86
|
-
Update GitHub NPM_TOKEN from clauth
|
|
87
|
-
|
|
88
|
-
clauth add service <n> Register new service
|
|
83
|
+
clauth get <service> Retrieve a key
|
|
84
|
+
clauth npm whoami Verify npm token without PowerShell secret plumbing
|
|
85
|
+
clauth npm sync-github-secret LIFEAI/rdc-skills
|
|
86
|
+
Update GitHub NPM_TOKEN from clauth
|
|
87
|
+
|
|
88
|
+
clauth add service <n> Register new service
|
|
89
89
|
clauth remove service <n> Remove service
|
|
90
90
|
clauth revoke <svc|all> Delete key (destructive)
|
|
91
91
|
```
|
|
@@ -127,7 +127,7 @@ Full daemon operations reference: see `regen-root/.claude/rules/clauth.md`.
|
|
|
127
127
|
|
|
128
128
|
---
|
|
129
129
|
|
|
130
|
-
## MCP Server — 3 Namespaces, 32 Tools
|
|
130
|
+
## MCP Server — 3 Namespaces, 32 Tools
|
|
131
131
|
|
|
132
132
|
clauth is the single MCP interface for all local tools. One process, namespaced paths:
|
|
133
133
|
|
|
@@ -135,18 +135,18 @@ clauth is the single MCP interface for all local tools. One process, namespaced
|
|
|
135
135
|
|------|-----------|-------|-------------|
|
|
136
136
|
| `/clauth` | `clauth_*` | 13 | Credential vault operations |
|
|
137
137
|
| `/gws` | `gws_*` | 6 | Google Workspace (Gmail, Calendar, Drive) |
|
|
138
|
-
| `/fs` | `fs_*` | 13 | Filesystem (read, write, append, chunked write, URL ingest, Git import, stat, grep, glob, delete, mkdir, mounts) |
|
|
139
|
-
| `/mcp` | all | 32 | All namespaces combined (Claude Code) |
|
|
140
|
-
|
|
141
|
-
### FS Tools
|
|
142
|
-
|
|
143
|
-
13 filesystem tools with path-jail security:
|
|
144
|
-
- `fs_read`, `fs_write`, `fs_stat`, `fs_append`, `fs_write_chunk`, `fs_ingest_url`, `fs_import_git_files`, `fs_list`, `fs_grep`, `fs_glob`, `fs_delete`, `fs_mkdir`, `fs_mounts`
|
|
145
|
-
- Uses `node:fs/promises` (async), `@vscode/ripgrep` (shipped binary), `fast-glob`
|
|
146
|
-
- Permission flags per mount: `r` (read), `w` (write), `d` (delete)
|
|
147
|
-
- Mount config stored as "fileserver" service type in vault — only configurable through web UI
|
|
148
|
-
- Large writes should use `fs_write_chunk`; cloud-to-local transfer should use `fs_ingest_url`; guarded appends should pass `expected_sha256` from `fs_stat`
|
|
149
|
-
- Durable new files authored by Claude.ai in GitHub should use `fs_import_git_files` so the local dirty monorepo fetches and restores only named paths without `git pull`
|
|
138
|
+
| `/fs` | `fs_*` | 13 | Filesystem (read, write, append, chunked write, URL ingest, Git import, stat, grep, glob, delete, mkdir, mounts) |
|
|
139
|
+
| `/mcp` | all | 32 | All namespaces combined (Claude Code) |
|
|
140
|
+
|
|
141
|
+
### FS Tools
|
|
142
|
+
|
|
143
|
+
13 filesystem tools with path-jail security:
|
|
144
|
+
- `fs_read`, `fs_write`, `fs_stat`, `fs_append`, `fs_write_chunk`, `fs_ingest_url`, `fs_import_git_files`, `fs_list`, `fs_grep`, `fs_glob`, `fs_delete`, `fs_mkdir`, `fs_mounts`
|
|
145
|
+
- Uses `node:fs/promises` (async), `@vscode/ripgrep` (shipped binary), `fast-glob`
|
|
146
|
+
- Permission flags per mount: `r` (read), `w` (write), `d` (delete)
|
|
147
|
+
- Mount config stored as "fileserver" service type in vault — only configurable through web UI
|
|
148
|
+
- Large writes should use `fs_write_chunk`; cloud-to-local transfer should use `fs_ingest_url`; guarded appends should pass `expected_sha256` from `fs_stat`
|
|
149
|
+
- Durable new files authored by Claude.ai in GitHub should use `fs_import_git_files` so the local dirty monorepo fetches and restores only named paths without `git pull`
|
|
150
150
|
|
|
151
151
|
### GWS Tools
|
|
152
152
|
|
|
@@ -200,15 +200,32 @@ Tests actual MCP tool calls (not just OAuth + listing).
|
|
|
200
200
|
|
|
201
201
|
## Releasing a New Version (maintainers)
|
|
202
202
|
|
|
203
|
+
clauth is a **private** repo, so we do **not** use GitHub Actions to publish —
|
|
204
|
+
Actions bill for minutes on private repos. (Reserve Actions for *public* repos,
|
|
205
|
+
where they're free.) Publishing is **webhook-driven**, with a manual fallback.
|
|
206
|
+
|
|
203
207
|
```bash
|
|
204
208
|
# 1. Bump version in package.json
|
|
205
|
-
# 2. Commit
|
|
206
|
-
git
|
|
209
|
+
# 2. Commit + tag + push — the tag push triggers the publish webhook
|
|
210
|
+
git add -A && git commit -m "feat(...): description (vX.Y.Z)"
|
|
211
|
+
git tag vX.Y.Z
|
|
207
212
|
git push && git push --tags
|
|
208
|
-
|
|
213
|
+
|
|
214
|
+
# 3. Verify it published (direct registry check — bypasses npm's cache)
|
|
215
|
+
curl -s https://registry.npmjs.org/@lifeaitools/clauth \
|
|
216
|
+
| python -c "import sys,json;print(json.load(sys.stdin)['dist-tags'])"
|
|
217
|
+
|
|
218
|
+
# 4. Fallback — if the webhook did NOT publish (registry still shows the old
|
|
219
|
+
# version), publish manually with the vault npm token:
|
|
220
|
+
clauth npm set-local # writes ~/.npmrc auth from the vault 'npm' service
|
|
221
|
+
npm publish --access public
|
|
209
222
|
```
|
|
210
223
|
|
|
211
|
-
**NEVER** commit a version bump without tagging — the tag
|
|
224
|
+
**NEVER** commit a version bump without tagging — the tag push is what triggers
|
|
225
|
+
the publish webhook. If the webhook fails silently, the usual cause is a **stale
|
|
226
|
+
npm token on the webhook receiver** (it holds an env copy, not the live vault
|
|
227
|
+
value) — re-sync the receiver's token to the current vault `npm` service, then
|
|
228
|
+
use the manual publish above to unblock the release.
|
|
212
229
|
|
|
213
230
|
---
|
|
214
231
|
|
package/cli/commands/serve.js
CHANGED
|
@@ -784,6 +784,7 @@ function dashboardHtml(port, whitelist, isStaged = false) {
|
|
|
784
784
|
<button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
|
|
785
785
|
<button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
|
|
786
786
|
<button class="btn-ccandme" id="ccandme-btn" onclick="launchCCandMe()" title="Launch CCandMe — 4-pane WezTerm: Claude + Codex + Me">⚡ CCandMe</button>
|
|
787
|
+
<button class="btn-lock" id="unlock-writes-btn" onclick="unlockWrites()" title="Enter password to enable saving changes this browser session (needed after a daemon restart when auto-unlocked via --pw)">🔓 Unlock Writes</button>
|
|
787
788
|
<button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
|
|
788
789
|
<button class="btn-stop" onclick="restartDaemon()" style="background:#1a2e1a;border:1px solid #166534;color:#86efac" title="Restart daemon — keeps vault unlocked">↺ Restart</button>
|
|
789
790
|
<button class="btn-stop" onclick="stopDaemon()" style="background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5" title="Stop daemon — password required on next start">⏹ Stop</button>
|
|
@@ -1090,6 +1091,7 @@ function showMain(ping) {
|
|
|
1090
1091
|
}
|
|
1091
1092
|
pollTunnel();
|
|
1092
1093
|
updateBuildStatus();
|
|
1094
|
+
refreshWriteLockUi();
|
|
1093
1095
|
}
|
|
1094
1096
|
|
|
1095
1097
|
// ── Unlock ──────────────────────────────────
|
|
@@ -1151,6 +1153,34 @@ async function lockVault() {
|
|
|
1151
1153
|
showLockScreen(r.hard_locked || false);
|
|
1152
1154
|
}
|
|
1153
1155
|
|
|
1156
|
+
// ── Unlock writes (re-establish write scope without locking) ──
|
|
1157
|
+
// Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
|
|
1158
|
+
// unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
|
|
1159
|
+
async function unlockWrites() {
|
|
1160
|
+
if (writeToken && !confirm("Writes are already unlocked this session. Re-unlock?")) return;
|
|
1161
|
+
const pw = prompt("Enter your vault password to enable saving changes (10-minute write session):");
|
|
1162
|
+
if (!pw) return;
|
|
1163
|
+
try {
|
|
1164
|
+
const r = await fetch(BASE + "/auth", {
|
|
1165
|
+
method: "POST",
|
|
1166
|
+
headers: { "Content-Type": "application/json" },
|
|
1167
|
+
body: JSON.stringify({ password: pw }),
|
|
1168
|
+
}).then(r => r.json());
|
|
1169
|
+
if (r.error) { alert("Unlock failed: " + r.error); return; }
|
|
1170
|
+
writeToken = r.write_token || null;
|
|
1171
|
+
refreshWriteLockUi();
|
|
1172
|
+
alert(writeToken ? "Writes unlocked for 10 minutes." : "Unlock did not return a write token.");
|
|
1173
|
+
} catch (e) { alert("Unlock error: " + (e.message || e)); }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Reflect write-lock state on the button so it is obvious when a save will fail.
|
|
1177
|
+
function refreshWriteLockUi() {
|
|
1178
|
+
const b = document.getElementById("unlock-writes-btn");
|
|
1179
|
+
if (!b) return;
|
|
1180
|
+
if (writeToken) { b.textContent = "🔓 Writes On"; b.style.opacity = "0.6"; b.style.borderColor = ""; }
|
|
1181
|
+
else { b.textContent = "🔓 Unlock Writes"; b.style.opacity = "1"; b.style.borderColor = "#f59e0b"; }
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1154
1184
|
// ── Make Live (blue-green: promote staged instance to live port) ──
|
|
1155
1185
|
async function makeLive() {
|
|
1156
1186
|
if (!confirm("Promote this staged instance to live?\\n\\nThe current live daemon (port ${LIVE_PORT}) will be stopped and this instance will restart on port ${LIVE_PORT}.")) return;
|
|
@@ -7251,19 +7281,35 @@ const MCP_TOOLS = [
|
|
|
7251
7281
|
},
|
|
7252
7282
|
{
|
|
7253
7283
|
name: "fs_commit",
|
|
7254
|
-
description: "
|
|
7284
|
+
description: "Commit and push to GitHub: stages changes, commits, and PUSHES by default — usually a single call is all you need after editing. Handles the messy cases: nothing-to-commit returns cleanly (no error); a merge/rebase in progress or a detached HEAD is refused with a clear message; if the branch is behind the remote it auto-rebases and retries the push, and on conflict it aborts the rebase (restoring your tree) and keeps the commit local so nothing is lost. Refuses to push protected branches (main/master/production) — those are promoted by a human. Returns the commit sha, the exact files committed, whether the push succeeded, and ahead/behind counts, so you never need a follow-up status call. Set dry_run=true first to preview what WOULD be committed/pushed without doing it. Set expected_head to refuse committing if the base moved under you. Push auth uses the vault github token directly (never exposed). Requires 'g' (git) access on the mount.",
|
|
7255
7285
|
inputSchema: {
|
|
7256
7286
|
type: "object",
|
|
7257
7287
|
properties: {
|
|
7258
|
-
message: { type: "string", description: "Commit message
|
|
7288
|
+
message: { type: "string", description: "Commit message. Required for a real commit; optional when dry_run=true." },
|
|
7259
7289
|
paths: { type: "array", items: { type: "string" }, description: "Repo-relative paths to commit. Omit to commit ALL current changes in the repo." },
|
|
7260
7290
|
push: { type: "boolean", description: "Push to the remote after committing (default true)" },
|
|
7291
|
+
dry_run: { type: "boolean", description: "Preview only: returns the branch, the files that would be committed, and whether/where it would push — without staging, committing, or pushing. Use to confirm before a real push." },
|
|
7292
|
+
expected_head: { type: "string", description: "Optimistic-concurrency guard: the commit SHA you believe HEAD is on. If HEAD has moved, the commit is refused so you don't build on a stale base." },
|
|
7261
7293
|
remote: { type: "string", description: "Git remote name (default: origin)" },
|
|
7262
7294
|
author_name: { type: "string", description: "Commit author name (default: clauth-fs)" },
|
|
7263
7295
|
author_email: { type: "string", description: "Commit author email (default: fs@clauth.local)" },
|
|
7264
7296
|
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7265
7297
|
},
|
|
7266
|
-
required: [
|
|
7298
|
+
required: [],
|
|
7299
|
+
additionalProperties: false,
|
|
7300
|
+
},
|
|
7301
|
+
},
|
|
7302
|
+
{
|
|
7303
|
+
name: "fs_diff",
|
|
7304
|
+
description: "Show the unified diff of your changes so you can self-verify BEFORE committing/pushing — essential after large or multi-step edits. Default compares the working tree to the last commit (HEAD). Pass ref (e.g. 'origin/develop') to compare against a remote branch and catch staleness — i.e. see exactly how your working tree differs from what's on the remote, in one call. Pass staged=true to see only what's staged. Returns a --stat summary plus the patch (capped at 60KB; `truncated` flags when hit). Requires 'g' (git) access on the mount.",
|
|
7305
|
+
inputSchema: {
|
|
7306
|
+
type: "object",
|
|
7307
|
+
properties: {
|
|
7308
|
+
paths: { type: "array", items: { type: "string" }, description: "Repo-relative paths to limit the diff to. Omit for all changes." },
|
|
7309
|
+
ref: { type: "string", description: "Compare the working tree against this ref instead of HEAD (e.g. 'origin/develop', a tag, or a commit sha). Use for remote/staleness comparison." },
|
|
7310
|
+
staged: { type: "boolean", description: "Show only staged changes (index vs HEAD) instead of the full working-tree diff." },
|
|
7311
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7312
|
+
},
|
|
7267
7313
|
additionalProperties: false,
|
|
7268
7314
|
},
|
|
7269
7315
|
},
|
|
@@ -8213,7 +8259,13 @@ async function handleMcpTool(vault, name, args) {
|
|
|
8213
8259
|
const { mounts, error } = await getFileserverMounts(vault);
|
|
8214
8260
|
if (error) return mcpError(error);
|
|
8215
8261
|
if (!mounts || mounts.length === 0) return mcpResult("No fileserver mounts configured. Create one:\n1. Use clauth dashboard or clauth_enable to add a service with key_type='fileserver'\n2. Set the secret value to JSON: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwdg\"} (add 'g' to allow the git verbs: fs_commit, fs_use_branch, fs_repo_status)");
|
|
8216
|
-
|
|
8262
|
+
// Decode the access string so callers know what's possible BEFORE trying —
|
|
8263
|
+
// notably `git` (the git verbs require it; absent = explain how to enable).
|
|
8264
|
+
const decorated = mounts.map((m) => {
|
|
8265
|
+
const a = String(m.access || "");
|
|
8266
|
+
return { ...m, can: { read: a.includes("r"), write: a.includes("w"), delete: a.includes("d"), git: a.includes("g") } };
|
|
8267
|
+
});
|
|
8268
|
+
return mcpResult(JSON.stringify(decorated, null, 2));
|
|
8217
8269
|
}
|
|
8218
8270
|
|
|
8219
8271
|
case "fs_repo_status": {
|
|
@@ -8247,10 +8299,11 @@ async function handleMcpTool(vault, name, args) {
|
|
|
8247
8299
|
if (r.error) return mcpError(r.error);
|
|
8248
8300
|
if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
|
|
8249
8301
|
const push = args.push !== false;
|
|
8302
|
+
const dryRun = args.dry_run === true;
|
|
8250
8303
|
// Fetch the push token from the vault up front (commit happens first, so
|
|
8251
|
-
// a missing token still preserves the local commit).
|
|
8304
|
+
// a missing token still preserves the local commit). Skipped on dry-run.
|
|
8252
8305
|
let token = null, tokenError = null;
|
|
8253
|
-
if (push) {
|
|
8306
|
+
if (push && !dryRun) {
|
|
8254
8307
|
const sec = await vaultRetrieveValue(vault, "github");
|
|
8255
8308
|
if (sec.error || !sec.value) tokenError = `could not read the 'github' token (${sec.error || "empty"})`;
|
|
8256
8309
|
else token = sec.value;
|
|
@@ -8260,6 +8313,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
8260
8313
|
message: args.message,
|
|
8261
8314
|
paths: args.paths,
|
|
8262
8315
|
push,
|
|
8316
|
+
dryRun,
|
|
8317
|
+
expectedHead: args.expected_head,
|
|
8263
8318
|
remote: args.remote,
|
|
8264
8319
|
token,
|
|
8265
8320
|
tokenError,
|
|
@@ -8273,6 +8328,19 @@ async function handleMcpTool(vault, name, args) {
|
|
|
8273
8328
|
}
|
|
8274
8329
|
}
|
|
8275
8330
|
|
|
8331
|
+
case "fs_diff": {
|
|
8332
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
8333
|
+
if (r.error) return mcpError(r.error);
|
|
8334
|
+
if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
|
|
8335
|
+
try {
|
|
8336
|
+
const { result, error } = await fsGit.diff(r.resolved, { paths: args.paths, ref: args.ref, staged: args.staged === true });
|
|
8337
|
+
if (error) return mcpError(error);
|
|
8338
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
8339
|
+
} catch (err) {
|
|
8340
|
+
return mcpError(`Diff failed: ${err.message}`);
|
|
8341
|
+
}
|
|
8342
|
+
}
|
|
8343
|
+
|
|
8276
8344
|
case "monkey_dispatch": {
|
|
8277
8345
|
const { prompt, job_id } = args;
|
|
8278
8346
|
if (!prompt) return mcpError("prompt required");
|
package/cli/lib/fs-git.js
CHANGED
|
@@ -138,7 +138,7 @@ export async function useBranch(repoRoot, branch, create) {
|
|
|
138
138
|
*/
|
|
139
139
|
export async function commit(repoRoot, opts = {}) {
|
|
140
140
|
const message = (opts.message || "").trim();
|
|
141
|
-
if (!message) return { error: "message is required" };
|
|
141
|
+
if (!message && !opts.dryRun) return { error: "message is required" };
|
|
142
142
|
const push = opts.push !== false;
|
|
143
143
|
const remote = opts.remote || "origin";
|
|
144
144
|
|
|
@@ -151,16 +151,43 @@ export async function commit(repoRoot, opts = {}) {
|
|
|
151
151
|
const branch = await runGitAsync(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
152
152
|
if (branch === "HEAD") return { error: "Refused: detached HEAD. Use fs_use_branch to get on a branch first." };
|
|
153
153
|
|
|
154
|
-
//
|
|
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 = [];
|
|
155
164
|
if (Array.isArray(opts.paths) && opts.paths.length > 0) {
|
|
156
165
|
if (opts.paths.length > 100) return { error: "Too many paths: max 100 per commit" };
|
|
157
|
-
const norm = [];
|
|
158
166
|
for (const p of opts.paths) {
|
|
159
167
|
const n = normalizeRepoPath(p);
|
|
160
168
|
if (!n) return { error: `Invalid repo path: ${p}` };
|
|
161
|
-
|
|
169
|
+
pathArgs.push(n);
|
|
162
170
|
}
|
|
163
|
-
|
|
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]);
|
|
164
191
|
} else {
|
|
165
192
|
await runGitAsync(repoRoot, ["add", "-A"]);
|
|
166
193
|
}
|
|
@@ -215,3 +242,41 @@ export async function commit(repoRoot, opts = {}) {
|
|
|
215
242
|
const { ahead, behind } = await aheadBehind(repoRoot);
|
|
216
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}.` } };
|
|
217
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
|
+
}
|