@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 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 and tag
206
- git tag v1.5.38
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
- # GitHub Actions publishes automatically via Trusted Publishing
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
- **NEVER** commit a version bump without taggingthe tag triggers npm CI.
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
 
@@ -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
+ }
@@ -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: "Save the files you edited durably: stages changes, commits, and PUSHES by default — usually a single call is all you need after editing. Handles the messy cases for you: 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. Push auth uses the vault's github token directly (never exposed). Requires 'g' (git) access on the mount.",
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 (required)" },
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: ["message"],
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
- return mcpResult(JSON.stringify(mounts, null, 2));
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
- // Stage
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
- norm.push(n);
169
+ pathArgs.push(n);
162
170
  }
163
- await runGitAsync(repoRoot, ["add", "--", ...norm]);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {