codex-control-chrome-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/bin/codex-control-chrome-mcp.js +8 -0
- package/docs/architecture.md +222 -0
- package/docs/install.md +119 -0
- package/docs/socket-probe.md +67 -0
- package/package.json +50 -0
- package/skills/codex-control-chrome-mcp/SKILL.md +145 -0
- package/skills/codex-control-chrome-mcp/agents/openai.yaml +10 -0
- package/src/bridge-client.js +59 -0
- package/src/cli.js +89 -0
- package/src/constants.js +47 -0
- package/src/framing.js +75 -0
- package/src/installer.js +161 -0
- package/src/mcp-server.js +299 -0
- package/src/native-host.js +235 -0
- package/src/probe.js +39 -0
- package/src/rpc-client.js +77 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { BRIDGE_SOCKET_DIR, BRIDGE_SOCKET_PREFIX } from "./constants.js";
|
|
5
|
+
import { LengthPrefixedJsonPeer } from "./framing.js";
|
|
6
|
+
import { RpcClient } from "./rpc-client.js";
|
|
7
|
+
|
|
8
|
+
export async function listBridgeSockets() {
|
|
9
|
+
let names = [];
|
|
10
|
+
try {
|
|
11
|
+
names = await fs.readdir(BRIDGE_SOCKET_DIR);
|
|
12
|
+
} catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return names
|
|
16
|
+
.filter((name) => name.startsWith(`${BRIDGE_SOCKET_PREFIX}-`) && name.endsWith(".sock"))
|
|
17
|
+
.map((name) => path.join(BRIDGE_SOCKET_DIR, name))
|
|
18
|
+
.sort((a, b) => a.localeCompare(b));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function connectBridge({ socketPath, timeoutMs = 5000 } = {}) {
|
|
22
|
+
const sockets = socketPath ? [socketPath] : await listBridgeSockets();
|
|
23
|
+
const failures = [];
|
|
24
|
+
for (const candidate of sockets) {
|
|
25
|
+
try {
|
|
26
|
+
const socket = await connectUnixSocket(candidate, timeoutMs);
|
|
27
|
+
const peer = new LengthPrefixedJsonPeer({
|
|
28
|
+
input: socket,
|
|
29
|
+
output: socket,
|
|
30
|
+
name: `bridge:${candidate}`,
|
|
31
|
+
});
|
|
32
|
+
const rpc = new RpcClient(peer, { idPrefix: `mcp-${process.pid}` });
|
|
33
|
+
return { socketPath: candidate, peer, rpc };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error(
|
|
39
|
+
`No Codex Control Chrome MCP bridge socket is available. Tried ${sockets.length} socket(s). ${failures.join("; ")}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function connectUnixSocket(socketPath, timeoutMs) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const socket = net.createConnection(socketPath);
|
|
46
|
+
const timeout = setTimeout(() => {
|
|
47
|
+
socket.destroy();
|
|
48
|
+
reject(new Error(`connect timeout after ${timeoutMs}ms`));
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
socket.once("connect", () => {
|
|
51
|
+
clearTimeout(timeout);
|
|
52
|
+
resolve(socket);
|
|
53
|
+
});
|
|
54
|
+
socket.once("error", (error) => {
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
reject(error);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { runMcpServer } from "./mcp-server.js";
|
|
2
|
+
import { runNativeHost } from "./native-host.js";
|
|
3
|
+
import { installNativeHost, nativeHostInstallStatus, uninstallNativeHost } from "./installer.js";
|
|
4
|
+
import { listBridgeSockets } from "./bridge-client.js";
|
|
5
|
+
import { probeLengthPrefixedSocket } from "./probe.js";
|
|
6
|
+
|
|
7
|
+
export async function runCli(argv) {
|
|
8
|
+
const args = argv.slice(2);
|
|
9
|
+
const command = args[0];
|
|
10
|
+
|
|
11
|
+
if (args.includes("--stdio") || command === "stdio") {
|
|
12
|
+
await runMcpServer();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (args.includes("--native-host") || looksLikeChromeOrigin(command)) {
|
|
17
|
+
await runNativeHost({ origin: looksLikeChromeOrigin(command) ? command : argv[3] });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
switch (command) {
|
|
22
|
+
case "install-native-host": {
|
|
23
|
+
const binPath = readOption(args, "--bin");
|
|
24
|
+
const noProxy = args.includes("--no-proxy");
|
|
25
|
+
const result = await installNativeHost({ binPath, proxy: !noProxy });
|
|
26
|
+
printJson(result);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
case "uninstall-native-host": {
|
|
30
|
+
const result = await uninstallNativeHost({ force: args.includes("--force") });
|
|
31
|
+
printJson(result);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
case "status": {
|
|
35
|
+
printJson({
|
|
36
|
+
install: await nativeHostInstallStatus(),
|
|
37
|
+
sockets: await listBridgeSockets(),
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
case "probe-socket": {
|
|
42
|
+
const socketPath = args[1];
|
|
43
|
+
if (!socketPath) {
|
|
44
|
+
throw new Error("Usage: codex-control-chrome-mcp probe-socket <socket-path>");
|
|
45
|
+
}
|
|
46
|
+
printJson(await probeLengthPrefixedSocket(socketPath));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
case "help":
|
|
50
|
+
case "--help":
|
|
51
|
+
case "-h":
|
|
52
|
+
case undefined:
|
|
53
|
+
printHelp();
|
|
54
|
+
return;
|
|
55
|
+
default:
|
|
56
|
+
throw new Error(`Unknown command: ${command}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function looksLikeChromeOrigin(value) {
|
|
61
|
+
return typeof value === "string" && value.startsWith("chrome-extension://");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readOption(args, name) {
|
|
65
|
+
const index = args.indexOf(name);
|
|
66
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printJson(value) {
|
|
70
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printHelp() {
|
|
74
|
+
process.stdout.write(`codex-control-chrome-mcp
|
|
75
|
+
|
|
76
|
+
Usage:
|
|
77
|
+
codex-control-chrome-mcp --stdio
|
|
78
|
+
codex-control-chrome-mcp --native-host
|
|
79
|
+
codex-control-chrome-mcp install-native-host [--bin /path/to/codex-control-chrome-mcp] [--no-proxy]
|
|
80
|
+
codex-control-chrome-mcp uninstall-native-host [--force]
|
|
81
|
+
codex-control-chrome-mcp status
|
|
82
|
+
codex-control-chrome-mcp probe-socket <socket-path>
|
|
83
|
+
|
|
84
|
+
Notes:
|
|
85
|
+
--stdio is the MCP server mode for Agent tools.
|
|
86
|
+
--native-host is normally launched by Chrome through Native Messaging.
|
|
87
|
+
install-native-host without --bin writes a stable launcher that runs npx.
|
|
88
|
+
`);
|
|
89
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const CODEX_EXTENSION_ID = "hehggadaopoacecdllhhajmbjkdcmajg";
|
|
5
|
+
export const CODEX_NATIVE_HOST_NAME = "com.openai.codexextension";
|
|
6
|
+
export const BRIDGE_SOCKET_DIR =
|
|
7
|
+
process.platform === "win32" ? "\\\\.\\pipe\\codex-browser-use" : "/tmp/codex-browser-use";
|
|
8
|
+
export const BRIDGE_SOCKET_PREFIX = "codex-control-chrome-mcp";
|
|
9
|
+
export const APP_STATE_DIR = path.join(os.homedir(), ".codex-control-chrome-mcp");
|
|
10
|
+
export const APP_CONFIG_PATH = path.join(APP_STATE_DIR, "config.json");
|
|
11
|
+
export const INSTALL_STATE_PATH = path.join(APP_STATE_DIR, "install-state.json");
|
|
12
|
+
|
|
13
|
+
export function nativeHostManifestPath() {
|
|
14
|
+
const home = os.homedir();
|
|
15
|
+
switch (process.platform) {
|
|
16
|
+
case "darwin":
|
|
17
|
+
return path.join(
|
|
18
|
+
home,
|
|
19
|
+
"Library",
|
|
20
|
+
"Application Support",
|
|
21
|
+
"Google",
|
|
22
|
+
"Chrome",
|
|
23
|
+
"NativeMessagingHosts",
|
|
24
|
+
`${CODEX_NATIVE_HOST_NAME}.json`,
|
|
25
|
+
);
|
|
26
|
+
case "linux":
|
|
27
|
+
return path.join(
|
|
28
|
+
home,
|
|
29
|
+
".config",
|
|
30
|
+
"google-chrome",
|
|
31
|
+
"NativeMessagingHosts",
|
|
32
|
+
`${CODEX_NATIVE_HOST_NAME}.json`,
|
|
33
|
+
);
|
|
34
|
+
case "win32":
|
|
35
|
+
return path.join(
|
|
36
|
+
home,
|
|
37
|
+
"AppData",
|
|
38
|
+
"Local",
|
|
39
|
+
"Google",
|
|
40
|
+
"Chrome",
|
|
41
|
+
"NativeMessagingHosts",
|
|
42
|
+
`${CODEX_NATIVE_HOST_NAME}.json`,
|
|
43
|
+
);
|
|
44
|
+
default:
|
|
45
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/framing.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
const LENGTH_BYTES = 4;
|
|
5
|
+
const IS_LE = os.endianness() === "LE";
|
|
6
|
+
|
|
7
|
+
export function encodeLengthPrefixedJson(message) {
|
|
8
|
+
const payload = Buffer.from(JSON.stringify(message), "utf8");
|
|
9
|
+
const header = Buffer.alloc(LENGTH_BYTES);
|
|
10
|
+
if (IS_LE) {
|
|
11
|
+
header.writeUInt32LE(payload.length, 0);
|
|
12
|
+
} else {
|
|
13
|
+
header.writeUInt32BE(payload.length, 0);
|
|
14
|
+
}
|
|
15
|
+
return Buffer.concat([header, payload]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readFrameLength(buffer) {
|
|
19
|
+
return IS_LE ? buffer.readUInt32LE(0) : buffer.readUInt32BE(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class LengthPrefixedJsonPeer extends EventEmitter {
|
|
23
|
+
constructor({ input, output, name = "peer" }) {
|
|
24
|
+
super();
|
|
25
|
+
this.input = input;
|
|
26
|
+
this.output = output;
|
|
27
|
+
this.name = name;
|
|
28
|
+
this.buffer = Buffer.alloc(0);
|
|
29
|
+
this.closed = false;
|
|
30
|
+
|
|
31
|
+
this.input.on("data", (chunk) => this.#handleData(chunk));
|
|
32
|
+
this.input.on("end", () => this.#markClosed());
|
|
33
|
+
this.input.on("close", () => this.#markClosed());
|
|
34
|
+
this.input.on("error", (error) => this.#markClosed(error));
|
|
35
|
+
this.output.on?.("error", (error) => this.#markClosed(error));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
send(message) {
|
|
39
|
+
if (this.closed) {
|
|
40
|
+
throw new Error(`${this.name} is closed`);
|
|
41
|
+
}
|
|
42
|
+
this.output.write(encodeLengthPrefixedJson(message));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
close() {
|
|
46
|
+
this.#markClosed();
|
|
47
|
+
this.output.end?.();
|
|
48
|
+
this.input.destroy?.();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#handleData(chunk) {
|
|
52
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
53
|
+
while (this.buffer.length >= LENGTH_BYTES) {
|
|
54
|
+
const length = readFrameLength(this.buffer);
|
|
55
|
+
if (this.buffer.length < LENGTH_BYTES + length) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const payload = this.buffer.subarray(LENGTH_BYTES, LENGTH_BYTES + length);
|
|
59
|
+
this.buffer = this.buffer.subarray(LENGTH_BYTES + length);
|
|
60
|
+
try {
|
|
61
|
+
this.emit("message", JSON.parse(payload.toString("utf8")));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this.emit("error", error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#markClosed(error) {
|
|
69
|
+
if (this.closed) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.closed = true;
|
|
73
|
+
this.emit("close", error);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
APP_CONFIG_PATH,
|
|
5
|
+
APP_STATE_DIR,
|
|
6
|
+
CODEX_EXTENSION_ID,
|
|
7
|
+
CODEX_NATIVE_HOST_NAME,
|
|
8
|
+
INSTALL_STATE_PATH,
|
|
9
|
+
nativeHostManifestPath,
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
|
|
12
|
+
const PACKAGE_NAME = "codex-control-chrome-mcp";
|
|
13
|
+
const PACKAGE_VERSION = "1.0.0";
|
|
14
|
+
const MANIFEST_DESCRIPTION = "Codex Control Chrome MCP native messaging host";
|
|
15
|
+
|
|
16
|
+
export async function installNativeHost({ binPath, proxy = true } = {}) {
|
|
17
|
+
const manifestPath = nativeHostManifestPath();
|
|
18
|
+
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
19
|
+
await fs.mkdir(APP_STATE_DIR, { recursive: true });
|
|
20
|
+
|
|
21
|
+
const hostPath = binPath ? await createLocalLauncher(path.resolve(binPath)) : await createNpxLauncher();
|
|
22
|
+
const existing = await readJsonIfExists(manifestPath);
|
|
23
|
+
const previousState = await readJsonIfExists(INSTALL_STATE_PATH);
|
|
24
|
+
const previousBackup = previousState?.backupPath
|
|
25
|
+
? await readJsonIfExists(previousState.backupPath)
|
|
26
|
+
: null;
|
|
27
|
+
const existingIsOurs = isProjectManifest(existing);
|
|
28
|
+
const previousBackupIsOurs = isProjectManifest(previousBackup);
|
|
29
|
+
let backupPath = null;
|
|
30
|
+
if (existing && !existingIsOurs) {
|
|
31
|
+
backupPath = await backupManifest(manifestPath);
|
|
32
|
+
} else if (previousBackup && !previousBackupIsOurs) {
|
|
33
|
+
backupPath = previousState.backupPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let officialHostPath = null;
|
|
37
|
+
if (proxy && previousBackup?.path && !previousBackupIsOurs) {
|
|
38
|
+
officialHostPath = previousBackup.path;
|
|
39
|
+
} else if (
|
|
40
|
+
proxy &&
|
|
41
|
+
typeof previousState?.officialHostPath === "string" &&
|
|
42
|
+
path.resolve(previousState.officialHostPath) !== path.resolve(hostPath)
|
|
43
|
+
) {
|
|
44
|
+
officialHostPath = previousState.officialHostPath;
|
|
45
|
+
} else if (
|
|
46
|
+
proxy &&
|
|
47
|
+
existing?.path &&
|
|
48
|
+
!existingIsOurs &&
|
|
49
|
+
path.resolve(existing.path) !== path.resolve(hostPath)
|
|
50
|
+
) {
|
|
51
|
+
officialHostPath = existing.path;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const manifest = {
|
|
55
|
+
allowed_origins: [`chrome-extension://${CODEX_EXTENSION_ID}/`],
|
|
56
|
+
description: MANIFEST_DESCRIPTION,
|
|
57
|
+
name: CODEX_NATIVE_HOST_NAME,
|
|
58
|
+
path: hostPath,
|
|
59
|
+
type: "stdio",
|
|
60
|
+
};
|
|
61
|
+
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
62
|
+
|
|
63
|
+
const config = {
|
|
64
|
+
installedAt: new Date().toISOString(),
|
|
65
|
+
manifestPath,
|
|
66
|
+
hostPath,
|
|
67
|
+
launchMode: binPath ? "direct-bin" : "npx-launcher",
|
|
68
|
+
backupPath,
|
|
69
|
+
officialHostPath,
|
|
70
|
+
proxy,
|
|
71
|
+
};
|
|
72
|
+
await fs.writeFile(APP_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
73
|
+
await fs.writeFile(INSTALL_STATE_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
74
|
+
return config;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function uninstallNativeHost({ force = false } = {}) {
|
|
78
|
+
const manifestPath = nativeHostManifestPath();
|
|
79
|
+
const state = await readJsonIfExists(INSTALL_STATE_PATH);
|
|
80
|
+
const manifest = await readJsonIfExists(manifestPath);
|
|
81
|
+
|
|
82
|
+
if (!manifest && !state?.backupPath) {
|
|
83
|
+
return { ok: true, action: "noop", manifestPath };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!force && !isProjectManifest(manifest)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Refusing to uninstall because ${manifestPath} does not look like a codex-control-chrome-mcp manifest. Use --force to override.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (state?.backupPath) {
|
|
93
|
+
await fs.copyFile(state.backupPath, manifestPath);
|
|
94
|
+
return { ok: true, action: "restored", manifestPath, backupPath: state.backupPath };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await fs.rm(manifestPath, { force: true });
|
|
98
|
+
return { ok: true, action: "removed", manifestPath };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function nativeHostInstallStatus() {
|
|
102
|
+
const manifestPath = nativeHostManifestPath();
|
|
103
|
+
const manifest = await readJsonIfExists(manifestPath);
|
|
104
|
+
const state = await readJsonIfExists(INSTALL_STATE_PATH);
|
|
105
|
+
return { manifestPath, manifest, state };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function backupManifest(manifestPath) {
|
|
109
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
110
|
+
const backupPath = path.join(
|
|
111
|
+
path.dirname(manifestPath),
|
|
112
|
+
`${path.basename(manifestPath)}.backup-${stamp}`,
|
|
113
|
+
);
|
|
114
|
+
await fs.copyFile(manifestPath, backupPath);
|
|
115
|
+
return backupPath;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function createNpxLauncher() {
|
|
119
|
+
if (process.platform === "win32") {
|
|
120
|
+
throw new Error("NPX native host launcher installation is not implemented for Windows yet.");
|
|
121
|
+
}
|
|
122
|
+
const launcherPath = path.join(APP_STATE_DIR, "native-host-launcher");
|
|
123
|
+
const script = `#!/usr/bin/env sh
|
|
124
|
+
exec npx -y ${PACKAGE_NAME}@${PACKAGE_VERSION} --native-host "$@"
|
|
125
|
+
`;
|
|
126
|
+
await fs.writeFile(launcherPath, script, { encoding: "utf8", mode: 0o755 });
|
|
127
|
+
await fs.chmod(launcherPath, 0o755);
|
|
128
|
+
return launcherPath;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isProjectManifest(manifest) {
|
|
132
|
+
return manifest?.description === MANIFEST_DESCRIPTION;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function createLocalLauncher(binPath) {
|
|
136
|
+
if (process.platform === "win32") {
|
|
137
|
+
return binPath;
|
|
138
|
+
}
|
|
139
|
+
if (!binPath.endsWith(".js")) {
|
|
140
|
+
return binPath;
|
|
141
|
+
}
|
|
142
|
+
const launcherPath = path.join(APP_STATE_DIR, "native-host-local-launcher");
|
|
143
|
+
const script = `#!/usr/bin/env sh
|
|
144
|
+
exec ${shellQuote(process.execPath)} ${shellQuote(binPath)} "$@"
|
|
145
|
+
`;
|
|
146
|
+
await fs.writeFile(launcherPath, script, { encoding: "utf8", mode: 0o755 });
|
|
147
|
+
await fs.chmod(launcherPath, 0o755);
|
|
148
|
+
return launcherPath;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function shellQuote(value) {
|
|
152
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readJsonIfExists(filePath) {
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|