react-native-sqlite-mcp 1.0.0 → 1.0.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/CONTRIBUTING.md +35 -0
- package/LICENSE +21 -0
- package/README.md +52 -31
- package/dist/db.js +73 -26
- package/dist/index.js +126 -60
- package/dist/locator.js +34 -44
- package/dist/logger.js +33 -0
- package/dist/shell.js +57 -0
- package/package.json +1 -1
- package/src/db.ts +84 -38
- package/src/index.ts +181 -79
- package/src/locator.ts +88 -71
- package/src/logger.ts +37 -0
- package/src/shell.ts +81 -0
package/src/locator.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { execSync } from "child_process";
|
|
2
1
|
import fs from "fs";
|
|
3
2
|
import path from "path";
|
|
4
3
|
import os from "os";
|
|
4
|
+
import { shell } from "./shell.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
5
6
|
|
|
6
7
|
export interface DatabaseLocation {
|
|
7
8
|
platform: 'ios' | 'android';
|
|
@@ -14,13 +15,19 @@ export async function listDatabases(bundleId?: string, targetPlatform?: 'ios' |
|
|
|
14
15
|
|
|
15
16
|
if (!targetPlatform || targetPlatform === 'ios') {
|
|
16
17
|
try {
|
|
17
|
-
const udidStr =
|
|
18
|
+
const udidStr = await shell(
|
|
19
|
+
"xcrun simctl list devices booted | awk -F '[()]' '/Booted/{print $2; exit}'",
|
|
20
|
+
{ timeout: 5_000, label: "xcrun-simctl-booted" }
|
|
21
|
+
);
|
|
22
|
+
|
|
18
23
|
if (udidStr) {
|
|
19
24
|
const appDataDir = `${process.env.HOME}/Library/Developer/CoreSimulator/Devices/${udidStr}/data/Containers/Data/Application`;
|
|
20
25
|
if (fs.existsSync(appDataDir)) {
|
|
21
26
|
try {
|
|
22
|
-
const
|
|
23
|
-
|
|
27
|
+
const found = await shell(
|
|
28
|
+
`find "${appDataDir}" -type f \\( -name "*.db" -o -name "*.sqlite" -o -name "*.sqlite3" \\) -maxdepth 7 -print`,
|
|
29
|
+
{ timeout: 15_000, label: "ios-find-dbs" }
|
|
30
|
+
);
|
|
24
31
|
if (found) {
|
|
25
32
|
results.push({
|
|
26
33
|
platform: 'ios',
|
|
@@ -29,7 +36,7 @@ export async function listDatabases(bundleId?: string, targetPlatform?: 'ios' |
|
|
|
29
36
|
});
|
|
30
37
|
}
|
|
31
38
|
} catch (e) {
|
|
32
|
-
|
|
39
|
+
logger.warn("iOS find failed", { error: String(e) });
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
} else if (targetPlatform === 'ios') {
|
|
@@ -44,7 +51,7 @@ export async function listDatabases(bundleId?: string, targetPlatform?: 'ios' |
|
|
|
44
51
|
|
|
45
52
|
if (!targetPlatform || targetPlatform === 'android') {
|
|
46
53
|
try {
|
|
47
|
-
|
|
54
|
+
await shell("adb get-state", { timeout: 5_000, label: "adb-get-state" });
|
|
48
55
|
} catch (e) {
|
|
49
56
|
if (targetPlatform === 'android') {
|
|
50
57
|
throw new Error("No booted Android Emulator found or adb is unresponsive.");
|
|
@@ -52,7 +59,7 @@ export async function listDatabases(bundleId?: string, targetPlatform?: 'ios' |
|
|
|
52
59
|
if (results.length === 0) {
|
|
53
60
|
throw new Error("No booted iOS Simulator or Android Emulator device found.");
|
|
54
61
|
}
|
|
55
|
-
return results;
|
|
62
|
+
return results;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
// if we have a specific bundleId-use it otherwise hunt
|
|
@@ -61,7 +68,10 @@ export async function listDatabases(bundleId?: string, targetPlatform?: 'ios' |
|
|
|
61
68
|
packagesToScan = [bundleId];
|
|
62
69
|
} else {
|
|
63
70
|
try {
|
|
64
|
-
const packagesStr =
|
|
71
|
+
const packagesStr = await shell(
|
|
72
|
+
"adb shell pm list packages -3",
|
|
73
|
+
{ timeout: 8_000, label: "adb-list-packages", retries: 1, retryDelay: 1_000 }
|
|
74
|
+
);
|
|
65
75
|
packagesToScan = packagesStr.split('\n')
|
|
66
76
|
.map(line => line.replace('package:', '').trim())
|
|
67
77
|
.filter(Boolean);
|
|
@@ -78,76 +88,76 @@ export async function listDatabases(bundleId?: string, targetPlatform?: 'ios' |
|
|
|
78
88
|
|
|
79
89
|
for (const pkg of packagesToScan) {
|
|
80
90
|
const baseDirs = [`/data/user/0/${pkg}`, `/data/data/${pkg}`];
|
|
81
|
-
|
|
82
91
|
for (const baseDir of baseDirs) {
|
|
83
92
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
await shell(
|
|
94
|
+
`adb shell run-as ${pkg} ls -d ${baseDir}`,
|
|
95
|
+
{ timeout: 3_000, label: `adb-ls-${pkg}` }
|
|
96
|
+
);
|
|
97
|
+
|
|
86
98
|
let foundFiles: string[] = [];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
|
|
100
|
+
// find .db / .sqlite / .sqlite3 files recursively
|
|
101
|
+
const findOut = await shell(
|
|
102
|
+
`adb shell "run-as ${pkg} find ${baseDir} -type f \\( -name \\"*.db\\" -o -name \\"*.sqlite\\" -o -name \\"*.sqlite3\\" \\)"`,
|
|
103
|
+
{ timeout: 8_000, ignoreErrors: true, label: `adb-find-${pkg}` }
|
|
104
|
+
);
|
|
105
|
+
if (findOut) {
|
|
106
|
+
foundFiles.push(...findOut.split('\n').map(l => l.trim().replace(/\r/g, '')).filter(Boolean));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// also check for extensionless files in /databases
|
|
110
|
+
const lsOut = await shell(
|
|
111
|
+
`adb shell run-as ${pkg} ls -1p ${baseDir}/databases`,
|
|
112
|
+
{ timeout: 3_000, ignoreErrors: true, label: `adb-ls-dbs-${pkg}` }
|
|
113
|
+
);
|
|
114
|
+
if (lsOut) {
|
|
98
115
|
const lsFiles = lsOut.split('\n')
|
|
99
116
|
.map(l => l.trim().replace(/\r/g, ''))
|
|
100
117
|
.filter(Boolean)
|
|
101
118
|
.filter(f => !f.endsWith('/'))
|
|
102
119
|
.filter(f => !f.endsWith('-journal') && !f.endsWith('-wal') && !f.endsWith('-shm'))
|
|
103
|
-
.filter(f => !f.includes('.'))
|
|
120
|
+
.filter(f => !f.includes('.'))
|
|
104
121
|
.map(f => `${baseDir}/databases/${f}`);
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
foundFiles.push(...lsFiles);
|
|
123
|
+
}
|
|
107
124
|
|
|
108
125
|
// Deduplicate
|
|
109
126
|
foundFiles = [...new Set(foundFiles)];
|
|
110
127
|
|
|
111
128
|
if (foundFiles.length > 0) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
break;
|
|
129
|
+
const displayFiles = foundFiles.map(f => f.replace(`${baseDir}/`, ''));
|
|
130
|
+
allAndroidDatabases.push(...displayFiles);
|
|
131
|
+
lastSuccessfulAppDir = `${baseDir}::${pkg}`;
|
|
132
|
+
break;
|
|
117
133
|
}
|
|
118
134
|
} catch (e) {
|
|
119
|
-
|
|
135
|
+
logger.debug(`Failed to list databases for app: ${pkg}`);
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
if (allAndroidDatabases.length > 0) {
|
|
125
|
-
|
|
141
|
+
results.push({
|
|
126
142
|
platform: 'android',
|
|
127
143
|
appDir: lastSuccessfulAppDir,
|
|
128
144
|
databases: allAndroidDatabases
|
|
129
145
|
});
|
|
130
146
|
} else if (targetPlatform === 'android') {
|
|
131
|
-
|
|
147
|
+
throw new Error(`Android Emulator is booted, but no SQLite databases were found in any debuggable third-party packages.`);
|
|
132
148
|
}
|
|
133
149
|
}
|
|
134
|
-
|
|
150
|
+
|
|
135
151
|
return results;
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
|
|
139
|
-
* Finds the DB file based on platform and a provided/detected filename.
|
|
140
|
-
* If dbNameGlob is empty/undefined, it will auto-select the first discovered database.
|
|
141
|
-
* Prioritizes iOS (simctl), falls back to Android (adb).
|
|
142
|
-
* Returns the local file path (either the iOS original or a pulled Android copy).
|
|
143
|
-
*/
|
|
154
|
+
|
|
144
155
|
export async function syncDatabase(dbNameGlob?: string, bundleId?: string, targetPlatform?: 'ios' | 'android'): Promise<{ localPath: string, dbName: string, platform: 'ios' | 'android' }[]> {
|
|
145
|
-
// First, discover available databases
|
|
146
156
|
const locations = await listDatabases(bundleId, targetPlatform);
|
|
147
157
|
|
|
148
158
|
if (locations.length === 0) {
|
|
149
159
|
if (targetPlatform) {
|
|
150
|
-
|
|
160
|
+
throw new Error(`No SQLite databases found for platform '${targetPlatform}'.`);
|
|
151
161
|
}
|
|
152
162
|
throw new Error(`No SQLite databases found on any platform.`);
|
|
153
163
|
}
|
|
@@ -156,16 +166,14 @@ export async function syncDatabase(dbNameGlob?: string, bundleId?: string, targe
|
|
|
156
166
|
|
|
157
167
|
for (const loc of locations) {
|
|
158
168
|
if (targetPlatform && loc.platform !== targetPlatform) continue;
|
|
159
|
-
|
|
160
169
|
let targetDbNames: string[] = [];
|
|
161
170
|
|
|
162
171
|
if (!dbNameGlob) {
|
|
163
|
-
// Auto-select: find the first .db or .sqlite file in this location
|
|
164
172
|
const preferred = loc.databases.find(d => d.endsWith('.db') || d.endsWith('.sqlite'));
|
|
165
173
|
if (preferred) {
|
|
166
174
|
targetDbNames.push(preferred);
|
|
167
175
|
} else if (loc.databases.length > 0) {
|
|
168
|
-
targetDbNames.push(loc.databases[0]);
|
|
176
|
+
targetDbNames.push(loc.databases[0]);
|
|
169
177
|
}
|
|
170
178
|
} else {
|
|
171
179
|
const globRegex = new RegExp('^' + dbNameGlob.replace(/\*/g, '.*') + '$');
|
|
@@ -178,34 +186,34 @@ export async function syncDatabase(dbNameGlob?: string, bundleId?: string, targe
|
|
|
178
186
|
// --- iOS Logic ---
|
|
179
187
|
if (platform === 'ios') {
|
|
180
188
|
if (!appDir) continue;
|
|
181
|
-
|
|
182
|
-
// Find the exact path of the targetDbName
|
|
183
|
-
const findCmd = `find "${appDir}" -type f -name "${targetDbName}" -maxdepth 7 -print | head -n 1`;
|
|
189
|
+
|
|
184
190
|
try {
|
|
185
|
-
const found =
|
|
191
|
+
const found = await shell(
|
|
192
|
+
`find "${appDir}" -type f -name "${targetDbName}" -maxdepth 7 -print | head -n 1`,
|
|
193
|
+
{ timeout: 8_000, label: `ios-find-${targetDbName}` }
|
|
194
|
+
);
|
|
186
195
|
if (found && fs.existsSync(found)) {
|
|
187
|
-
|
|
196
|
+
logger.info(`Located iOS DB at: ${found}`);
|
|
188
197
|
synced.push({ localPath: found, dbName: targetDbName, platform: 'ios' });
|
|
189
198
|
}
|
|
190
199
|
} catch (e) {
|
|
191
|
-
|
|
200
|
+
logger.warn(`Failed to locate full path for iOS DB: ${targetDbName}`);
|
|
192
201
|
}
|
|
193
202
|
continue;
|
|
194
203
|
}
|
|
195
204
|
|
|
196
205
|
// --- Android Logic ---
|
|
197
206
|
if (!appDir || !appDir.includes("::")) {
|
|
198
|
-
|
|
207
|
+
logger.warn(`Invalid Android appDir format: ${appDir}`);
|
|
199
208
|
continue;
|
|
200
209
|
}
|
|
201
|
-
|
|
210
|
+
|
|
202
211
|
const [targetDbDir, targetPkg] = appDir.split("::");
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
212
|
+
|
|
213
|
+
await shell(
|
|
214
|
+
`adb shell am force-stop ${targetPkg}`,
|
|
215
|
+
{ timeout: 5_000, ignoreErrors: true, label: `adb-force-stop-${targetPkg}` }
|
|
216
|
+
);
|
|
209
217
|
|
|
210
218
|
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "rn-sqlite-mcp-"));
|
|
211
219
|
const safeLocalName = targetDbName.replace(/\//g, '_');
|
|
@@ -217,16 +225,24 @@ export async function syncDatabase(dbNameGlob?: string, bundleId?: string, targe
|
|
|
217
225
|
const remoteWal = `${remoteMain}-wal`;
|
|
218
226
|
const remoteShm = `${remoteMain}-shm`;
|
|
219
227
|
|
|
220
|
-
|
|
221
|
-
const pullOne = (remote: string, local: string) => {
|
|
228
|
+
const pullOne = async (remote: string, local: string): Promise<boolean> => {
|
|
222
229
|
try {
|
|
223
230
|
const remoteBase = path.basename(remote);
|
|
224
231
|
const tmpRemote = `/data/local/tmp/${targetPkg}_${remoteBase}_${Date.now()}`;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
232
|
+
|
|
233
|
+
await shell(
|
|
234
|
+
`adb shell "run-as '${targetPkg}' cat '${remote}' > '${tmpRemote}'"`,
|
|
235
|
+
{ timeout: 10_000, retries: 1, retryDelay: 1_000, label: `adb-cat-${remoteBase}` }
|
|
236
|
+
);
|
|
237
|
+
await shell(
|
|
238
|
+
`adb pull '${tmpRemote}' '${local}'`,
|
|
239
|
+
{ timeout: 10_000, label: `adb-pull-${remoteBase}` }
|
|
240
|
+
);
|
|
241
|
+
await shell(
|
|
242
|
+
`adb shell rm '${tmpRemote}'`,
|
|
243
|
+
{ timeout: 5_000, ignoreErrors: true, label: `adb-rm-tmp-${remoteBase}` }
|
|
244
|
+
);
|
|
245
|
+
|
|
230
246
|
return fs.existsSync(local) && fs.statSync(local).size > 0;
|
|
231
247
|
} catch (e) {
|
|
232
248
|
if (fs.existsSync(local)) fs.unlinkSync(local);
|
|
@@ -234,15 +250,16 @@ export async function syncDatabase(dbNameGlob?: string, bundleId?: string, targe
|
|
|
234
250
|
}
|
|
235
251
|
};
|
|
236
252
|
|
|
237
|
-
if (!pullOne(remoteMain, localDb)) {
|
|
238
|
-
|
|
253
|
+
if (!(await pullOne(remoteMain, localDb))) {
|
|
254
|
+
logger.warn(`Failed to pull main DB file from Android: ${remoteMain}`);
|
|
239
255
|
continue;
|
|
240
256
|
}
|
|
241
257
|
|
|
242
|
-
|
|
243
|
-
pullOne(
|
|
258
|
+
// WAL and SHM are best-effort
|
|
259
|
+
await pullOne(remoteWal, localWal);
|
|
260
|
+
await pullOne(remoteShm, localShm);
|
|
244
261
|
|
|
245
|
-
|
|
262
|
+
logger.info(`Pulled Android DB to local temp: ${localDb}`);
|
|
246
263
|
synced.push({ localPath: localDb, dbName: targetDbName, platform: 'android' });
|
|
247
264
|
}
|
|
248
265
|
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
4
|
+
debug: 0,
|
|
5
|
+
info: 1,
|
|
6
|
+
warn: 2,
|
|
7
|
+
error: 3,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const minLevel: LogLevel = (process.env.MCP_LOG_LEVEL as LogLevel) || 'info';
|
|
11
|
+
|
|
12
|
+
function shouldLog(level: LogLevel): boolean {
|
|
13
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatTimestamp(): string {
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function log(level: LogLevel, message: string, meta?: Record<string, unknown>): void {
|
|
21
|
+
if (!shouldLog(level)) return;
|
|
22
|
+
|
|
23
|
+
const parts = [`[${formatTimestamp()}]`, `[${level.toUpperCase()}]`, message];
|
|
24
|
+
if (meta && Object.keys(meta).length > 0) {
|
|
25
|
+
parts.push(JSON.stringify(meta));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// CRITICAL: Always stderr — stdout is reserved for MCP JSON-RPC
|
|
29
|
+
process.stderr.write(parts.join(' ') + '\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const logger = {
|
|
33
|
+
debug: (msg: string, meta?: Record<string, unknown>) => log('debug', msg, meta),
|
|
34
|
+
info: (msg: string, meta?: Record<string, unknown>) => log('info', msg, meta),
|
|
35
|
+
warn: (msg: string, meta?: Record<string, unknown>) => log('warn', msg, meta),
|
|
36
|
+
error: (msg: string, meta?: Record<string, unknown>) => log('error', msg, meta),
|
|
37
|
+
};
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { exec as execCb } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(execCb);
|
|
6
|
+
|
|
7
|
+
export interface ShellOptions {
|
|
8
|
+
timeout?: number;
|
|
9
|
+
retries?: number;
|
|
10
|
+
retryDelay?: number;
|
|
11
|
+
ignoreErrors?: boolean;
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ShellResult {
|
|
16
|
+
stdout: string;
|
|
17
|
+
stderr: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sleep(ms: number): Promise<void> {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function shell(
|
|
25
|
+
command: string,
|
|
26
|
+
options: ShellOptions = {}
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const {
|
|
29
|
+
timeout = 10_000,
|
|
30
|
+
retries = 0,
|
|
31
|
+
retryDelay = 1_000,
|
|
32
|
+
ignoreErrors = false,
|
|
33
|
+
label,
|
|
34
|
+
} = options;
|
|
35
|
+
|
|
36
|
+
const tag = label || command.slice(0, 60);
|
|
37
|
+
|
|
38
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
if (attempt > 0) {
|
|
41
|
+
const delay = retryDelay * Math.pow(2, attempt - 1);
|
|
42
|
+
logger.debug(`Retry ${attempt}/${retries} for "${tag}" after ${delay}ms`);
|
|
43
|
+
await sleep(delay);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.debug(`Executing: "${tag}"`, { timeout, attempt });
|
|
47
|
+
|
|
48
|
+
const result = await execAsync(command, {
|
|
49
|
+
timeout,
|
|
50
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB — large schema dumps
|
|
51
|
+
env: { ...process.env },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return result.stdout.trim();
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
const isLastAttempt = attempt >= retries;
|
|
57
|
+
|
|
58
|
+
if (error.killed) {
|
|
59
|
+
logger.warn(`Command timed out after ${timeout}ms: "${tag}"`);
|
|
60
|
+
} else {
|
|
61
|
+
logger.debug(`Command failed: "${tag}"`, {
|
|
62
|
+
code: error.code,
|
|
63
|
+
stderr: error.stderr?.slice(0, 200),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isLastAttempt) {
|
|
68
|
+
if (ignoreErrors) {
|
|
69
|
+
logger.debug(`Ignoring error for "${tag}"`);
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Shell command failed: ${tag}\n${error.message || error.stderr || "Unknown error"}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Unreachable, but TypeScript needs it
|
|
80
|
+
return "";
|
|
81
|
+
}
|