mcp admin
This commit is contained in:
@@ -1,13 +1,23 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createConnection } from "node:net";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import inquirer from "inquirer";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const composeFile = join(rootDir, "docker", "dev-services.compose.yml");
|
||||
const composeProject = "autumn-dev-services";
|
||||
const zshrcFile = join(homedir(), ".zshrc");
|
||||
const ngrokConfigFiles = [
|
||||
join(homedir(), "Library", "Application Support", "ngrok", "ngrok.yml"),
|
||||
join(homedir(), ".config", "ngrok", "ngrok.yml"),
|
||||
join(homedir(), ".ngrok2", "ngrok.yml"),
|
||||
];
|
||||
|
||||
const localConfig = {
|
||||
postgresPort: 5432,
|
||||
ngrokApiPort: 4040,
|
||||
redisStackPort: 6379,
|
||||
dragonflyPort: 6380,
|
||||
databaseUrl: "postgresql://postgres:postgres@localhost:5432/autumn",
|
||||
@@ -23,6 +33,103 @@ const log = (message: string) => console.log(`[dev:services] ${message}`);
|
||||
|
||||
const composeEnv = { ...process.env };
|
||||
|
||||
const readShellConfigEnvVar = ({ key }: { key: string }) => {
|
||||
if (!existsSync(zshrcFile)) return;
|
||||
|
||||
const match = readFileSync(zshrcFile, "utf-8").match(
|
||||
new RegExp(`^\\s*(?:export\\s+)?${key}=(.+?)\\s*$`, "m"),
|
||||
);
|
||||
return match?.[1]?.trim().replace(/^["']|["']$/g, "");
|
||||
};
|
||||
|
||||
const writeShellConfigEnvVar = ({
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
key: string;
|
||||
value: string;
|
||||
}) => {
|
||||
const current = existsSync(zshrcFile)
|
||||
? readFileSync(zshrcFile, "utf-8").split("\n")
|
||||
: [];
|
||||
let updated = false;
|
||||
const lines = current.map((line) => {
|
||||
if (new RegExp(`^\\s*(?:export\\s+)?${key}=`).test(line)) {
|
||||
updated = true;
|
||||
return `export ${key}=${value}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) lines.push(`export ${key}=${value}`);
|
||||
|
||||
writeFileSync(zshrcFile, `${lines.join("\n").replace(/\n+$/, "")}\n`);
|
||||
};
|
||||
|
||||
const readNgrokAuthtokenFromConfig = () => {
|
||||
for (const configFile of ngrokConfigFiles) {
|
||||
if (!existsSync(configFile)) continue;
|
||||
|
||||
const match = readFileSync(configFile, "utf-8").match(
|
||||
/^\s*authtoken:\s*(.+?)\s*$/m,
|
||||
);
|
||||
const token = match?.[1]?.trim().replace(/^["']|["']$/g, "");
|
||||
if (token) return token;
|
||||
}
|
||||
};
|
||||
|
||||
const getDomainFromUrl = ({ url }: { url: string }) => {
|
||||
const normalizedUrl = url.startsWith("http") ? url : `https://${url}`;
|
||||
return new URL(normalizedUrl).host;
|
||||
};
|
||||
|
||||
const configureNgrokUrl = () => {
|
||||
const ngrokUrl = composeEnv.NGROK_URL;
|
||||
if (!ngrokUrl) {
|
||||
throw new Error(
|
||||
"NGROK_URL is required for dev services. It should be injected from Infisical dev secrets.",
|
||||
);
|
||||
}
|
||||
|
||||
composeEnv.NGROK_DOMAIN = getDomainFromUrl({ url: ngrokUrl });
|
||||
};
|
||||
|
||||
const configureNgrokToken = async () => {
|
||||
if (composeEnv.NGROK_AUTHTOKEN) return;
|
||||
|
||||
const shellToken = readShellConfigEnvVar({ key: "NGROK_AUTHTOKEN" });
|
||||
if (shellToken) {
|
||||
composeEnv.NGROK_AUTHTOKEN = shellToken;
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredToken = readNgrokAuthtokenFromConfig();
|
||||
if (configuredToken) {
|
||||
composeEnv.NGROK_AUTHTOKEN = configuredToken;
|
||||
writeShellConfigEnvVar({
|
||||
key: "NGROK_AUTHTOKEN",
|
||||
value: configuredToken,
|
||||
});
|
||||
log(`saved NGROK_AUTHTOKEN from local ngrok config to ${zshrcFile}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`NGROK_AUTHTOKEN will be saved to ${zshrcFile} after first entry`);
|
||||
const { token } = await inquirer.prompt<{ token: string }>([
|
||||
{
|
||||
type: "password",
|
||||
name: "token",
|
||||
message: "NGROK_AUTHTOKEN",
|
||||
mask: "*",
|
||||
validate: (value: string) =>
|
||||
Boolean(value.trim()) || "NGROK_AUTHTOKEN is required",
|
||||
},
|
||||
]);
|
||||
|
||||
composeEnv.NGROK_AUTHTOKEN = token.trim();
|
||||
writeShellConfigEnvVar({ key: "NGROK_AUTHTOKEN", value: token.trim() });
|
||||
log(`saved NGROK_AUTHTOKEN to ${zshrcFile}`);
|
||||
};
|
||||
|
||||
const run = ({
|
||||
cmd,
|
||||
args,
|
||||
@@ -54,6 +161,8 @@ const composeArgs = ({ args }: { args: string[] }) => [
|
||||
composeProject,
|
||||
"-f",
|
||||
composeFile,
|
||||
"--profile",
|
||||
"ngrok",
|
||||
...args,
|
||||
];
|
||||
|
||||
@@ -193,18 +302,80 @@ const ensureChatDatabase = () => {
|
||||
psql({ args: ["-d", "postgres", "-c", "CREATE DATABASE chat"] });
|
||||
};
|
||||
|
||||
const ensureNgrokRunning = () => {
|
||||
const result = dockerCompose({
|
||||
args: ["ps", "--status", "running", "--services", "ngrok"],
|
||||
quiet: true,
|
||||
});
|
||||
const services = new TextDecoder().decode(result.stdout).trim().split("\n");
|
||||
if (!services.includes("ngrok")) {
|
||||
const logs = dockerCompose({
|
||||
args: ["logs", "--tail", "40", "ngrok"],
|
||||
quiet: true,
|
||||
allowFailure: true,
|
||||
});
|
||||
const stderr = new TextDecoder().decode(logs.stderr).trim();
|
||||
const stdout = new TextDecoder().decode(logs.stdout).trim();
|
||||
throw new Error(
|
||||
[
|
||||
"ngrok container is not running",
|
||||
stdout || stderr ? `${stdout}\n${stderr}`.trim() : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getNgrokUrl = async () => {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${localConfig.ngrokApiPort}/api/tunnels`,
|
||||
);
|
||||
const data = (await response.json()) as {
|
||||
tunnels?: Array<{ public_url?: string; proto?: string }>;
|
||||
};
|
||||
const tunnel = data.tunnels?.find(
|
||||
(tunnel) => tunnel.proto === "https" && tunnel.public_url,
|
||||
);
|
||||
if (tunnel?.public_url) return tunnel.public_url.replace(/\/$/, "");
|
||||
} catch {
|
||||
// ngrok's local API is not ready yet.
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error("ngrok did not expose a public URL on :4040");
|
||||
};
|
||||
|
||||
const up = async () => {
|
||||
configureNgrokUrl();
|
||||
await configureNgrokToken();
|
||||
log("starting Docker services");
|
||||
dockerCompose({ args: ["up", "-d", "--remove-orphans"] });
|
||||
dockerCompose({
|
||||
args: ["rm", "-sf", "ngrok"],
|
||||
allowFailure: true,
|
||||
});
|
||||
dockerCompose({
|
||||
args: ["up", "-d", "--remove-orphans"],
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
waitForTcp({ port: localConfig.postgresPort, label: "Postgres" }),
|
||||
waitForTcp({ port: localConfig.redisStackPort, label: "Redis Stack" }),
|
||||
waitForTcp({ port: localConfig.dragonflyPort, label: "Dragonfly" }),
|
||||
waitForTcp({ port: localConfig.ngrokApiPort, label: "ngrok" }),
|
||||
]);
|
||||
|
||||
ensureChatDatabase();
|
||||
await doctor();
|
||||
ensureNgrokRunning();
|
||||
|
||||
const ngrokUrl = await getNgrokUrl();
|
||||
log(`ngrok URL: ${ngrokUrl}`);
|
||||
log(`export NGROK_URL=${ngrokUrl}`);
|
||||
};
|
||||
|
||||
const down = () => {
|
||||
@@ -237,7 +408,7 @@ const help = () => {
|
||||
console.log(`Usage: bun dev:services <command>
|
||||
|
||||
Commands:
|
||||
up Start local Postgres, Redis Stack, and Dragonfly
|
||||
up Start local Postgres, Redis Stack, Dragonfly, and ngrok
|
||||
down Stop local services and keep all data
|
||||
down --volumes Stop services and delete Redis/Dragonfly data
|
||||
down --postgres Stop services and delete Postgres data
|
||||
@@ -248,6 +419,7 @@ Commands:
|
||||
Local service values:
|
||||
DATABASE_URL=${localConfig.databaseUrl}
|
||||
CHAT_STATE_DATABASE_URL=${localConfig.chatStateDatabaseUrl}
|
||||
NGROK_URL=<printed by bun dev:services up>
|
||||
CACHE_URL=${localConfig.cacheUrl}
|
||||
CACHE_URL_US_EAST=${localConfig.cacheUrl}
|
||||
CACHE_V2_DRAGONFLY_URL=${localConfig.dragonflyUrl}
|
||||
|
||||
Reference in New Issue
Block a user