mcp-for-i 0.1.13 → 0.2.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.
Files changed (67) hide show
  1. package/README.md +17 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +14 -9
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
@@ -62,10 +62,19 @@ function getCredentialStoreMode() {
62
62
  function describeError(err) {
63
63
  return err instanceof Error ? err.message : String(err);
64
64
  }
65
- export async function setPassword(connectionName, password) {
66
- if (getCredentialStoreMode() === "session") {
67
- sessionStore.set(connectionName, password);
68
- return;
65
+ export class KeychainUnavailableError extends Error {
66
+ constructor(operation, detail) {
67
+ super(`MCP_FOR_I_CREDENTIAL_STORE=keychain is set, but the OS keychain could not ${operation}` +
68
+ `${detail ? `: ${detail}` : ""}. ` +
69
+ "Passwords are not kept in memory in keychain mode. Fix the keychain (or unset MCP_FOR_I_CREDENTIAL_STORE) and try again.");
70
+ this.name = "KeychainUnavailableError";
71
+ }
72
+ }
73
+ export async function setPassword(connectionName, password, options = {}) {
74
+ const mode = getCredentialStoreMode();
75
+ if (mode === "session") {
76
+ setSessionValue(connectionName, password, options);
77
+ return { persistent: false, store: "session" };
69
78
  }
70
79
  const kt = await getKeytar();
71
80
  if (kt) {
@@ -73,16 +82,24 @@ export async function setPassword(connectionName, password) {
73
82
  await kt.setPassword(SERVICE, connectionName, password);
74
83
  lastKeychainError = undefined;
75
84
  sessionStore.delete(connectionName);
76
- return;
85
+ return { persistent: true, store: "keychain" };
77
86
  }
78
87
  catch (err) {
79
88
  lastKeychainError = describeError(err);
80
89
  }
81
90
  }
82
- sessionStore.set(connectionName, password);
91
+ else if (!lastKeychainError) {
92
+ lastKeychainError = "keytar module is not available";
93
+ }
94
+ if (mode === "keychain") {
95
+ throw new KeychainUnavailableError("store the password", lastKeychainError);
96
+ }
97
+ setSessionValue(connectionName, password, options);
98
+ return { persistent: false, store: "session" };
83
99
  }
84
100
  export async function getPassword(connectionName) {
85
- if (getCredentialStoreMode() === "session") {
101
+ const mode = getCredentialStoreMode();
102
+ if (mode === "session") {
86
103
  return sessionStore.get(connectionName);
87
104
  }
88
105
  const kt = await getKeytar();
@@ -92,15 +109,23 @@ export async function getPassword(connectionName) {
92
109
  lastKeychainError = undefined;
93
110
  if (password)
94
111
  return password;
112
+ if (mode === "keychain")
113
+ return undefined;
95
114
  }
96
115
  catch (err) {
97
116
  lastKeychainError = describeError(err);
117
+ if (mode === "keychain")
118
+ throw new KeychainUnavailableError("read the password", lastKeychainError);
98
119
  }
99
120
  }
121
+ else if (mode === "keychain") {
122
+ throw new KeychainUnavailableError("read the password", "keytar module is not available");
123
+ }
100
124
  return sessionStore.get(connectionName);
101
125
  }
102
126
  export async function deletePassword(connectionName) {
103
- if (getCredentialStoreMode() === "session") {
127
+ const mode = getCredentialStoreMode();
128
+ if (mode === "session") {
104
129
  sessionStore.delete(connectionName);
105
130
  return;
106
131
  }
@@ -112,10 +137,32 @@ export async function deletePassword(connectionName) {
112
137
  }
113
138
  catch (err) {
114
139
  lastKeychainError = describeError(err);
140
+ if (mode === "keychain") {
141
+ sessionStore.delete(connectionName);
142
+ throw new KeychainUnavailableError("delete the password", lastKeychainError);
143
+ }
115
144
  }
116
145
  }
146
+ else if (mode === "keychain") {
147
+ sessionStore.delete(connectionName);
148
+ throw new KeychainUnavailableError("delete the password", "keytar module is not available");
149
+ }
117
150
  sessionStore.delete(connectionName);
118
151
  }
152
+ function setSessionValue(connectionName, password, options) {
153
+ if (options.keepExistingSessionValue && sessionStore.has(connectionName))
154
+ return;
155
+ sessionStore.set(connectionName, password);
156
+ }
157
+ /**
158
+ * Replaces the keytar module used by this store. Intended for tests only:
159
+ * pass an object with keytar's methods, `null` for "keytar not installed",
160
+ * or `undefined` to restore normal lazy loading.
161
+ */
162
+ export function setKeytarModuleForTesting(mod) {
163
+ keytar = mod;
164
+ lastKeychainError = undefined;
165
+ }
119
166
  export async function isKeychainAvailable() {
120
167
  if (getCredentialStoreMode() === "session")
121
168
  return false;
@@ -153,11 +200,13 @@ export async function getCredentialStoreStatus() {
153
200
  error: lastKeychainError,
154
201
  message: keychainAvailable
155
202
  ? `Passwords are stored in ${environment.nativeStore}.`
156
- : requestedMode === "session"
157
- ? "Password storage is configured for session-only mode by MCP_FOR_I_CREDENTIAL_STORE=session."
158
- : isWsl
159
- ? "WSL could not access a persistent Linux Secret Service keychain; passwords are kept in this process only. Use SSH keys, install/configure libsecret in WSL, or run MCP-for-i from Windows to use Windows Credential Manager."
160
- : "No persistent OS keychain is available; passwords are kept in this process only."
203
+ : requestedMode === "keychain"
204
+ ? `MCP_FOR_I_CREDENTIAL_STORE=keychain is set but ${environment.nativeStore} is not usable; saving or reading passwords will fail until it is fixed.`
205
+ : requestedMode === "session"
206
+ ? "Password storage is configured for session-only mode by MCP_FOR_I_CREDENTIAL_STORE=session."
207
+ : isWsl
208
+ ? "WSL could not access a persistent Linux Secret Service keychain; passwords are kept in this process only. Use SSH keys, install/configure libsecret in WSL, or run MCP-for-i from Windows to use Windows Credential Manager."
209
+ : "No persistent OS keychain is available; passwords are kept in this process only."
161
210
  };
162
211
  }
163
212
  function isWslRuntime(env = process.env, platform = process.platform, release = os.release()) {
@@ -0,0 +1,138 @@
1
+ import fs from "fs/promises";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { getConfigDir } from "../config/store.js";
5
+ const caseInsensitive = process.platform === "win32" || process.platform === "darwin";
6
+ function sensitiveLocations() {
7
+ const home = os.homedir();
8
+ const list = [
9
+ ".ssh",
10
+ ".aws",
11
+ ".gnupg",
12
+ ".azure",
13
+ ".kube",
14
+ ".docker",
15
+ ".netrc",
16
+ ".git-credentials",
17
+ ".pgpass",
18
+ ".npmrc",
19
+ ".pypirc",
20
+ path.join(".config", "gcloud"),
21
+ path.join(".config", "gh")
22
+ ].map(rel => path.join(home, rel));
23
+ if (process.platform === "win32") {
24
+ list.push(process.env.APPDATA || path.join(home, "AppData", "Roaming"));
25
+ }
26
+ if (process.platform === "darwin") {
27
+ list.push(path.join(home, "Library", "Keychains"));
28
+ }
29
+ list.push(getConfigDir());
30
+ return list;
31
+ }
32
+ /**
33
+ * On Windows, %LOCALAPPDATA% holds browser profiles and cached tokens, but
34
+ * also the temp directory. Everything there except temp is refused.
35
+ */
36
+ function isInsideLocalAppDataOutsideTemp(resolved) {
37
+ if (process.platform !== "win32")
38
+ return false;
39
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
40
+ if (!isInside(resolved, localAppData))
41
+ return false;
42
+ return !isInside(resolved, os.tmpdir()) && !isInside(resolved, path.join(localAppData, "Temp"));
43
+ }
44
+ function isCredentialsFileUnderConfig(resolved) {
45
+ const configRoot = path.join(os.homedir(), ".config");
46
+ if (!isInside(resolved, configRoot))
47
+ return false;
48
+ return path.relative(configRoot, resolved)
49
+ .split(path.sep)
50
+ .some(segment => /^credentials(\.[\w-]+)?$/i.test(segment));
51
+ }
52
+ function comparable(value) {
53
+ const normalized = path.resolve(value);
54
+ return caseInsensitive ? normalized.toLowerCase() : normalized;
55
+ }
56
+ function isInside(target, root) {
57
+ const relative = path.relative(comparable(root), comparable(target));
58
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
59
+ }
60
+ /** Resolve symlinks for the longest existing prefix of the path. */
61
+ async function realpathAllowMissing(target) {
62
+ const absolute = path.resolve(target);
63
+ const missing = [];
64
+ let current = absolute;
65
+ for (;;) {
66
+ try {
67
+ const real = await fs.realpath(current);
68
+ return missing.length > 0 ? path.join(real, ...missing.reverse()) : real;
69
+ }
70
+ catch {
71
+ const parent = path.dirname(current);
72
+ if (parent === current)
73
+ return absolute;
74
+ missing.push(path.basename(current));
75
+ current = parent;
76
+ }
77
+ }
78
+ }
79
+ export function parseRootList(value) {
80
+ return String(value || "")
81
+ .split(path.delimiter)
82
+ .map(item => item.trim())
83
+ .filter(Boolean);
84
+ }
85
+ export function defaultLocalRoots() {
86
+ // A working directory that is a filesystem root (common when a client
87
+ // launches the server) would allow the whole disk, so it is skipped.
88
+ return [process.cwd(), os.homedir()].filter(root => {
89
+ const resolved = path.resolve(root);
90
+ return path.parse(resolved).root !== resolved;
91
+ });
92
+ }
93
+ export function configuredLocalRoots(settingsRoots) {
94
+ const fromEnv = parseRootList(process.env.MCP_FOR_I_ALLOWED_LOCAL_ROOTS);
95
+ if (fromEnv.length > 0)
96
+ return fromEnv;
97
+ if (Array.isArray(settingsRoots)) {
98
+ const fromSettings = settingsRoots.map(v => String(v || "").trim()).filter(Boolean);
99
+ if (fromSettings.length > 0)
100
+ return fromSettings;
101
+ }
102
+ return defaultLocalRoots();
103
+ }
104
+ /**
105
+ * Resolve a local path and refuse it unless it is inside an allowed root and
106
+ * outside every sensitive location. Returns the resolved real path, which
107
+ * callers should use for the actual file operation.
108
+ */
109
+ export async function resolveAllowedLocalPath(value, mode, roots = configuredLocalRoots()) {
110
+ const raw = String(value ?? "").trim();
111
+ if (!raw)
112
+ throw new Error("Local path is required");
113
+ if (raw.includes("\0"))
114
+ throw new Error("Local path contains a NUL character");
115
+ const expanded = raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")
116
+ ? path.join(os.homedir(), raw.substring(1))
117
+ : raw;
118
+ const resolved = await realpathAllowMissing(expanded);
119
+ for (const sensitive of sensitiveLocations()) {
120
+ const realSensitive = await realpathAllowMissing(sensitive);
121
+ if (isInside(resolved, sensitive) || isInside(resolved, realSensitive)) {
122
+ throw new Error(`Local path refused (${mode}): ${raw} is inside a protected location`);
123
+ }
124
+ }
125
+ if (isInsideLocalAppDataOutsideTemp(resolved)) {
126
+ throw new Error(`Local path refused (${mode}): ${raw} is inside a protected location`);
127
+ }
128
+ if (isCredentialsFileUnderConfig(resolved)) {
129
+ throw new Error(`Local path refused (${mode}): ${raw} is a credentials file`);
130
+ }
131
+ for (const root of roots) {
132
+ const realRoot = await realpathAllowMissing(root);
133
+ if (isInside(resolved, realRoot))
134
+ return resolved;
135
+ }
136
+ throw new Error(`Local path refused (${mode}): ${raw} is outside the allowed local roots. ` +
137
+ "Set MCP_FOR_I_ALLOWED_LOCAL_ROOTS or settings.allowedLocalRoots to allow it.");
138
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Secret redaction for logs and audit records.
3
+ *
4
+ * Keys are matched case-insensitively by substring, so `newPassword`,
5
+ * `db_pwd` and `apiKey` are all caught. String values are also scanned for
6
+ * secrets embedded in CL commands, SQL text and command lines.
7
+ */
8
+ export const REDACTED = "***REDACTED***";
9
+ const SECRET_KEY_PARTS = [
10
+ "password",
11
+ "passwd",
12
+ "pwd",
13
+ "secret",
14
+ "token",
15
+ "apikey",
16
+ "api_key",
17
+ "api-key",
18
+ "privatekey",
19
+ "private_key",
20
+ "passphrase",
21
+ "authorization",
22
+ "signingkey",
23
+ "credential"
24
+ ];
25
+ export function isSecretKey(key) {
26
+ const lower = String(key || "").toLowerCase();
27
+ return SECRET_KEY_PARTS.some(part => lower.includes(part));
28
+ }
29
+ const SECRET_WORDS = "[\\w-]*(?:password|passwd|pwd|secret|token|apikey|api_key|passphrase)[\\w-]*";
30
+ const TEXT_RULES = [
31
+ // CL keyword parameters: PASSWORD(x), PWD('x'), NEWPWD(x), OLDPWD(x), RMTPWD(x) ...
32
+ {
33
+ pattern: /\b([A-Z0-9]*PWD|PASSWORD|PASSWD|PASSPHRASE)(\s*\(\s*)('(?:[^']|'')*'|[^)]*)(\))/gi,
34
+ replace: (_m, kw, open, _value, close) => `${kw}${open}${REDACTED}${close}`
35
+ },
36
+ // SQL: SET ENCRYPTION PASSWORD = 'x', PASSWORD 'x', ... USING 'x'
37
+ {
38
+ pattern: /\b(PASSWORD|USING)(\s*=?\s*)'(?:[^']|'')*'/gi,
39
+ replace: (_m, kw, sep) => `${kw}${sep}'${REDACTED}'`
40
+ },
41
+ // Command-line flags: --password=x, --password x, -pwd x
42
+ {
43
+ pattern: new RegExp(`(?<![\\w-])(--?${SECRET_WORDS})(=|\\s+)("[^"]*"|'[^']*'|[^\\s,;&]+)`, "gi"),
44
+ replace: (_m, flag, sep) => `${flag}${sep}${REDACTED}`
45
+ },
46
+ // key=value / key: value / "key":"value" pairs
47
+ {
48
+ pattern: new RegExp(`(\\b${SECRET_WORDS}"?)(\\s*[=:]\\s*)("[^"]*"|'[^']*'|[^\\s,;&"']+)`, "gi"),
49
+ replace: (_m, key, sep) => `${key}${sep}${REDACTED}`
50
+ },
51
+ // HTTP authorization values
52
+ {
53
+ pattern: /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g,
54
+ replace: (_m, scheme) => `${scheme} ${REDACTED}`
55
+ }
56
+ ];
57
+ /** Redact secrets embedded in a free-text value. */
58
+ export function redactSecretsInText(text) {
59
+ let out = String(text);
60
+ for (const rule of TEXT_RULES) {
61
+ out = out.replace(rule.pattern, (...args) => rule.replace(...args.slice(0, -2)));
62
+ }
63
+ return out;
64
+ }
65
+ /** Deep-copy a value, redacting secret keys and secrets inside strings. */
66
+ export function redactSecrets(input) {
67
+ if (Array.isArray(input))
68
+ return input.map(redactSecrets);
69
+ if (input && typeof input === "object") {
70
+ const out = {};
71
+ for (const [key, value] of Object.entries(input)) {
72
+ out[key] = isSecretKey(key) ? REDACTED : redactSecrets(value);
73
+ }
74
+ return out;
75
+ }
76
+ if (typeof input === "string")
77
+ return redactSecretsInText(input);
78
+ return input;
79
+ }
@@ -0,0 +1,101 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ /**
5
+ * Error codes that Windows (antivirus, search indexer, backup agents, another
6
+ * reader holding the file open) raises transiently on rename/open/unlink.
7
+ */
8
+ const TRANSIENT_CODES = new Set(["EPERM", "EBUSY", "EACCES", "EAGAIN", "EMFILE", "ENFILE"]);
9
+ export function isTransientFsError(err) {
10
+ const code = err?.code;
11
+ return typeof code === "string" && TRANSIENT_CODES.has(code);
12
+ }
13
+ /**
14
+ * Writes `data` to `filePath` atomically: the content goes to a uniquely named
15
+ * temp file in the same directory, is fsynced, and then renamed over the target.
16
+ * Rename is retried on transient Windows errors. There is deliberately no
17
+ * non-atomic fallback: if the rename keeps failing the temp file is removed and
18
+ * the error is thrown, leaving the original file untouched.
19
+ */
20
+ export async function writeFileAtomic(filePath, data, options = {}) {
21
+ const dir = path.dirname(filePath);
22
+ const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
23
+ const mode = options.mode ?? 0o666;
24
+ const handle = await fs.open(tempPath, "wx", mode);
25
+ try {
26
+ if (typeof data === "string") {
27
+ await handle.writeFile(data, options.encoding || "utf8");
28
+ }
29
+ else {
30
+ await handle.writeFile(data);
31
+ }
32
+ if (options.fsync !== false) {
33
+ await handle.sync();
34
+ }
35
+ }
36
+ catch (err) {
37
+ await handle.close().catch(() => { });
38
+ await fs.rm(tempPath, { force: true }).catch(() => { });
39
+ throw err;
40
+ }
41
+ await handle.close();
42
+ try {
43
+ await renameWithRetry(tempPath, filePath, options.retries ?? 10, options.retryDelayMs ?? 25);
44
+ }
45
+ catch (err) {
46
+ await fs.rm(tempPath, { force: true }).catch(() => { });
47
+ throw err;
48
+ }
49
+ if (options.mode !== undefined && process.platform !== "win32") {
50
+ await fs.chmod(filePath, options.mode).catch(() => { });
51
+ }
52
+ if (options.fsync !== false) {
53
+ await fsyncDirectory(dir);
54
+ }
55
+ }
56
+ export async function renameWithRetry(from, to, retries = 10, delayMs = 25) {
57
+ for (let attempt = 0;; attempt++) {
58
+ try {
59
+ await fs.rename(from, to);
60
+ return;
61
+ }
62
+ catch (err) {
63
+ if (!isTransientFsError(err) || attempt >= retries)
64
+ throw err;
65
+ await sleep(delayMs * (attempt + 1));
66
+ }
67
+ }
68
+ }
69
+ /** Reads a file, retrying on transient Windows sharing errors. ENOENT and other errors are thrown unchanged. */
70
+ export async function readFileWithRetry(filePath, retries = 5, delayMs = 25) {
71
+ for (let attempt = 0;; attempt++) {
72
+ try {
73
+ return await fs.readFile(filePath, "utf8");
74
+ }
75
+ catch (err) {
76
+ if (!isTransientFsError(err) || attempt >= retries)
77
+ throw err;
78
+ await sleep(delayMs * (attempt + 1));
79
+ }
80
+ }
81
+ }
82
+ async function fsyncDirectory(dir) {
83
+ // Directory fsync makes the rename durable on POSIX; Windows cannot open directories this way.
84
+ if (process.platform === "win32")
85
+ return;
86
+ try {
87
+ const handle = await fs.open(dir, "r");
88
+ try {
89
+ await handle.sync();
90
+ }
91
+ finally {
92
+ await handle.close();
93
+ }
94
+ }
95
+ catch {
96
+ // Best effort only.
97
+ }
98
+ }
99
+ export function sleep(ms) {
100
+ return new Promise(resolve => setTimeout(resolve, ms));
101
+ }
@@ -0,0 +1,206 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { isTransientFsError, sleep } from "./atomicWrite.js";
6
+ const DEFAULT_STALE_MS = 30_000;
7
+ const DEFAULT_TIMEOUT_MS = 15_000;
8
+ const DEFAULT_RETRY_MS = 20;
9
+ // Serializes lock users inside this process so they queue instead of spinning on the lock file.
10
+ const inProcessQueues = new Map();
11
+ /**
12
+ * Runs `fn` while holding an exclusive lock that works across processes.
13
+ *
14
+ * - Within one process, callers using the same lock path are queued in order.
15
+ * - Across processes the lock is a file created with O_EXCL ("wx"). The owner
16
+ * refreshes its mtime while it holds the lock; a lock file that is older than
17
+ * `staleMs`, or whose owner process on this host no longer exists, is broken.
18
+ *
19
+ * The lock is not re-entrant: calling withFileLock for the same path from inside
20
+ * `fn` deadlocks until the timeout.
21
+ */
22
+ export async function withFileLock(lockPath, fn, options = {}) {
23
+ const key = lockKey(lockPath);
24
+ const previous = inProcessQueues.get(key) || Promise.resolve();
25
+ let releaseQueue;
26
+ const gate = new Promise(resolve => {
27
+ releaseQueue = resolve;
28
+ });
29
+ const tail = previous.then(() => gate);
30
+ inProcessQueues.set(key, tail);
31
+ await previous;
32
+ try {
33
+ const handle = await acquireFileLock(lockPath, options);
34
+ try {
35
+ return await fn();
36
+ }
37
+ finally {
38
+ await handle.release();
39
+ }
40
+ }
41
+ finally {
42
+ releaseQueue();
43
+ if (inProcessQueues.get(key) === tail)
44
+ inProcessQueues.delete(key);
45
+ }
46
+ }
47
+ /**
48
+ * Acquires the cross-process lock file only (no in-process queueing). Prefer
49
+ * withFileLock unless the lock must be held across separate calls.
50
+ */
51
+ export async function acquireFileLock(lockPath, options = {}) {
52
+ const staleMs = Math.max(1000, options.staleMs ?? DEFAULT_STALE_MS);
53
+ const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
54
+ const retryDelayMs = Math.max(1, options.retryDelayMs ?? DEFAULT_RETRY_MS);
55
+ const deadline = Date.now() + timeoutMs;
56
+ const token = crypto.randomBytes(16).toString("hex");
57
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
58
+ let lastOwner;
59
+ for (let attempt = 0;; attempt++) {
60
+ try {
61
+ await createLockFile(lockPath, token);
62
+ return startHeldLock(lockPath, token, staleMs);
63
+ }
64
+ catch (err) {
65
+ const code = err?.code;
66
+ if (code !== "EEXIST" && !isTransientFsError(err))
67
+ throw err;
68
+ }
69
+ const state = await inspectLock(lockPath);
70
+ if (state) {
71
+ lastOwner = state.owner;
72
+ if (isStale(state, staleMs)) {
73
+ await breakStaleLock(lockPath, state.raw);
74
+ continue;
75
+ }
76
+ }
77
+ if (Date.now() >= deadline) {
78
+ const holder = lastOwner?.pid ? ` (held by pid ${lastOwner.pid} on ${lastOwner.hostname || "unknown host"})` : "";
79
+ throw new Error(`Timed out after ${timeoutMs} ms waiting for lock ${lockPath}${holder}`);
80
+ }
81
+ const backoff = Math.min(retryDelayMs * (1 + Math.floor(attempt / 5)), 250);
82
+ await sleep(backoff + Math.floor(Math.random() * retryDelayMs));
83
+ }
84
+ }
85
+ async function createLockFile(lockPath, token) {
86
+ const owner = {
87
+ pid: process.pid,
88
+ hostname: os.hostname(),
89
+ token,
90
+ acquiredAt: new Date().toISOString()
91
+ };
92
+ const handle = await fs.open(lockPath, "wx", 0o600);
93
+ try {
94
+ await handle.writeFile(JSON.stringify(owner), "utf8");
95
+ }
96
+ finally {
97
+ await handle.close();
98
+ }
99
+ }
100
+ function startHeldLock(lockPath, token, staleMs) {
101
+ const heartbeat = setInterval(() => {
102
+ const now = new Date();
103
+ fs.utimes(lockPath, now, now).catch(() => { });
104
+ }, Math.max(250, Math.floor(staleMs / 3)));
105
+ heartbeat.unref?.();
106
+ let released = false;
107
+ return {
108
+ lockPath,
109
+ token,
110
+ async release() {
111
+ if (released)
112
+ return;
113
+ released = true;
114
+ clearInterval(heartbeat);
115
+ try {
116
+ const raw = await fs.readFile(lockPath, "utf8");
117
+ if (parseOwner(raw)?.token !== token)
118
+ return; // our lock was broken and someone else owns it now
119
+ }
120
+ catch {
121
+ return;
122
+ }
123
+ for (let attempt = 0; attempt < 10; attempt++) {
124
+ try {
125
+ await fs.unlink(lockPath);
126
+ return;
127
+ }
128
+ catch (err) {
129
+ if (err?.code === "ENOENT")
130
+ return;
131
+ if (!isTransientFsError(err))
132
+ throw err;
133
+ await sleep(15 * (attempt + 1));
134
+ }
135
+ }
136
+ }
137
+ };
138
+ }
139
+ async function inspectLock(lockPath) {
140
+ try {
141
+ const stat = await fs.stat(lockPath);
142
+ let raw = "";
143
+ try {
144
+ raw = await fs.readFile(lockPath, "utf8");
145
+ }
146
+ catch {
147
+ // Holder may be mid-create or mid-release; age alone decides.
148
+ }
149
+ return { raw, owner: parseOwner(raw), mtimeMs: stat.mtimeMs };
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
155
+ function isStale(state, staleMs) {
156
+ if (Date.now() - state.mtimeMs > staleMs)
157
+ return true;
158
+ const owner = state.owner;
159
+ if (owner?.pid && owner.hostname === os.hostname() && owner.pid !== process.pid) {
160
+ return !isProcessAlive(owner.pid);
161
+ }
162
+ return false;
163
+ }
164
+ function isProcessAlive(pid) {
165
+ try {
166
+ process.kill(pid, 0);
167
+ return true;
168
+ }
169
+ catch (err) {
170
+ return err?.code === "EPERM";
171
+ }
172
+ }
173
+ async function breakStaleLock(lockPath, observedRaw) {
174
+ const aside = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
175
+ try {
176
+ await fs.rename(lockPath, aside);
177
+ }
178
+ catch {
179
+ return; // someone else broke or released it first
180
+ }
181
+ let movedRaw = "";
182
+ try {
183
+ movedRaw = await fs.readFile(aside, "utf8");
184
+ }
185
+ catch {
186
+ // ignore
187
+ }
188
+ if (observedRaw && movedRaw !== observedRaw) {
189
+ // We moved a lock that was re-acquired after we inspected it. Put it back if nobody took the slot.
190
+ await fs.link(aside, lockPath).catch(() => { });
191
+ }
192
+ await fs.rm(aside, { force: true }).catch(() => { });
193
+ }
194
+ function parseOwner(raw) {
195
+ try {
196
+ const parsed = JSON.parse(raw);
197
+ return parsed && typeof parsed === "object" ? parsed : undefined;
198
+ }
199
+ catch {
200
+ return undefined;
201
+ }
202
+ }
203
+ function lockKey(lockPath) {
204
+ const resolved = path.resolve(lockPath);
205
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
206
+ }