@mokoconsulting/mokomysql-mcp 2.5.4 → 2.6.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.
- package/LICENSE +696 -696
- package/README.md +122 -112
- package/config.example.json +27 -26
- package/dist/README.md +7 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts +57 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +224 -0
- package/dist/migrate.js.map +1 -0
- package/dist/secrets.d.ts.map +1 -1
- package/dist/secrets.js +8 -2
- package/dist/secrets.js.map +1 -1
- package/package.json +1 -1
- package/source/client.ts +210 -210
- package/source/config.ts +104 -97
- package/source/index.ts +1105 -1100
- package/source/migrate.ts +263 -0
- package/source/secrets.ts +78 -70
- package/source/state.ts +62 -62
- package/source/types.ts +91 -91
- package/tsconfig.json +19 -19
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
*
|
|
3
|
+
* This file is part of a Moko Consulting project.
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* FILE INFORMATION
|
|
8
|
+
* DEFGROUP: mokomysql-mcp.Migrate
|
|
9
|
+
* INGROUP: mokomysql-mcp
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
+
* PATH: /source/migrate.ts
|
|
12
|
+
* BRIEF: Self-heal inline secrets in the MySQL MCP config into file-backed refs.
|
|
13
|
+
*
|
|
14
|
+
* No-inline-secrets policy (MokoCLI #1043). Any secret found INLINE in the
|
|
15
|
+
* on-disk `~/.mcp_mokomysql.json` is migrated to `~/.ssh/tokens/<slug>.token`
|
|
16
|
+
* and the config is rewritten so the field becomes `file:<path>`.
|
|
17
|
+
*
|
|
18
|
+
* mysql-specific: secret files are written as RAW values (NOT JSON). A MySQL
|
|
19
|
+
* password is an opaque string; `resolveRef()` reads the file verbatim, so a
|
|
20
|
+
* JSON wrapper would be read back literally and corrupt the credential. This
|
|
21
|
+
* intentionally diverges from the GitHub/OAuth JSON token-file shape used by
|
|
22
|
+
* token-based MCPs.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { constants as FS } from 'node:fs';
|
|
26
|
+
import { access, chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { dirname, resolve } from 'node:path';
|
|
29
|
+
|
|
30
|
+
/** Directory that holds file-backed secrets. */
|
|
31
|
+
function tokensDir(): string {
|
|
32
|
+
return resolve(homedir(), '.ssh', 'tokens');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when the value is already a `file:<path>` reference (nothing to migrate). */
|
|
36
|
+
function isFileRef(value: unknown): boolean {
|
|
37
|
+
return typeof value === 'string' && /^file:/.test(value.trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when the value is a non-empty inline literal that must be migrated. */
|
|
41
|
+
function isInlineSecret(value: unknown): value is string {
|
|
42
|
+
return typeof value === 'string' && value.trim() !== '' && !isFileRef(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Sanitize a slug component to the `[A-Za-z0-9._-]` alphabet. */
|
|
46
|
+
function sanitize(part: string): string {
|
|
47
|
+
return part.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** stderr only — stdout is the MCP stdio channel and must never carry logs. */
|
|
51
|
+
function warn(message: string): void {
|
|
52
|
+
process.stderr.write(`[mokomysql] WARNING: ${message}\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One sensitive field discovered inline on a connection, with the slug its
|
|
57
|
+
* secret file should take. `field` is the config key to rewrite; `get`/`set`
|
|
58
|
+
* read and replace the value in the parsed object.
|
|
59
|
+
*/
|
|
60
|
+
interface InlineField {
|
|
61
|
+
connectionKey: string;
|
|
62
|
+
/** Human label for warnings, e.g. "prod.password" or "prod.ssl.key". */
|
|
63
|
+
label: string;
|
|
64
|
+
slug: string;
|
|
65
|
+
value: string;
|
|
66
|
+
/** Replace the inline value with a `file:` ref in the parsed object. */
|
|
67
|
+
apply(ref: string): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Enumerate every inline secret across all connections. The ONLY top-level
|
|
72
|
+
* connection secret in this config shape is `password`; an inline PEM private
|
|
73
|
+
* key under `ssl.key` is also a secret value (a key *value*, not a path) and is
|
|
74
|
+
* migrated too. A `file:` ref or empty value is skipped.
|
|
75
|
+
*/
|
|
76
|
+
function findInlineSecrets(connections: Record<string, unknown>): InlineField[] {
|
|
77
|
+
const out: InlineField[] = [];
|
|
78
|
+
for (const [key, raw] of Object.entries(connections)) {
|
|
79
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
80
|
+
const conn = raw as Record<string, unknown>;
|
|
81
|
+
const connSlug = sanitize(key) || 'connection';
|
|
82
|
+
|
|
83
|
+
if (isInlineSecret(conn.password)) {
|
|
84
|
+
out.push({
|
|
85
|
+
connectionKey: key,
|
|
86
|
+
label: `${key}.password`,
|
|
87
|
+
slug: `mysql-${connSlug}-password`,
|
|
88
|
+
value: conn.password,
|
|
89
|
+
apply: (ref) => {
|
|
90
|
+
conn.password = ref;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ssl = conn.ssl;
|
|
96
|
+
if (ssl && typeof ssl === 'object') {
|
|
97
|
+
const sslRec = ssl as Record<string, unknown>;
|
|
98
|
+
// An inline PEM private-key *value* (begins with a PEM header) is a
|
|
99
|
+
// secret; a short reference/path is left alone.
|
|
100
|
+
if (isInlineSecret(sslRec.key) && /BEGIN[^\n]*PRIVATE KEY/.test(sslRec.key)) {
|
|
101
|
+
out.push({
|
|
102
|
+
connectionKey: key,
|
|
103
|
+
label: `${key}.ssl.key`,
|
|
104
|
+
slug: `mysql-${connSlug}-ssl-key`,
|
|
105
|
+
value: sslRec.key,
|
|
106
|
+
apply: (ref) => {
|
|
107
|
+
sslRec.key = ref;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** True if the path exists. */
|
|
117
|
+
async function exists(path: string): Promise<boolean> {
|
|
118
|
+
try {
|
|
119
|
+
await access(path, FS.F_OK);
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write the raw secret to `~/.ssh/tokens/<slug>.token` (mode 0600). If the file
|
|
128
|
+
* already exists it is NOT overwritten — the existing file is reused. Returns
|
|
129
|
+
* the absolute token path. mysql reads this file RAW, so the content is the
|
|
130
|
+
* verbatim secret with no trailing newline and no JSON wrapper.
|
|
131
|
+
*/
|
|
132
|
+
async function writeTokenFile(slug: string, secret: string): Promise<string> {
|
|
133
|
+
const dir = tokensDir();
|
|
134
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
135
|
+
// Best-effort tighten perms if the dir pre-existed with looser bits.
|
|
136
|
+
await chmod(dir, 0o700).catch(() => {});
|
|
137
|
+
|
|
138
|
+
const tokenPath = resolve(dir, `${slug}.token`);
|
|
139
|
+
if (await exists(tokenPath)) {
|
|
140
|
+
warn(`secret file already exists, reusing without overwrite: ${tokenPath}`);
|
|
141
|
+
return tokenPath;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Atomic: write temp in the same dir, chmod, rename into place.
|
|
145
|
+
const tmp = `${tokenPath}.tmp-${process.pid}`;
|
|
146
|
+
await writeFile(tmp, secret, { encoding: 'utf-8', mode: 0o600 });
|
|
147
|
+
await chmod(tmp, 0o600).catch(() => {});
|
|
148
|
+
await rename(tmp, tokenPath);
|
|
149
|
+
return tokenPath;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Collapse an absolute path under $HOME back to a portable `~/...` form. */
|
|
153
|
+
function tildeify(absPath: string): string {
|
|
154
|
+
const home = homedir();
|
|
155
|
+
if (absPath === home) return '~';
|
|
156
|
+
const prefix = home.endsWith('/') || home.endsWith('\\') ? home : home + '/';
|
|
157
|
+
const prefixBack = home.endsWith('/') || home.endsWith('\\') ? home : home + '\\';
|
|
158
|
+
if (absPath.startsWith(prefix)) return '~/' + absPath.slice(prefix.length).replace(/\\/g, '/');
|
|
159
|
+
if (absPath.startsWith(prefixBack)) return '~/' + absPath.slice(prefixBack.length).replace(/\\/g, '/');
|
|
160
|
+
return absPath;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Atomically rewrite the config file with the migrated `file:` refs. Backs the
|
|
165
|
+
* original up to `<config>.bak` once (only if no `.bak` exists yet). The config
|
|
166
|
+
* is written mode 0600.
|
|
167
|
+
*/
|
|
168
|
+
async function rewriteConfig(configPath: string, parsed: unknown): Promise<void> {
|
|
169
|
+
const bak = `${configPath}.bak`;
|
|
170
|
+
if (!(await exists(bak))) {
|
|
171
|
+
const original = await readFile(configPath, 'utf-8');
|
|
172
|
+
await writeFile(bak, original, { encoding: 'utf-8', mode: 0o600 });
|
|
173
|
+
await chmod(bak, 0o600).catch(() => {});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const serialized = JSON.stringify(parsed, null, '\t') + '\n';
|
|
177
|
+
const tmp = `${configPath}.tmp-${process.pid}`;
|
|
178
|
+
await writeFile(tmp, serialized, { encoding: 'utf-8', mode: 0o600 });
|
|
179
|
+
await chmod(tmp, 0o600).catch(() => {});
|
|
180
|
+
await rename(tmp, configPath);
|
|
181
|
+
await chmod(configPath, 0o600).catch(() => {});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Self-heal inline secrets in the parsed config, mutating `parsed` in place so
|
|
186
|
+
* the subsequent `resolveSecretRefs` pass picks up the new `file:` refs.
|
|
187
|
+
*
|
|
188
|
+
* Behavior (no-inline-secrets policy):
|
|
189
|
+
* - For each inline secret: warn to stderr (never echo the value), write a RAW
|
|
190
|
+
* token file under `~/.ssh/tokens/`, and rewrite the config so the field is a
|
|
191
|
+
* `file:` ref with the inline literal removed.
|
|
192
|
+
* - Existing token files are reused, never overwritten.
|
|
193
|
+
* - Config is backed up once to `<config>.bak`; writes are atomic; mode 0600.
|
|
194
|
+
* - On ANY error (read-only FS, perms, unwritable config) → warn only and keep
|
|
195
|
+
* the inline value for this run. Never throws, never loses the credential.
|
|
196
|
+
*
|
|
197
|
+
* Only runs against the real on-disk config path (the resolved
|
|
198
|
+
* `~/.mcp_mokomysql.json` or its `MYSQL_MCP_CONFIG` override).
|
|
199
|
+
*/
|
|
200
|
+
export async function migrateInlineSecrets(
|
|
201
|
+
parsed: { connections?: Record<string, unknown> },
|
|
202
|
+
configPath: string,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
try {
|
|
205
|
+
if (!parsed.connections || typeof parsed.connections !== 'object') return;
|
|
206
|
+
|
|
207
|
+
const inline = findInlineSecrets(parsed.connections);
|
|
208
|
+
if (inline.length === 0) return;
|
|
209
|
+
|
|
210
|
+
// Only touch the config if it is the real on-disk file we can read.
|
|
211
|
+
if (!(await exists(configPath))) {
|
|
212
|
+
warn(
|
|
213
|
+
`${inline.length} inline secret(s) found but config path is not a readable file ` +
|
|
214
|
+
`(${configPath}); leaving inline for this run.`,
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let migratedAny = false;
|
|
220
|
+
for (const field of inline) {
|
|
221
|
+
try {
|
|
222
|
+
warn(
|
|
223
|
+
`inline secret detected at ${field.label}; migrating to a file-backed secret ` +
|
|
224
|
+
`(no-inline-secrets policy, MokoCLI #1043). The value is NOT shown.`,
|
|
225
|
+
);
|
|
226
|
+
const tokenPath = await writeTokenFile(field.slug, field.value);
|
|
227
|
+
field.apply(`file:${tildeify(tokenPath)}`);
|
|
228
|
+
migratedAny = true;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
231
|
+
warn(
|
|
232
|
+
`failed to migrate ${field.label} to a file (${msg}); ` +
|
|
233
|
+
`keeping the inline value for this run.`,
|
|
234
|
+
);
|
|
235
|
+
// Revert any ref we may have set on this field so resolution uses the literal.
|
|
236
|
+
field.apply(field.value);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (migratedAny) {
|
|
241
|
+
try {
|
|
242
|
+
await rewriteConfig(configPath, parsed);
|
|
243
|
+
warn(
|
|
244
|
+
`config rewritten with file-backed secret ref(s): ${configPath} ` +
|
|
245
|
+
`(backup at ${configPath}.bak).`,
|
|
246
|
+
);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
249
|
+
warn(
|
|
250
|
+
`token file(s) written but rewriting the config failed (${msg}); ` +
|
|
251
|
+
`the config still references the secret value(s) inline. ` +
|
|
252
|
+
`The file-backed ref(s) are used for this run only.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Absolute backstop — migration must never crash the server.
|
|
258
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
259
|
+
warn(`secret auto-migration aborted (${msg}); continuing with config as-is.`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const __test = { sanitize, isInlineSecret, isFileRef, findInlineSecrets, tildeify };
|
package/source/secrets.ts
CHANGED
|
@@ -1,70 +1,78 @@
|
|
|
1
|
-
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
-
*
|
|
3
|
-
* This file is part of a Moko Consulting project.
|
|
4
|
-
*
|
|
5
|
-
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
-
*
|
|
7
|
-
* FILE INFORMATION
|
|
8
|
-
* DEFGROUP: mokomysql-mcp.Secrets
|
|
9
|
-
* INGROUP: mokomysql-mcp
|
|
10
|
-
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
-
* PATH: /source/secrets.ts
|
|
12
|
-
* BRIEF: Resolve file-backed secret references in a parsed config object.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { readFile } from 'node:fs/promises';
|
|
16
|
-
import { homedir } from 'node:os';
|
|
17
|
-
import { resolve } from 'node:path';
|
|
18
|
-
|
|
19
|
-
/** Expand a leading "~" to the user's home directory, then resolve to absolute. */
|
|
20
|
-
function expandHome(p: string): string {
|
|
21
|
-
if (p === '~') return homedir();
|
|
22
|
-
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
23
|
-
return resolve(homedir(), p.slice(2));
|
|
24
|
-
}
|
|
25
|
-
return resolve(p);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Resolve a single config string.
|
|
30
|
-
* - "file:<path>" -> the trimmed contents of <path> (a leading "~" expands to $HOME).
|
|
31
|
-
* - anything else -> returned unchanged, so inline literals keep working.
|
|
32
|
-
*
|
|
33
|
-
* A missing/unreadable file throws (fail loud) — a secret must never silently
|
|
34
|
-
* degrade to the literal "file:..." string.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
*
|
|
3
|
+
* This file is part of a Moko Consulting project.
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* FILE INFORMATION
|
|
8
|
+
* DEFGROUP: mokomysql-mcp.Secrets
|
|
9
|
+
* INGROUP: mokomysql-mcp
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
+
* PATH: /source/secrets.ts
|
|
12
|
+
* BRIEF: Resolve file-backed secret references in a parsed config object.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** Expand a leading "~" to the user's home directory, then resolve to absolute. */
|
|
20
|
+
function expandHome(p: string): string {
|
|
21
|
+
if (p === '~') return homedir();
|
|
22
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
23
|
+
return resolve(homedir(), p.slice(2));
|
|
24
|
+
}
|
|
25
|
+
return resolve(p);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a single config string.
|
|
30
|
+
* - "file:<path>" -> the trimmed contents of <path> (a leading "~" expands to $HOME).
|
|
31
|
+
* - anything else -> returned unchanged, so inline literals keep working.
|
|
32
|
+
*
|
|
33
|
+
* A missing/unreadable file throws (fail loud) — a secret must never silently
|
|
34
|
+
* degrade to the literal "file:..." string. An empty or whitespace-only file
|
|
35
|
+
* also throws, so a blank secret file never goes out as an empty value.
|
|
36
|
+
*/
|
|
37
|
+
async function resolveRef(value: string): Promise<string> {
|
|
38
|
+
const m = /^file:(.+)$/s.exec(value.trim());
|
|
39
|
+
if (!m) return value;
|
|
40
|
+
const path = expandHome(m[1].trim());
|
|
41
|
+
let trimmed: string;
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readFile(path, 'utf-8');
|
|
44
|
+
trimmed = raw.replace(/\r?\n+$/, '');
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
47
|
+
throw new Error(`Failed to read secret file "${path}" (referenced as "${value}"): ${msg}`);
|
|
48
|
+
}
|
|
49
|
+
if (trimmed.trim() === '') {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Secret file "${path}" (referenced as "${value}") is empty or whitespace-only; refusing to use an empty secret value.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return trimmed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recursively resolve every "file:" secret reference in a parsed config object,
|
|
59
|
+
* in place. Lets secrets (tokens, passwords, API keys) live in files such as
|
|
60
|
+
* ~/.ssh/tokens/* instead of inline in the config JSON. Plain values are left
|
|
61
|
+
* untouched, so pre-existing inline configs remain valid.
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveSecretRefs(obj: unknown): Promise<void> {
|
|
64
|
+
if (Array.isArray(obj)) {
|
|
65
|
+
for (let i = 0; i < obj.length; i++) {
|
|
66
|
+
const v = obj[i];
|
|
67
|
+
if (typeof v === 'string') obj[i] = await resolveRef(v);
|
|
68
|
+
else if (v && typeof v === 'object') await resolveSecretRefs(v);
|
|
69
|
+
}
|
|
70
|
+
} else if (obj && typeof obj === 'object') {
|
|
71
|
+
const rec = obj as Record<string, unknown>;
|
|
72
|
+
for (const k of Object.keys(rec)) {
|
|
73
|
+
const v = rec[k];
|
|
74
|
+
if (typeof v === 'string') rec[k] = await resolveRef(v);
|
|
75
|
+
else if (v && typeof v === 'object') await resolveSecretRefs(v);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
package/source/state.ts
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
-
*
|
|
3
|
-
* This file is part of a Moko Consulting project.
|
|
4
|
-
*
|
|
5
|
-
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
-
*
|
|
7
|
-
* FILE INFORMATION
|
|
8
|
-
* DEFGROUP: mokomysql.State
|
|
9
|
-
* INGROUP: mokomysql
|
|
10
|
-
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
-
* PATH: /source/state.ts
|
|
12
|
-
* BRIEF: Sidecar state store for learned Joomla markers (per host:port/database).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
16
|
-
import { getStatePath } from './config.js';
|
|
17
|
-
import type { JoomlaMarker, MySqlState } from './types.js';
|
|
18
|
-
|
|
19
|
-
let cache: MySqlState | null = null;
|
|
20
|
-
/** Serializes write-through so concurrent setMarker calls can't clobber the file. */
|
|
21
|
-
let writeChain: Promise<void> = Promise.resolve();
|
|
22
|
-
|
|
23
|
-
/** Stable key for a learned verdict: identifies the physical database target. */
|
|
24
|
-
export function markerKey(host: string, port: number, database: string): string {
|
|
25
|
-
return `${host}:${port}/${database}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Load the sidecar once into memory; a missing/corrupt file is an empty store. */
|
|
29
|
-
export async function loadState(): Promise<MySqlState> {
|
|
30
|
-
if (cache) return cache;
|
|
31
|
-
try {
|
|
32
|
-
const parsed = JSON.parse(await readFile(getStatePath(), 'utf-8')) as Partial<MySqlState>;
|
|
33
|
-
cache = parsed && typeof parsed.markers === 'object' && parsed.markers !== null
|
|
34
|
-
? { markers: parsed.markers }
|
|
35
|
-
: { markers: {} };
|
|
36
|
-
} catch {
|
|
37
|
-
cache = { markers: {} };
|
|
38
|
-
}
|
|
39
|
-
return cache;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function getMarker(key: string): Promise<JoomlaMarker | undefined> {
|
|
43
|
-
return (await loadState()).markers[key];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Record a learned verdict and persist it. The write is best-effort: an unwritable
|
|
48
|
-
* sidecar (read-only home, permissions) must not break queries — the in-memory
|
|
49
|
-
* cache still carries the verdict for this process, we just can't remember it next time.
|
|
50
|
-
*/
|
|
51
|
-
export async function setMarker(key: string, marker: JoomlaMarker): Promise<void> {
|
|
52
|
-
const state = await loadState();
|
|
53
|
-
state.markers[key] = marker;
|
|
54
|
-
writeChain = writeChain.then(async () => {
|
|
55
|
-
try {
|
|
56
|
-
await writeFile(getStatePath(), JSON.stringify(state, null, '\t') + '\n', 'utf-8');
|
|
57
|
-
} catch (e) {
|
|
58
|
-
process.stderr.write(`mokomysql: could not write state file ${getStatePath()}: ${e}\n`);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
await writeChain;
|
|
62
|
-
}
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
*
|
|
3
|
+
* This file is part of a Moko Consulting project.
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* FILE INFORMATION
|
|
8
|
+
* DEFGROUP: mokomysql.State
|
|
9
|
+
* INGROUP: mokomysql
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
+
* PATH: /source/state.ts
|
|
12
|
+
* BRIEF: Sidecar state store for learned Joomla markers (per host:port/database).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { getStatePath } from './config.js';
|
|
17
|
+
import type { JoomlaMarker, MySqlState } from './types.js';
|
|
18
|
+
|
|
19
|
+
let cache: MySqlState | null = null;
|
|
20
|
+
/** Serializes write-through so concurrent setMarker calls can't clobber the file. */
|
|
21
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
22
|
+
|
|
23
|
+
/** Stable key for a learned verdict: identifies the physical database target. */
|
|
24
|
+
export function markerKey(host: string, port: number, database: string): string {
|
|
25
|
+
return `${host}:${port}/${database}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Load the sidecar once into memory; a missing/corrupt file is an empty store. */
|
|
29
|
+
export async function loadState(): Promise<MySqlState> {
|
|
30
|
+
if (cache) return cache;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(await readFile(getStatePath(), 'utf-8')) as Partial<MySqlState>;
|
|
33
|
+
cache = parsed && typeof parsed.markers === 'object' && parsed.markers !== null
|
|
34
|
+
? { markers: parsed.markers }
|
|
35
|
+
: { markers: {} };
|
|
36
|
+
} catch {
|
|
37
|
+
cache = { markers: {} };
|
|
38
|
+
}
|
|
39
|
+
return cache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function getMarker(key: string): Promise<JoomlaMarker | undefined> {
|
|
43
|
+
return (await loadState()).markers[key];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record a learned verdict and persist it. The write is best-effort: an unwritable
|
|
48
|
+
* sidecar (read-only home, permissions) must not break queries — the in-memory
|
|
49
|
+
* cache still carries the verdict for this process, we just can't remember it next time.
|
|
50
|
+
*/
|
|
51
|
+
export async function setMarker(key: string, marker: JoomlaMarker): Promise<void> {
|
|
52
|
+
const state = await loadState();
|
|
53
|
+
state.markers[key] = marker;
|
|
54
|
+
writeChain = writeChain.then(async () => {
|
|
55
|
+
try {
|
|
56
|
+
await writeFile(getStatePath(), JSON.stringify(state, null, '\t') + '\n', 'utf-8');
|
|
57
|
+
} catch (e) {
|
|
58
|
+
process.stderr.write(`mokomysql: could not write state file ${getStatePath()}: ${e}\n`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
await writeChain;
|
|
62
|
+
}
|