feat: add AI Workspace npm bridge setup
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# NODE.DC AI Workspace Bridge
|
||||
|
||||
Installs the remote Codex worker used by NODE.DC AI Workspace.
|
||||
|
||||
Registry form:
|
||||
|
||||
```sh
|
||||
npx --yes @nodedc/ai-workspace-bridge setup <setup-code> --gateway https://ai-hub.nodedc.ru
|
||||
```
|
||||
|
||||
Commands:
|
||||
|
||||
```sh
|
||||
ai-workspace-bridge setup <setup-code> [--gateway <url>] [--install-root <path>] [--workspace <path>] [--port <port>]
|
||||
ai-workspace-bridge status [--install-root <path>]
|
||||
ai-workspace-bridge doctor [--install-root <path>]
|
||||
ai-workspace-bridge start [--install-root <path>]
|
||||
ai-workspace-bridge stop [--install-root <path>]
|
||||
ai-workspace-bridge logs [--install-root <path>]
|
||||
```
|
||||
|
||||
The setup code is one-time and short-lived. The CLI does not print the redeemed pairing code or MCP tokens.
|
||||
+1017
File diff suppressed because it is too large
Load Diff
+2255
File diff suppressed because it is too large
Load Diff
Executable
+878
@@ -0,0 +1,878 @@
|
||||
#!/usr/bin/env node
|
||||
import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import fssync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULT_GATEWAY = "https://ai-hub.nodedc.ru";
|
||||
const PACKAGE_NAME = "@nodedc/ai-workspace-bridge";
|
||||
const SERVICE_NAME = "NDC AI Workspace Bridge";
|
||||
const WINDOWS_TASK_NAME = "NDC AI Workspace Bridge";
|
||||
const HEALTH_PATH = "/api/ai-workspace/bridge/v1/health";
|
||||
const DEFAULT_CODEX_ARGS = [
|
||||
"exec",
|
||||
"--json",
|
||||
"--skip-git-repo-check",
|
||||
"-c",
|
||||
"model_reasoning_summary=detailed",
|
||||
"-c",
|
||||
"model_supports_reasoning_summaries=true",
|
||||
"-c",
|
||||
"use_experimental_reasoning_summary=true",
|
||||
"-c",
|
||||
"hide_agent_reasoning=false",
|
||||
"-c",
|
||||
"show_raw_agent_reasoning=false",
|
||||
"-",
|
||||
];
|
||||
|
||||
const CLI_FILE = fileURLToPath(import.meta.url);
|
||||
const PACKAGE_ROOT = path.resolve(path.dirname(CLI_FILE), "..");
|
||||
const ASSETS_DIR = path.join(PACKAGE_ROOT, "assets");
|
||||
|
||||
class UsageError extends Error {}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printHelp(args.command);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command === "setup") return runSetup(args);
|
||||
if (args.command === "status") return runStatus(args, { doctor: false });
|
||||
if (args.command === "doctor") return runStatus(args, { doctor: true });
|
||||
if (args.command === "start") return runStart(args);
|
||||
if (args.command === "stop") return runStop(args);
|
||||
if (args.command === "logs") return runLogs(args);
|
||||
|
||||
throw new UsageError(`Unknown command: ${args.command}`);
|
||||
}
|
||||
|
||||
async function runSetup(args) {
|
||||
if (!args.setupCode) {
|
||||
throw new UsageError(`Missing setup code. Use: ai-workspace-bridge setup <setup-code>`);
|
||||
}
|
||||
ensureNodeRuntime();
|
||||
|
||||
const gateway = cleanHttpEndpoint(args.gateway || process.env.NODEDC_AI_WORKSPACE_GATEWAY || DEFAULT_GATEWAY);
|
||||
if (!gateway) throw new UsageError("--gateway must be an http(s) URL.");
|
||||
|
||||
console.log(`${SERVICE_NAME} setup`);
|
||||
console.log(`Gateway: ${gateway}`);
|
||||
const redeemPayload = await redeemSetupCode(gateway, args.setupCode);
|
||||
const setup = normalizeSetupPayload(redeemPayload);
|
||||
const bridge = setup.bridge;
|
||||
|
||||
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
|
||||
const workspace = path.resolve(expandHome(args.workspace || bridge.workspace || process.cwd()));
|
||||
const port = sanitizePort(args.port || bridge.port || 8787);
|
||||
const codexHome = path.join(installRoot, "codex-home");
|
||||
const paths = buildInstallPaths(installRoot);
|
||||
const pathDirs = resolvePathDirs();
|
||||
|
||||
await mkdir(paths.logsDir, { recursive: true });
|
||||
await mkdir(workspace, { recursive: true });
|
||||
await copyFile(path.join(ASSETS_DIR, "worker.mjs"), paths.workerPath);
|
||||
await copyFile(path.join(ASSETS_DIR, "ndcAgentMcpServer.mjs"), paths.ndcAgentMcpServerPath);
|
||||
await writeStartScript(paths.startScriptPath);
|
||||
await writeConfig(paths.configPath, {
|
||||
service: SERVICE_NAME,
|
||||
packageName: PACKAGE_NAME,
|
||||
installedAt: new Date().toISOString(),
|
||||
gateway,
|
||||
installRoot,
|
||||
workspace,
|
||||
port,
|
||||
codexHome,
|
||||
worker: paths.workerPath,
|
||||
ndcAgentMcpServer: paths.ndcAgentMcpServerPath,
|
||||
startScript: paths.startScriptPath,
|
||||
pidPath: paths.pidPath,
|
||||
logPath: paths.logPath,
|
||||
healthPath: HEALTH_PATH,
|
||||
nodePath: process.execPath,
|
||||
pathDirs,
|
||||
hubUrl: bridge.hubUrl,
|
||||
hubUrls: bridge.hubUrls,
|
||||
pairingCode: bridge.pairingCode,
|
||||
machineName: bridge.machineName || os.hostname(),
|
||||
appMcpServers: bridge.appMcpServers,
|
||||
});
|
||||
|
||||
if (!args.noCodexInstall) {
|
||||
await ensureCodexCli({ pathDirs });
|
||||
}
|
||||
await setupCodexHome(codexHome, bridge.appMcpServers);
|
||||
if (!args.noCodexLogin) {
|
||||
await ensureCodexLogin(codexHome, { pathDirs });
|
||||
}
|
||||
|
||||
if (!args.noAutostart) {
|
||||
await installAutostart(paths.startScriptPath, installRoot).catch((error) => {
|
||||
console.warn(`Autostart was not registered: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
await stopBridge(installRoot).catch(() => {});
|
||||
await startBridge(installRoot);
|
||||
const health = await waitForHealth(port, 15000);
|
||||
|
||||
console.log(`${SERVICE_NAME} setup complete.`);
|
||||
console.log(`Install root: ${installRoot}`);
|
||||
console.log(`Workspace: ${workspace}`);
|
||||
console.log(`Health: ${health.ok ? "online" : "not ready"}`);
|
||||
console.log(`Run: ai-workspace-bridge doctor`);
|
||||
}
|
||||
|
||||
async function runStatus(args, { doctor }) {
|
||||
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
|
||||
const config = await readLocalConfig(installRoot);
|
||||
if (!config) {
|
||||
console.log(`${SERVICE_NAME} is not installed at ${installRoot}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const health = await fetchHealth(config.port);
|
||||
const codex = doctor ? inspectCodex(config.codexHome, config.pathDirs || []) : null;
|
||||
console.log(`${SERVICE_NAME} status`);
|
||||
console.log(`Install root: ${config.installRoot || installRoot}`);
|
||||
console.log(`Workspace: ${config.workspace || ""}`);
|
||||
console.log(`Port: ${config.port || ""}`);
|
||||
console.log(`Hub: ${health.ok ? "online" : "offline"}`);
|
||||
if (doctor) {
|
||||
console.log(`Node: ${process.version}`);
|
||||
console.log(`Codex CLI: ${codex.codexOk ? codex.codexVersion || "ok" : "missing"}`);
|
||||
console.log(`Codex auth: ${codex.authOk ? "present" : "missing"}`);
|
||||
if (codex.error) console.log(`Codex detail: ${codex.error}`);
|
||||
}
|
||||
process.exitCode = health.ok && (!doctor || (codex.codexOk && codex.authOk)) ? 0 : 1;
|
||||
}
|
||||
|
||||
async function runStart(args) {
|
||||
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
|
||||
await startBridge(installRoot);
|
||||
console.log(`${SERVICE_NAME} started.`);
|
||||
}
|
||||
|
||||
async function runStop(args) {
|
||||
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
|
||||
await stopBridge(installRoot);
|
||||
console.log(`${SERVICE_NAME} stopped.`);
|
||||
}
|
||||
|
||||
async function runLogs(args) {
|
||||
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
|
||||
const config = await readLocalConfig(installRoot);
|
||||
const logPath = config?.logPath || path.join(installRoot, "logs", "bridge.log");
|
||||
const text = await readFile(logPath, "utf8").catch(() => "");
|
||||
console.log(tailLines(text, Number(args.lines || 120)));
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs) {
|
||||
let args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
|
||||
let command = "setup";
|
||||
if (["setup", "status", "doctor", "start", "stop", "logs"].includes(args[0])) {
|
||||
command = args[0];
|
||||
args = args.slice(1);
|
||||
} else if (args[0] === "help") {
|
||||
return { command: "help", help: true };
|
||||
}
|
||||
|
||||
const parsed = {
|
||||
command,
|
||||
gateway: "",
|
||||
setupCode: "",
|
||||
installRoot: "",
|
||||
workspace: "",
|
||||
port: "",
|
||||
lines: "",
|
||||
help: false,
|
||||
noAutostart: false,
|
||||
noCodexInstall: false,
|
||||
noCodexLogin: false,
|
||||
};
|
||||
const positional = [];
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--gateway") {
|
||||
parsed.gateway = requireValue(args, ++i, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--gateway=")) {
|
||||
parsed.gateway = arg.slice("--gateway=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--install-root") {
|
||||
parsed.installRoot = requireValue(args, ++i, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--install-root=")) {
|
||||
parsed.installRoot = arg.slice("--install-root=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--workspace") {
|
||||
parsed.workspace = requireValue(args, ++i, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--workspace=")) {
|
||||
parsed.workspace = arg.slice("--workspace=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--port") {
|
||||
parsed.port = requireValue(args, ++i, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--port=")) {
|
||||
parsed.port = arg.slice("--port=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--lines") {
|
||||
parsed.lines = requireValue(args, ++i, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--lines=")) {
|
||||
parsed.lines = arg.slice("--lines=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-autostart") {
|
||||
parsed.noAutostart = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-codex-install") {
|
||||
parsed.noCodexInstall = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-codex-login") {
|
||||
parsed.noCodexLogin = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--setup-code" || arg === "--code") {
|
||||
parsed.setupCode = requireValue(args, ++i, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--setup-code=")) {
|
||||
parsed.setupCode = arg.slice("--setup-code=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--code=")) {
|
||||
parsed.setupCode = arg.slice("--code=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) throw new UsageError(`Unknown argument: ${arg}`);
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
if (command === "setup") {
|
||||
parsed.setupCode ||= positional.shift() || "";
|
||||
}
|
||||
if (positional.length) throw new UsageError(`Unexpected argument: ${positional[0]}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireValue(args, index, flag) {
|
||||
const value = args[index];
|
||||
if (!value || value.startsWith("--")) throw new UsageError(`Missing value for ${flag}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Install ${SERVICE_NAME}.
|
||||
|
||||
Usage:
|
||||
ai-workspace-bridge setup <setup-code> [--gateway <url>] [--install-root <path>] [--workspace <path>] [--port <port>]
|
||||
ai-workspace-bridge status [--install-root <path>]
|
||||
ai-workspace-bridge doctor [--install-root <path>]
|
||||
ai-workspace-bridge start [--install-root <path>]
|
||||
ai-workspace-bridge stop [--install-root <path>]
|
||||
ai-workspace-bridge logs [--install-root <path>]
|
||||
|
||||
Registry form:
|
||||
npx --yes ${PACKAGE_NAME} setup <setup-code> --gateway ${DEFAULT_GATEWAY}
|
||||
`);
|
||||
}
|
||||
|
||||
async function redeemSetupCode(gateway, setupCode) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${gateway}/api/ai-workspace/setup-codes/redeem`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ setupCode, setup_code: setupCode }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Setup code redeem failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
let data = {};
|
||||
try {
|
||||
data = bodyText ? JSON.parse(bodyText) : {};
|
||||
} catch {
|
||||
throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`);
|
||||
}
|
||||
if (!response.ok || data.ok === false) {
|
||||
throw new Error(`Setup code redeem failed: ${data.error || `HTTP ${response.status}`}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeSetupPayload(payload) {
|
||||
const setup = isPlainObject(payload?.setup) ? payload.setup : {};
|
||||
const bridge = isPlainObject(setup.bridge) ? setup.bridge : {};
|
||||
const pairingCode = cleanString(bridge.pairingCode || bridge.pairing_code, 80);
|
||||
const hubUrl = cleanWsEndpoint(bridge.hubUrl || bridge.hub_url);
|
||||
const hubUrls = uniqueStrings([
|
||||
hubUrl,
|
||||
...(Array.isArray(bridge.hubUrls) ? bridge.hubUrls : []),
|
||||
...(Array.isArray(bridge.hub_urls) ? bridge.hub_urls : []),
|
||||
].map(cleanWsEndpoint));
|
||||
if (!pairingCode) throw new Error("Setup payload is missing pairing code.");
|
||||
if (!hubUrls.length) throw new Error("Setup payload is missing Hub URL.");
|
||||
return {
|
||||
bridge: {
|
||||
hubUrl: hubUrls[0],
|
||||
hubUrls,
|
||||
pairingCode,
|
||||
machineName: cleanString(bridge.machineName || bridge.machine_name, 120) || os.hostname(),
|
||||
workspace: cleanString(bridge.workspace || bridge.workspacePath || bridge.workspace_path, 1000),
|
||||
port: sanitizePort(bridge.port || 8787),
|
||||
appMcpServers: sanitizeMcpServers(bridge.appMcpServers || bridge.app_mcp_servers),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeStartScript(startScriptPath) {
|
||||
const source = `#!/usr/bin/env node
|
||||
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
||||
import fssync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const configPath = path.join(root, "config.json");
|
||||
const config = JSON.parse(await readFile(configPath, "utf8"));
|
||||
await mkdir(path.dirname(config.logPath), { recursive: true });
|
||||
await writeFile(config.pidPath, String(process.pid), "utf8").catch(() => {});
|
||||
|
||||
let stopping = false;
|
||||
let child = null;
|
||||
process.on("SIGTERM", () => stop("SIGTERM"));
|
||||
process.on("SIGINT", () => stop("SIGINT"));
|
||||
|
||||
function stop(signal) {
|
||||
stopping = true;
|
||||
if (child && !child.killed) {
|
||||
try { child.kill(signal); } catch {}
|
||||
}
|
||||
setTimeout(() => process.exit(0), 1500).unref();
|
||||
}
|
||||
|
||||
function buildEnv() {
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const pathDirs = Array.isArray(config.pathDirs) ? config.pathDirs.filter(Boolean) : [];
|
||||
return {
|
||||
...process.env,
|
||||
CODEX_HOME: config.codexHome,
|
||||
AI_BRIDGE_HOST: "0.0.0.0",
|
||||
AI_BRIDGE_PORT: String(config.port || 8787),
|
||||
AI_BRIDGE_CODEX_CWD: config.workspace,
|
||||
AI_BRIDGE_CODEX_BIN: "codex",
|
||||
AI_BRIDGE_CODEX_ARGS: ${JSON.stringify(JSON.stringify(DEFAULT_CODEX_ARGS))},
|
||||
AI_BRIDGE_ALLOW_ORIGIN: "*",
|
||||
AI_BRIDGE_HUB_URL: config.hubUrl || "",
|
||||
AI_BRIDGE_HUB_URLS: Array.isArray(config.hubUrls) ? config.hubUrls.join(",") : "",
|
||||
AI_BRIDGE_PAIRING_CODE: config.pairingCode || "",
|
||||
AI_BRIDGE_MACHINE_NAME: config.machineName || "",
|
||||
AI_BRIDGE_ACTIVE_MIRROR_DIR: path.join(config.workspace || root, ".nodedc", "ai-workspace"),
|
||||
AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE: "1",
|
||||
PATH: [...pathDirs, process.env.PATH || ""].filter(Boolean).join(delimiter),
|
||||
Path: [...pathDirs, process.env.Path || process.env.PATH || ""].filter(Boolean).join(delimiter),
|
||||
};
|
||||
}
|
||||
|
||||
async function runOnce() {
|
||||
const log = await open(config.logPath, "a");
|
||||
try {
|
||||
child = spawn(config.nodePath || process.execPath, [config.worker], {
|
||||
cwd: config.installRoot || root,
|
||||
env: buildEnv(),
|
||||
stdio: ["ignore", log.fd, log.fd],
|
||||
windowsHide: true,
|
||||
});
|
||||
const code = await new Promise((resolve) => {
|
||||
child.on("exit", (exitCode, signal) => resolve(signal || exitCode || 0));
|
||||
child.on("error", (error) => resolve(error.message));
|
||||
});
|
||||
child = null;
|
||||
return code;
|
||||
} finally {
|
||||
await log.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
while (!stopping) {
|
||||
const code = await runOnce();
|
||||
if (stopping) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
`;
|
||||
await writeFile(startScriptPath, source, "utf8");
|
||||
await chmodIfPossible(startScriptPath, 0o755);
|
||||
}
|
||||
|
||||
async function writeConfig(configPath, config) {
|
||||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function setupCodexHome(codexHome, appMcpServers) {
|
||||
await mkdir(codexHome, { recursive: true });
|
||||
await copyIfExists(path.join(os.homedir(), ".codex", "auth.json"), path.join(codexHome, "auth.json"));
|
||||
await mergeCodexMcpConfig(path.join(codexHome, "config.toml"), appMcpServers);
|
||||
}
|
||||
|
||||
async function mergeCodexMcpConfig(configPath, appMcpServers) {
|
||||
const servers = sanitizeMcpServers(appMcpServers);
|
||||
if (!servers.length) {
|
||||
await touchFile(configPath);
|
||||
return;
|
||||
}
|
||||
let raw = await readFile(configPath, "utf8").catch(() => "");
|
||||
for (const server of servers) {
|
||||
raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}]`);
|
||||
raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}.headers]`);
|
||||
raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}.http_headers]`);
|
||||
const lines = [
|
||||
`[mcp_servers.${server.serverName}]`,
|
||||
`url = ${tomlString(server.url)}`,
|
||||
`startup_timeout_sec = ${server.startupTimeoutSec}`,
|
||||
`tool_timeout_sec = ${server.toolTimeoutSec}`,
|
||||
`required = ${server.required ? "true" : "false"}`,
|
||||
];
|
||||
if (Object.keys(server.httpHeaders).length) {
|
||||
lines.push("", `[mcp_servers.${server.serverName}.http_headers]`);
|
||||
for (const [key, value] of Object.entries(server.httpHeaders)) {
|
||||
lines.push(`${tomlBareKey(key)} = ${tomlString(value)}`);
|
||||
}
|
||||
}
|
||||
raw = `${raw.trim()}\n\n${lines.join("\n")}\n`;
|
||||
}
|
||||
await mkdir(path.dirname(configPath), { recursive: true });
|
||||
await writeFile(configPath, raw.trimStart(), "utf8");
|
||||
}
|
||||
|
||||
function removeTomlSection(raw, header) {
|
||||
const lines = String(raw || "").split(/\r?\n/);
|
||||
const out = [];
|
||||
let skipping = false;
|
||||
const normalizedHeader = normalizeTomlHeader(header);
|
||||
for (const line of lines) {
|
||||
const current = normalizeTomlHeader(line);
|
||||
if (current) {
|
||||
skipping = current === normalizedHeader;
|
||||
if (skipping) continue;
|
||||
}
|
||||
if (!skipping) out.push(line);
|
||||
}
|
||||
return out.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
function normalizeTomlHeader(line) {
|
||||
const text = String(line || "").trim();
|
||||
if (!text.startsWith("[") || !text.endsWith("]")) return "";
|
||||
return text.replace(/\s+/g, "").replace(/["']/g, "");
|
||||
}
|
||||
|
||||
async function ensureCodexCli({ pathDirs }) {
|
||||
const before = runCommandQuiet("codex", ["--version"], { pathDirs });
|
||||
if (before.ok) return;
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
console.log("Installing Codex CLI with npm...");
|
||||
await runCommand(npm, ["install", "-g", "@openai/codex", "--no-audit", "--no-fund"], { pathDirs });
|
||||
const after = runCommandQuiet("codex", ["--version"], { pathDirs: resolvePathDirs() });
|
||||
if (!after.ok) throw new Error("Codex CLI was installed, but codex is not available in PATH.");
|
||||
}
|
||||
|
||||
async function ensureCodexLogin(codexHome, { pathDirs }) {
|
||||
if (fssync.existsSync(path.join(codexHome, "auth.json"))) return;
|
||||
console.log("Codex auth is missing. Opening codex login...");
|
||||
await runCommand("codex", ["login"], {
|
||||
pathDirs,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
stdio: "inherit",
|
||||
}).catch((error) => {
|
||||
console.warn(`Codex login did not complete: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function inspectCodex(codexHome, pathDirs) {
|
||||
const version = runCommandQuiet("codex", ["--version"], { pathDirs });
|
||||
return {
|
||||
codexOk: version.ok,
|
||||
codexVersion: version.stdout.trim(),
|
||||
authOk: fssync.existsSync(path.join(codexHome || "", "auth.json")),
|
||||
error: version.ok ? "" : version.error,
|
||||
};
|
||||
}
|
||||
|
||||
async function installAutostart(startScriptPath, installRoot) {
|
||||
if (process.platform === "win32") return installWindowsTask(startScriptPath);
|
||||
if (process.platform === "darwin") return installLaunchAgent(startScriptPath);
|
||||
return installSystemdUserService(startScriptPath, installRoot);
|
||||
}
|
||||
|
||||
async function installWindowsTask(startScriptPath) {
|
||||
const taskRun = `"${process.execPath}" "${startScriptPath}"`;
|
||||
runCommandQuiet("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], {});
|
||||
await runCommand("schtasks", [
|
||||
"/Create",
|
||||
"/TN",
|
||||
WINDOWS_TASK_NAME,
|
||||
"/SC",
|
||||
"ONLOGON",
|
||||
"/TR",
|
||||
taskRun,
|
||||
"/RL",
|
||||
"HIGHEST",
|
||||
"/F",
|
||||
], {});
|
||||
}
|
||||
|
||||
async function installLaunchAgent(startScriptPath) {
|
||||
const label = "ru.nodedc.ai-workspace-bridge";
|
||||
const plistDir = path.join(os.homedir(), "Library", "LaunchAgents");
|
||||
const plistPath = path.join(plistDir, `${label}.plist`);
|
||||
await mkdir(plistDir, { recursive: true });
|
||||
await writeFile(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>${label}</string>
|
||||
<key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(startScriptPath)}</string></array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key><string>${xmlEscape(path.join(path.dirname(startScriptPath), "logs", "launchd.out.log"))}</string>
|
||||
<key>StandardErrorPath</key><string>${xmlEscape(path.join(path.dirname(startScriptPath), "logs", "launchd.err.log"))}</string>
|
||||
</dict></plist>
|
||||
`, "utf8");
|
||||
runCommandQuiet("launchctl", ["bootout", `gui/${process.getuid?.()}`, plistPath], {});
|
||||
await runCommand("launchctl", ["bootstrap", `gui/${process.getuid?.()}`, plistPath], {});
|
||||
await runCommand("launchctl", ["enable", `gui/${process.getuid?.()}/${label}`], {});
|
||||
}
|
||||
|
||||
async function installSystemdUserService(startScriptPath, installRoot) {
|
||||
const serviceDir = path.join(os.homedir(), ".config", "systemd", "user");
|
||||
const servicePath = path.join(serviceDir, "nodedc-ai-workspace-bridge.service");
|
||||
await mkdir(serviceDir, { recursive: true });
|
||||
await writeFile(servicePath, `[Unit]
|
||||
Description=NDC AI Workspace Bridge
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${process.execPath} ${startScriptPath}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
WorkingDirectory=${installRoot}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`, "utf8");
|
||||
await runCommand("systemctl", ["--user", "daemon-reload"], {});
|
||||
await runCommand("systemctl", ["--user", "enable", "--now", "nodedc-ai-workspace-bridge.service"], {});
|
||||
}
|
||||
|
||||
async function startBridge(installRoot) {
|
||||
const config = await readLocalConfig(installRoot);
|
||||
if (!config?.startScript) throw new Error(`${SERVICE_NAME} is not installed at ${installRoot}`);
|
||||
const child = spawn(config.nodePath || process.execPath, [config.startScript], {
|
||||
cwd: installRoot,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function stopBridge(installRoot) {
|
||||
const config = await readLocalConfig(installRoot);
|
||||
const pidPath = config?.pidPath || path.join(installRoot, "bridge.pid");
|
||||
const pidText = await readFile(pidPath, "utf8").catch(() => "");
|
||||
const pid = Number(pidText.trim());
|
||||
if (!Number.isInteger(pid) || pid <= 0) return;
|
||||
if (process.platform === "win32") {
|
||||
runCommandQuiet("taskkill", ["/PID", String(pid), "/T", "/F"], {});
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
await rm(pidPath, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
async function waitForHealth(port, timeoutMs) {
|
||||
const started = Date.now();
|
||||
let latest = { ok: false, error: "not_checked" };
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
latest = await fetchHealth(port);
|
||||
if (latest.ok) return latest;
|
||||
await sleep(700);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function fetchHealth(port) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${sanitizePort(port)}${HEALTH_PATH}`);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return { ok: response.ok && data?.ok !== false, data };
|
||||
} catch (error) {
|
||||
return { ok: false, error: String(error?.message || error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalConfig(installRoot) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(installRoot, "config.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstallPaths(installRoot) {
|
||||
return {
|
||||
logsDir: path.join(installRoot, "logs"),
|
||||
logPath: path.join(installRoot, "logs", "bridge.log"),
|
||||
pidPath: path.join(installRoot, "bridge.pid"),
|
||||
configPath: path.join(installRoot, "config.json"),
|
||||
workerPath: path.join(installRoot, "worker.mjs"),
|
||||
ndcAgentMcpServerPath: path.join(installRoot, "ndcAgentMcpServer.mjs"),
|
||||
startScriptPath: path.join(installRoot, "start-bridge.mjs"),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultInstallRoot() {
|
||||
if (process.platform === "win32") {
|
||||
return path.join(process.env.LOCALAPPDATA || os.homedir(), "NDC", "AIWorkspaceBridge");
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(os.homedir(), "Library", "Application Support", "NDC", "AIWorkspaceBridge");
|
||||
}
|
||||
return path.join(os.homedir(), ".local", "share", "nodedc", "ai-workspace-bridge");
|
||||
}
|
||||
|
||||
function resolvePathDirs() {
|
||||
const dirs = [path.dirname(process.execPath)];
|
||||
const prefix = runCommandQuiet(process.platform === "win32" ? "npm.cmd" : "npm", ["prefix", "-g"], {}).stdout.trim();
|
||||
if (prefix) {
|
||||
dirs.push(process.platform === "win32" ? prefix : path.join(prefix, "bin"));
|
||||
}
|
||||
return uniqueStrings(dirs);
|
||||
}
|
||||
|
||||
function runCommandQuiet(command, args, options = {}) {
|
||||
try {
|
||||
const result = spawnSync(command, args, {
|
||||
env: commandEnv(options),
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
stdout: result.stdout || "",
|
||||
stderr: result.stderr || "",
|
||||
error: result.error ? result.error.message : result.stderr || "",
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, stdout: "", stderr: "", error: String(error?.message || error) };
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env: commandEnv(options),
|
||||
stdio: options.stdio || "inherit",
|
||||
shell: process.platform === "win32",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function commandEnv(options = {}) {
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const pathDirs = Array.isArray(options.pathDirs) ? options.pathDirs.filter(Boolean) : [];
|
||||
const basePath = process.env.PATH || process.env.Path || "";
|
||||
return {
|
||||
...process.env,
|
||||
...(options.env || {}),
|
||||
PATH: [...pathDirs, basePath].filter(Boolean).join(delimiter),
|
||||
Path: [...pathDirs, process.env.Path || basePath].filter(Boolean).join(delimiter),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureNodeRuntime() {
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
if (!Number.isInteger(major) || major < 22) {
|
||||
throw new Error(`${SERVICE_NAME} requires Node.js 22+ for WebSocket bridge mode. Current: ${process.version}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMcpServers(value) {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
return items.map((item) => {
|
||||
if (!isPlainObject(item)) return null;
|
||||
const serverName = safeMcpServerName(item.serverName || item.server_name || item.name);
|
||||
const url = cleanHttpEndpoint(item.url);
|
||||
if (!serverName || !url) return null;
|
||||
return {
|
||||
serverName,
|
||||
url,
|
||||
required: item.required === true,
|
||||
startupTimeoutSec: sanitizeInteger(item.startupTimeoutSec || item.startup_timeout_sec, 20, 1, 300),
|
||||
toolTimeoutSec: sanitizeInteger(item.toolTimeoutSec || item.tool_timeout_sec, 60, 1, 3600),
|
||||
httpHeaders: sanitizeHeaders(item.httpHeaders || item.http_headers || item.headers),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function sanitizeHeaders(value) {
|
||||
if (!isPlainObject(value)) return {};
|
||||
const headers = {};
|
||||
for (const [key, raw] of Object.entries(value)) {
|
||||
const name = cleanString(key, 120);
|
||||
const text = cleanString(raw, 2000);
|
||||
if (!name || !text || /[\r\n\0]/.test(name) || /[\r\n\0]/.test(text)) continue;
|
||||
headers[name] = text;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function safeMcpServerName(value) {
|
||||
const text = String(value || "").trim().replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80);
|
||||
return text || "";
|
||||
}
|
||||
|
||||
function cleanHttpEndpoint(value) {
|
||||
const text = cleanString(value, 1000);
|
||||
if (!text) return "";
|
||||
try {
|
||||
const url = new URL(text);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function cleanWsEndpoint(value) {
|
||||
const text = cleanString(value, 1000);
|
||||
if (!text) return "";
|
||||
try {
|
||||
const url = new URL(text);
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") return "";
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePort(value) {
|
||||
const port = Number(value || 8787);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError("port_invalid");
|
||||
return port;
|
||||
}
|
||||
|
||||
function sanitizeInteger(value, fallback, min, max) {
|
||||
const number = Number(value || fallback);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.min(Math.max(Math.trunc(number), min), max);
|
||||
}
|
||||
|
||||
function cleanString(value, max) {
|
||||
return String(value || "").trim().slice(0, max);
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function expandHome(value) {
|
||||
const text = String(value || "");
|
||||
if (text === "~") return os.homedir();
|
||||
if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2));
|
||||
return text;
|
||||
}
|
||||
|
||||
function tomlString(value) {
|
||||
return JSON.stringify(String(value || ""));
|
||||
}
|
||||
|
||||
function tomlBareKey(value) {
|
||||
const key = String(value || "");
|
||||
return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function copyIfExists(from, to) {
|
||||
try {
|
||||
await stat(from);
|
||||
await mkdir(path.dirname(to), { recursive: true });
|
||||
await copyFile(from, to);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function touchFile(filePath) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
if (!fssync.existsSync(filePath)) await writeFile(filePath, "", "utf8");
|
||||
}
|
||||
|
||||
async function chmodIfPossible(filePath, mode) {
|
||||
try {
|
||||
await fssync.promises.chmod(filePath, mode);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function tailLines(text, count) {
|
||||
return String(text || "").split(/\r?\n/).slice(-Math.max(1, count || 120)).join("\n");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof UsageError ? error.message : `Error: ${error.message || error}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@nodedc/ai-workspace-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Install NODE.DC AI Workspace Bridge worker.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ai-workspace-bridge": "./bin/nodedc-ai-workspace-bridge.mjs",
|
||||
"nodedc-ai-workspace-bridge": "./bin/nodedc-ai-workspace-bridge.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"files": [
|
||||
"assets/",
|
||||
"bin/",
|
||||
"README.md"
|
||||
],
|
||||
"keywords": [
|
||||
"nodedc",
|
||||
"ai-workspace",
|
||||
"codex",
|
||||
"bridge"
|
||||
],
|
||||
"license": "UNLICENSED",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user