@lifeaitools/clauth 1.5.84 → 1.7.1

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.
@@ -10,6 +10,13 @@ import fs from "fs";
10
10
  import path from "path";
11
11
  import os from "os";
12
12
  import { fileURLToPath } from "url";
13
+ import {
14
+ getRegistryPath,
15
+ getWatchdogStatuses,
16
+ readWatchdogEvents,
17
+ registerWatchdogManifest,
18
+ restartWatchdogService,
19
+ } from "../watchdog-registry.js";
13
20
 
14
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
22
  const TASK_NAME = "ClautWatchdog";
@@ -26,9 +33,9 @@ function ensureWatchdogScript() {
26
33
 
27
34
  function isElevated() {
28
35
  try {
29
- execSync(`${PS_EXE} -NoProfile -Command "[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"`,
36
+ const result = execSync(`${PS_EXE} -NoProfile -Command "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"`,
30
37
  { stdio: "pipe", encoding: "utf8" });
31
- return true;
38
+ return result.trim().toLowerCase() === "true";
32
39
  } catch { return false; }
33
40
  }
34
41
 
@@ -55,10 +62,10 @@ function buildUnregisterCmd() {
55
62
 
56
63
  function runElevated(psCmd) {
57
64
  // Wrap in Start-Process -Verb RunAs to trigger UAC dialog
58
- const inner = psCmd.replace(/"/g, '\\"');
65
+ const encoded = Buffer.from(psCmd, "utf16le").toString("base64");
59
66
  const result = spawnSync(PS_EXE, [
60
67
  "-NoProfile", "-Command",
61
- `Start-Process '${PS_EXE}' -Verb RunAs -Wait -ArgumentList '-NoProfile -ExecutionPolicy Bypass -Command "${inner}"'`,
68
+ `Start-Process -FilePath '${PS_EXE}' -Verb RunAs -Wait -ArgumentList '-NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encoded}'`,
62
69
  ], { stdio: "inherit", encoding: "utf8" });
63
70
  return result.status === 0;
64
71
  }
@@ -70,22 +77,80 @@ function runDirect(psCmd) {
70
77
  return result.status === 0;
71
78
  }
72
79
 
73
- export async function runWatchdog(action) {
80
+ function readScheduledTaskState() {
81
+ try {
82
+ const out = execSync(
83
+ `${PS_EXE} -NoProfile -Command "Get-ScheduledTask -TaskName '${TASK_NAME}' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty State"`,
84
+ { encoding: "utf8", stdio: ["pipe","pipe","pipe"], timeout: 5000 }
85
+ ).trim();
86
+ if (out) return out;
87
+ } catch {}
88
+
89
+ try {
90
+ const out = execSync(`schtasks /Query /TN ${TASK_NAME} /FO LIST`, {
91
+ encoding: "utf8",
92
+ stdio: ["pipe","pipe","pipe"],
93
+ timeout: 5000,
94
+ });
95
+ const status = out.match(/^Status:\\s*(.+)$/mi)?.[1]?.trim();
96
+ return status || "Registered";
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export async function runWatchdog(action, opts = {}) {
103
+ if (action === "register") {
104
+ const manifestPath = opts.manifest || opts.args?.[0];
105
+ if (!manifestPath) {
106
+ console.log("Usage: clauth watchdog register <manifest.json>");
107
+ return;
108
+ }
109
+ const manifest = JSON.parse(fs.readFileSync(path.resolve(manifestPath), "utf8"));
110
+ const result = registerWatchdogManifest(manifest);
111
+ console.log(`Registered ${result.registered} watchdog service(s): ${result.services.join(", ")}`);
112
+ console.log(`Registry: ${getRegistryPath()}`);
113
+ return;
114
+ }
115
+
116
+ if (action === "list" || action === "services") {
117
+ const status = await getWatchdogStatuses();
118
+ console.log(JSON.stringify(status, null, 2));
119
+ return;
120
+ }
121
+
122
+ if (action === "events") {
123
+ const limit = opts.limit ? Number(opts.limit) : 100;
124
+ console.log(JSON.stringify(readWatchdogEvents(limit), null, 2));
125
+ return;
126
+ }
127
+
128
+ if (action === "restart") {
129
+ const serviceId = opts.service || opts.args?.[0];
130
+ if (!serviceId) {
131
+ console.log("Usage: clauth watchdog restart <service-id>");
132
+ return;
133
+ }
134
+ const result = restartWatchdogService(serviceId);
135
+ console.log(JSON.stringify(result, null, 2));
136
+ return;
137
+ }
138
+
74
139
  if (os.platform() !== "win32") {
75
140
  console.log("Watchdog auto-start is Windows-only. On Linux/macOS, use systemd or launchd.");
76
141
  return;
77
142
  }
78
143
 
79
144
  if (action === "status" || !action) {
145
+ const state = readScheduledTaskState();
80
146
  try {
81
- const out = execSync(
82
- `${PS_EXE} -NoProfile -Command "Get-ScheduledTask -TaskName '${TASK_NAME}' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty State"`,
83
- { encoding: "utf8", stdio: ["pipe","pipe","pipe"], timeout: 5000 }
84
- ).trim();
85
- if (out) {
86
- console.log(`Watchdog task: ${TASK_NAME} — State: ${out}`);
147
+ if (state) {
148
+ console.log(`Watchdog task: ${TASK_NAME} State: ${state}`);
87
149
  console.log(`Script: ${DEST_PS1}`);
88
150
  console.log(`Log: ${path.join(CLAUTH_DIR, "watchdog.log")}`);
151
+ const status = await getWatchdogStatuses();
152
+ console.log(`Services: ${status.total} registered (${status.healthy} healthy, ${status.degraded} degraded, ${status.unreachable} unreachable)`);
153
+ console.log(`Registry: ${getRegistryPath()}`);
89
154
  } else {
90
155
  console.log(`Watchdog task '${TASK_NAME}' is NOT registered.`);
91
156
  console.log("Run: clauth watchdog install");
@@ -105,7 +170,7 @@ export async function runWatchdog(action) {
105
170
  console.log(" Windows will ask for administrator approval — please click Yes.\n");
106
171
 
107
172
  const cmd = buildRegisterCmd(DEST_PS1);
108
- const ok = runElevated(cmd);
173
+ const ok = isElevated() ? runDirect(cmd) : runElevated(cmd);
109
174
 
110
175
  if (ok) {
111
176
  console.log("\n Watchdog installed. Starting now...");
@@ -124,7 +189,7 @@ export async function runWatchdog(action) {
124
189
  if (action === "uninstall") {
125
190
  console.log("\n Removing clauth watchdog...");
126
191
  console.log(" Windows will ask for administrator approval — please click Yes.\n");
127
- const ok = runElevated(buildUnregisterCmd());
192
+ const ok = isElevated() ? runDirect(buildUnregisterCmd()) : runElevated(buildUnregisterCmd());
128
193
  if (ok) console.log("\n Watchdog removed.\n");
129
194
  else console.log("\n Removal cancelled or failed.\n");
130
195
  return;
@@ -140,5 +205,5 @@ export async function runWatchdog(action) {
140
205
  return;
141
206
  }
142
207
 
143
- console.log("Usage: clauth watchdog [install|uninstall|status|start]");
208
+ console.log("Usage: clauth watchdog [install|uninstall|status|start|register|list|events|restart]");
144
209
  }
package/cli/index.js CHANGED
@@ -9,6 +9,7 @@ import Conf from "conf";
9
9
  import { getConfOptions } from "./conf-path.js";
10
10
  import { getMachineHash, deriveToken, deriveSeedHash } from "./fingerprint.js";
11
11
  import * as api from "./api.js";
12
+ import { writeCredentialWithRecovery } from "./recovery.js";
12
13
  import os from "os";
13
14
  import fs from "fs";
14
15
  import path from "path";
@@ -388,9 +389,18 @@ writeCmd
388
389
  }
389
390
  const spinner = ora(`Writing key for ${service}...`).start();
390
391
  try {
391
- const result = await api.write(auth.password, auth.machineHash, auth.token, auth.timestamp, service, val);
392
+ const { result, snapshot, normalized } = await writeCredentialWithRecovery({
393
+ password: auth.password,
394
+ machineHash: auth.machineHash,
395
+ service,
396
+ value: val,
397
+ });
392
398
  if (result.error) throw new Error(result.error);
393
- spinner.succeed(chalk.green(`Key stored in vault: auth.${service}`));
399
+ const details = [
400
+ snapshot?.ok ? "recovery snapshot written" : null,
401
+ normalized ? "value normalized" : null,
402
+ ].filter(Boolean);
403
+ spinner.succeed(chalk.green(`Key stored in vault: auth.${service}${details.length ? ` (${details.join(", ")})` : ""}`));
394
404
  } catch (err) {
395
405
  spinner.fail(chalk.red(err.message));
396
406
  }
@@ -637,11 +647,14 @@ Examples:
637
647
  // clauth watchdog
638
648
  // ──────────────────────────────────────────────
639
649
  program
640
- .command("watchdog [action]")
641
- .description("Manage auto-restart watchdog (install|uninstall|status|start)")
642
- .action(async (action) => {
650
+ .command("watchdog [action] [args...]")
651
+ .description("Manage auto-restart watchdog (install|uninstall|status|start|register|list|events|restart)")
652
+ .option("--manifest <path>", "Watchdog service manifest for register")
653
+ .option("--service <id>", "Watchdog service id for restart")
654
+ .option("--limit <n>", "Event count for events", "100")
655
+ .action(async (action, args, opts) => {
643
656
  const { runWatchdog } = await import("./commands/watchdog.js");
644
- await runWatchdog(action);
657
+ await runWatchdog(action, { ...opts, args });
645
658
  });
646
659
 
647
660
  // clauth doctor
@@ -0,0 +1,217 @@
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) 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
+ // Stage
155
+ if (Array.isArray(opts.paths) && opts.paths.length > 0) {
156
+ if (opts.paths.length > 100) return { error: "Too many paths: max 100 per commit" };
157
+ const norm = [];
158
+ for (const p of opts.paths) {
159
+ const n = normalizeRepoPath(p);
160
+ if (!n) return { error: `Invalid repo path: ${p}` };
161
+ norm.push(n);
162
+ }
163
+ await runGitAsync(repoRoot, ["add", "--", ...norm]);
164
+ } else {
165
+ await runGitAsync(repoRoot, ["add", "-A"]);
166
+ }
167
+ const stagedRaw = await runGitAsync(repoRoot, ["diff", "--cached", "--name-only"]);
168
+ if (!stagedRaw) {
169
+ const headShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
170
+ return { result: { status: "nothing_to_commit", branch, head_short: headShort, message: "No changes in the requested scope; nothing was committed." } };
171
+ }
172
+ const committedFiles = stagedRaw.split("\n").filter(Boolean);
173
+
174
+ await runGitAsync(repoRoot, [
175
+ "-c", `user.name=${opts.authorName || "clauth-fs"}`,
176
+ "-c", `user.email=${opts.authorEmail || "fs@clauth.local"}`,
177
+ "commit", "-m", message,
178
+ ]);
179
+ const commitSha = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
180
+ const commitShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
181
+ const base = { branch, commit: commitSha, commit_short: commitShort, files: committedFiles };
182
+
183
+ if (!push) {
184
+ return { result: { status: "committed_local", ...base, pushed: false, message: `Committed ${committedFiles.length} file(s) locally as ${commitShort}.` } };
185
+ }
186
+ if (FS_GIT_PROTECTED_BRANCHES.has(branch.toLowerCase())) {
187
+ 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.` } };
188
+ }
189
+ if (!opts.token) {
190
+ 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"}.` } };
191
+ }
192
+ const token = opts.token.trim();
193
+
194
+ try {
195
+ await runGitPushAuthed(repoRoot, token, remote, `HEAD:${branch}`);
196
+ } catch (e1) {
197
+ // Branch likely diverged — integrate then retry once.
198
+ try { await runGitAsync(repoRoot, ["fetch", "--no-tags", remote, branch]); } catch { /* branch may not exist on remote yet */ }
199
+ try {
200
+ await runGitAsync(repoRoot, ["rebase", `${remote}/${branch}`]);
201
+ } catch {
202
+ try { await runGitAsync(repoRoot, ["rebase", "--abort"]); } catch {}
203
+ 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.` } };
204
+ }
205
+ try {
206
+ await runGitPushAuthed(repoRoot, token, remote, `HEAD:${branch}`);
207
+ } catch (e2) {
208
+ const sha2 = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
209
+ 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}` } };
210
+ }
211
+ }
212
+
213
+ const finalSha = await runGitAsync(repoRoot, ["rev-parse", "HEAD"]);
214
+ const finalShort = await runGitAsync(repoRoot, ["rev-parse", "--short", "HEAD"]);
215
+ const { ahead, behind } = await aheadBehind(repoRoot);
216
+ 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
+ }
@@ -0,0 +1,101 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { getConfOptions } from "./conf-path.js";
6
+ import { deriveToken } from "./fingerprint.js";
7
+ import * as api from "./api.js";
8
+
9
+ const RECOVERY_VERSION = 1;
10
+
11
+ function getRecoveryDir() {
12
+ const opts = getConfOptions();
13
+ if (opts.cwd) return path.join(opts.cwd, "recovery");
14
+ return path.join(os.homedir(), ".config", "clauth", "recovery");
15
+ }
16
+
17
+ function hashValue(value) {
18
+ return crypto.createHash("sha256").update(String(value), "utf8").digest("hex");
19
+ }
20
+
21
+ function deriveRecoveryKey(password, machineHash, salt) {
22
+ return crypto.scryptSync(`${password}:${machineHash}`, salt, 32);
23
+ }
24
+
25
+ function safeServiceName(service) {
26
+ return String(service || "unknown").replace(/[^a-zA-Z0-9_-]/g, "_");
27
+ }
28
+
29
+ export function normalizeCredentialValue(value, { keyType = "", service = "" } = {}) {
30
+ if (typeof value !== "string") return value;
31
+ const text = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
32
+ const looksPem = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(text);
33
+ const looksSsh = String(keyType).toLowerCase() === "ssh" || /ssh|pem|private/i.test(service);
34
+ if (!looksPem && !looksSsh) return value;
35
+ return text.replace(/\n*$/, "\n");
36
+ }
37
+
38
+ export async function snapshotCredentialBeforeWrite({ password, machineHash, service, logFile }) {
39
+ if (!password || !machineHash || !service) return { ok: false, skipped: "missing_context" };
40
+
41
+ let current;
42
+ try {
43
+ const { token, timestamp } = deriveToken(password, machineHash);
44
+ current = await api.retrieve(password, machineHash, token, timestamp, service);
45
+ } catch (err) {
46
+ return { ok: false, skipped: "retrieve_failed", error: err.message };
47
+ }
48
+
49
+ if (current?.error || current?.value === undefined || current?.value === null) {
50
+ return { ok: false, skipped: current?.error || "no_existing_value" };
51
+ }
52
+
53
+ const value = typeof current.value === "string" ? current.value : JSON.stringify(current.value);
54
+ const now = new Date().toISOString();
55
+ const salt = crypto.randomBytes(16);
56
+ const iv = crypto.randomBytes(12);
57
+ const key = deriveRecoveryKey(password, machineHash, salt);
58
+ const meta = {
59
+ version: RECOVERY_VERSION,
60
+ service,
61
+ key_type: current.key_type || null,
62
+ created_at: now,
63
+ value_sha256: hashValue(value),
64
+ };
65
+
66
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
67
+ cipher.setAAD(Buffer.from(JSON.stringify(meta), "utf8"));
68
+ const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
69
+ const tag = cipher.getAuthTag();
70
+
71
+ const payload = {
72
+ ...meta,
73
+ kdf: "scrypt",
74
+ cipher: "aes-256-gcm",
75
+ salt: salt.toString("base64"),
76
+ iv: iv.toString("base64"),
77
+ tag: tag.toString("base64"),
78
+ ciphertext: ciphertext.toString("base64"),
79
+ };
80
+
81
+ const dir = getRecoveryDir();
82
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
83
+ const stamp = now.replace(/[-:.]/g, "").replace("T", "-").replace("Z", "");
84
+ const filePath = path.join(dir, `${stamp}-${safeServiceName(service)}.json`);
85
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
86
+
87
+ if (logFile) {
88
+ try { fs.appendFileSync(logFile, `[${now}] Recovery snapshot written for ${service}: ${filePath}\n`); } catch {}
89
+ }
90
+ return { ok: true, filePath, value_sha256: meta.value_sha256 };
91
+ }
92
+
93
+ export async function writeCredentialWithRecovery({ password, machineHash, service, value, logFile, normalize = true }) {
94
+ const { token, timestamp } = deriveToken(password, machineHash);
95
+ const status = await api.status(password, machineHash, token, timestamp);
96
+ const svc = (status.services || []).find(s => String(s.name || "").toLowerCase() === String(service || "").toLowerCase());
97
+ const normalizedValue = normalize ? normalizeCredentialValue(value, { keyType: svc?.key_type, service }) : value;
98
+ const snapshot = await snapshotCredentialBeforeWrite({ password, machineHash, service, logFile });
99
+ const result = await api.write(password, machineHash, token, timestamp, service, normalizedValue);
100
+ return { result, snapshot, normalized: normalizedValue !== value };
101
+ }