@lifeaitools/clauth 1.7.1 → 1.8.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 +45 -26
- package/cli/commands/npm.js +59 -0
- package/cli/commands/serve.js +74 -6
- package/cli/index.js +22 -1
- 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,34 @@ Tests actual MCP tool calls (not just OAuth + listing).
|
|
|
200
200
|
|
|
201
201
|
## Releasing a New Version (maintainers)
|
|
202
202
|
|
|
203
|
+
Publishing is **manual** — there is no auto-publish. The GitHub Actions
|
|
204
|
+
`publish.yml` workflow was removed on 2026-04-27 (commit `08b7751`); trusted
|
|
205
|
+
publishing via OIDC was tried first (`b2bf08b`→`41d2d92`) and dropped. clauth is
|
|
206
|
+
a **private** repo and GitHub Actions bill for minutes on private repos, so we
|
|
207
|
+
don't run them here (reserve Actions for *public* repos, where they're free).
|
|
208
|
+
|
|
203
209
|
```bash
|
|
204
210
|
# 1. Bump version in package.json
|
|
205
|
-
# 2. Commit
|
|
206
|
-
git
|
|
211
|
+
# 2. Commit + tag + push
|
|
212
|
+
git add -A && git commit -m "feat(...): description (vX.Y.Z)"
|
|
213
|
+
git tag vX.Y.Z
|
|
207
214
|
git push && git push --tags
|
|
208
|
-
|
|
215
|
+
|
|
216
|
+
# 3. Publish manually with the vault npm token
|
|
217
|
+
clauth npm set-local # writes ~/.npmrc auth from the vault 'npm' service
|
|
218
|
+
npm publish --access public
|
|
219
|
+
|
|
220
|
+
# 4. Verify on the registry (direct check — bypasses npm's local cache)
|
|
221
|
+
curl -s https://registry.npmjs.org/@lifeaitools/clauth \
|
|
222
|
+
| python -c "import sys,json;print(json.load(sys.stdin)['dist-tags'])"
|
|
223
|
+
|
|
224
|
+
# 5. Update the running daemon
|
|
225
|
+
curl -s -X POST http://127.0.0.1:52437/restart # picks up new code, stays unlocked
|
|
209
226
|
```
|
|
210
227
|
|
|
211
|
-
**
|
|
228
|
+
**The tag push does NOT publish anything** — you must run step 3. A version bump
|
|
229
|
+
that is committed+tagged but never `npm publish`ed leaves the registry stale
|
|
230
|
+
(symptom: `npm view` still shows the old version after a push).
|
|
212
231
|
|
|
213
232
|
---
|
|
214
233
|
|
package/cli/commands/npm.js
CHANGED
|
@@ -121,3 +121,62 @@ export async function runNpm(action = "help", opts = {}) {
|
|
|
121
121
|
usage();
|
|
122
122
|
throw new Error(`unknown clauth npm action: ${action}`);
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
// ── Guarded publish ──────────────────────────────────────────────────────────
|
|
126
|
+
// Generic, package-agnostic publish that REFUSES to publish unless the package
|
|
127
|
+
// is already committed and pushed to GitHub — so the npm tarball can never be
|
|
128
|
+
// built from uncommitted "dev" code that isn't on the repo. Works for standalone
|
|
129
|
+
// repos and monorepo subpackages (clean-check is scoped to the package's dir).
|
|
130
|
+
export async function runPublish(target, opts = {}) {
|
|
131
|
+
const pkgDir = path.resolve(target || process.cwd());
|
|
132
|
+
const pkgJsonPath = path.join(pkgDir, "package.json");
|
|
133
|
+
if (!fs.existsSync(pkgJsonPath)) throw new Error(`No package.json found at ${pkgDir}`);
|
|
134
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
|
|
135
|
+
if (!pkg.name || !pkg.version) throw new Error(`package.json at ${pkgDir} is missing name or version`);
|
|
136
|
+
if (pkg.private === true) throw new Error(`${pkg.name} is marked "private": true — refusing to publish`);
|
|
137
|
+
const access = opts.access || pkg.publishConfig?.access || (pkg.name.startsWith("@") ? "public" : undefined);
|
|
138
|
+
|
|
139
|
+
const gitRoot = run("git", ["rev-parse", "--show-toplevel"], { cwd: pkgDir }).stdout?.trim();
|
|
140
|
+
if (!gitRoot) throw new Error(`${pkgDir} is not inside a git repository`);
|
|
141
|
+
const rel = path.relative(gitRoot, pkgDir) || ".";
|
|
142
|
+
|
|
143
|
+
// Guard 1 — nothing uncommitted in the package (else we'd pack dev code).
|
|
144
|
+
const dirty = run("git", ["status", "--porcelain", "--", rel], { cwd: gitRoot }).stdout?.trim();
|
|
145
|
+
if (dirty && !opts.allowDirty) {
|
|
146
|
+
throw new Error(`Refusing to publish ${pkg.name}@${pkg.version}: uncommitted changes in ${rel} would be packed but are NOT on GitHub:\n${dirty}\n\nCommit + push first (or pass --allow-dirty to override).`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Guard 2 — HEAD is on a remote (actually pushed to GitHub).
|
|
150
|
+
const head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot }).stdout?.trim();
|
|
151
|
+
const onRemote = run("git", ["branch", "-r", "--contains", head], { cwd: gitRoot }).stdout?.trim();
|
|
152
|
+
if (!onRemote && !opts.allowUnpushed) {
|
|
153
|
+
throw new Error(`Refusing to publish ${pkg.name}@${pkg.version}: HEAD ${head.slice(0, 9)} is not on any remote branch — push to GitHub first (or pass --allow-unpushed to override).`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Traceability — warn (don't block) if there's no v<version> tag at HEAD.
|
|
157
|
+
const tags = (run("git", ["tag", "--points-at", "HEAD"], { cwd: gitRoot }).stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
|
158
|
+
if (!tags.includes(`v${pkg.version}`)) {
|
|
159
|
+
console.log(`⚠ No git tag v${pkg.version} at HEAD (traceability only). Tags here: ${tags.join(", ") || "none"}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`${opts.dryRun ? "[dry-run] " : ""}Publishing ${pkg.name}@${pkg.version} from ${rel} @ ${head.slice(0, 9)} (pushed${access ? `, access=${access}` : ""})`);
|
|
163
|
+
|
|
164
|
+
const token = await fetchNpmToken();
|
|
165
|
+
const args = ["publish"];
|
|
166
|
+
if (access) args.push("--access", access);
|
|
167
|
+
if (opts.dryRun) args.push("--dry-run");
|
|
168
|
+
const result = withNpmAuth(token, (npmrc) => run("npm", ["--userconfig", npmrc, ...args], { cwd: pkgDir }));
|
|
169
|
+
printResult(result, { redact: true });
|
|
170
|
+
if (result.status !== 0) { process.exitCode = result.status; throw new Error(`npm publish failed for ${pkg.name}`); }
|
|
171
|
+
if (opts.dryRun) { console.log("Dry run complete — nothing published."); return; }
|
|
172
|
+
|
|
173
|
+
// Verify on the registry directly (bypasses npm's local cache lag).
|
|
174
|
+
try {
|
|
175
|
+
const reg = await fetch(`https://registry.npmjs.org/${pkg.name}`);
|
|
176
|
+
const meta = await reg.json();
|
|
177
|
+
if (meta.versions?.[pkg.version]) console.log(`✓ Verified ${pkg.name}@${pkg.version} on the registry (latest: ${meta["dist-tags"]?.latest}).`);
|
|
178
|
+
else console.log(`⚠ ${pkg.name}@${pkg.version} not visible on the registry yet (propagation lag) — re-check shortly.`);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
console.log(`(could not verify on registry: ${e.message})`);
|
|
181
|
+
}
|
|
182
|
+
}
|
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/index.js
CHANGED
|
@@ -150,7 +150,7 @@ import { runUninstall } from './commands/uninstall.js';
|
|
|
150
150
|
import { runScrub } from './commands/scrub.js';
|
|
151
151
|
import { runServe } from './commands/serve.js';
|
|
152
152
|
import { runCodevelop } from './commands/codevelop.js';
|
|
153
|
-
import { runNpm } from './commands/npm.js';
|
|
153
|
+
import { runNpm, runPublish } from './commands/npm.js';
|
|
154
154
|
|
|
155
155
|
program
|
|
156
156
|
.command('install')
|
|
@@ -229,6 +229,27 @@ program
|
|
|
229
229
|
await runNpm(action, { ...opts, args });
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
// ──────────────────────────────────────────────
|
|
233
|
+
// clauth publish [target]
|
|
234
|
+
// Guarded npm publish for ANY package — refuses to ship code that isn't
|
|
235
|
+
// committed AND pushed to GitHub (prevents npm/repo divergence from dev builds).
|
|
236
|
+
// ──────────────────────────────────────────────
|
|
237
|
+
program
|
|
238
|
+
.command("publish [target]")
|
|
239
|
+
.description("Safely publish an npm package (default: cwd). Refuses unless committed + pushed to GitHub.")
|
|
240
|
+
.option("--dry-run", "Run all guards and pack, but do not publish")
|
|
241
|
+
.option("--access <access>", "npm access: public | restricted")
|
|
242
|
+
.option("--allow-dirty", "Override the uncommitted-changes guard (NOT recommended)")
|
|
243
|
+
.option("--allow-unpushed", "Override the not-pushed-to-remote guard (NOT recommended)")
|
|
244
|
+
.action(async (target, opts) => {
|
|
245
|
+
try {
|
|
246
|
+
await runPublish(target, opts);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error(chalk.red(err.message));
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
232
253
|
// ──────────────────────────────────────────────
|
|
233
254
|
// clauth setup
|
|
234
255
|
// ──────────────────────────────────────────────
|
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
|
+
}
|