[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
82
electron/cli/discoveryPath.cjs
Normal file
82
electron/cli/discoveryPath.cjs
Normal file
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const CLI_STATE_DIR_NAME = "netcatty-tool-cli";
|
||||
const TOOL_CLI_DISCOVERY_ENV_VAR = "NETCATTY_TOOL_CLI_DISCOVERY_FILE";
|
||||
const FALLBACK_APP_DATA_DIR_NAME = "netcatty";
|
||||
|
||||
function toUnpackedAsarPath(filePath) {
|
||||
return filePath.replace(/app\.asar([\\/])/, "app.asar.unpacked$1");
|
||||
}
|
||||
|
||||
function getDefaultAppDataDirName() {
|
||||
const packageJsonPaths = [
|
||||
process.resourcesPath ? path.join(process.resourcesPath, "app.asar", "package.json") : null,
|
||||
path.resolve(__dirname, "../../package.json"),
|
||||
path.join(process.cwd(), "package.json"),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const packageJsonPath of packageJsonPaths) {
|
||||
try {
|
||||
const packageJson = require(packageJsonPath);
|
||||
if (typeof packageJson?.name === "string" && packageJson.name) {
|
||||
return packageJson.name;
|
||||
}
|
||||
} catch {
|
||||
// Try the next location.
|
||||
}
|
||||
}
|
||||
|
||||
return FALLBACK_APP_DATA_DIR_NAME;
|
||||
}
|
||||
|
||||
function getDefaultUserDataDir() {
|
||||
const appDataDirName = getDefaultAppDataDirName();
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(os.homedir(), "Library", "Application Support", appDataDirName);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, appDataDirName);
|
||||
}
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
return path.join(xdgConfigHome, appDataDirName);
|
||||
}
|
||||
|
||||
function getConfiguredDiscoveryFilePath() {
|
||||
return process.env[TOOL_CLI_DISCOVERY_ENV_VAR] || null;
|
||||
}
|
||||
|
||||
function getToolCliStateDir(options = {}) {
|
||||
const discoveryFilePath = getConfiguredDiscoveryFilePath();
|
||||
if (discoveryFilePath) {
|
||||
return path.dirname(discoveryFilePath);
|
||||
}
|
||||
const userDataDir = typeof options.userDataDir === "string" && options.userDataDir
|
||||
? options.userDataDir
|
||||
: getDefaultUserDataDir();
|
||||
return path.join(userDataDir, CLI_STATE_DIR_NAME);
|
||||
}
|
||||
|
||||
function getCliDiscoveryFilePath(options = {}) {
|
||||
const discoveryFilePath = getConfiguredDiscoveryFilePath();
|
||||
if (discoveryFilePath) {
|
||||
return discoveryFilePath;
|
||||
}
|
||||
return path.join(getToolCliStateDir(options), "discovery.json");
|
||||
}
|
||||
|
||||
function getCliLauncherPath() {
|
||||
const fileName = process.platform === "win32"
|
||||
? "netcatty-tool-cli.cmd"
|
||||
: "netcatty-tool-cli";
|
||||
return toUnpackedAsarPath(path.join(__dirname, fileName));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getToolCliStateDir,
|
||||
getCliDiscoveryFilePath,
|
||||
getCliLauncherPath,
|
||||
TOOL_CLI_DISCOVERY_ENV_VAR,
|
||||
};
|
||||
66
electron/cli/externalMcpDiscovery.cjs
Normal file
66
electron/cli/externalMcpDiscovery.cjs
Normal file
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function buildExternalDiscoveryPayload({
|
||||
host = "127.0.0.1",
|
||||
port,
|
||||
token,
|
||||
pid,
|
||||
permissionMode,
|
||||
chatSessionId,
|
||||
}) {
|
||||
return {
|
||||
version: 1,
|
||||
host,
|
||||
port,
|
||||
token,
|
||||
pid,
|
||||
permissionMode: permissionMode || "confirm",
|
||||
chatSessionId: chatSessionId || "__external_mcp__",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function writeExternalDiscovery(filePath, options) {
|
||||
const payload = buildExternalDiscoveryPayload(options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
||||
return payload;
|
||||
}
|
||||
|
||||
function removeExternalDiscovery(filePath) {
|
||||
if (!filePath) return;
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
|
||||
function readExternalDiscovery(filePath) {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("External MCP discovery file is invalid.");
|
||||
}
|
||||
const port = Number(parsed.port);
|
||||
const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
|
||||
if (!Number.isFinite(port) || port <= 0 || !token) {
|
||||
throw new Error("External MCP discovery file is missing port or token.");
|
||||
}
|
||||
return {
|
||||
host: typeof parsed.host === "string" && parsed.host.trim() ? parsed.host.trim() : "127.0.0.1",
|
||||
port,
|
||||
token,
|
||||
permissionMode: typeof parsed.permissionMode === "string" ? parsed.permissionMode : "confirm",
|
||||
chatSessionId: typeof parsed.chatSessionId === "string" && parsed.chatSessionId
|
||||
? parsed.chatSessionId
|
||||
: "__external_mcp__",
|
||||
pid: parsed.pid ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildExternalDiscoveryPayload,
|
||||
writeExternalDiscovery,
|
||||
removeExternalDiscovery,
|
||||
readExternalDiscovery,
|
||||
};
|
||||
161
electron/cli/externalMcpDiscoveryPath.cjs
Normal file
161
electron/cli/externalMcpDiscoveryPath.cjs
Normal file
@@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const EXTERNAL_MCP_STATE_DIR_NAME = "external-mcp";
|
||||
const EXTERNAL_MCP_DISCOVERY_ENV_VAR = "NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE";
|
||||
const EXTERNAL_MCP_CHAT_SESSION_ID = "__external_mcp__";
|
||||
const FALLBACK_APP_DATA_DIR_NAME = "Netcatty";
|
||||
|
||||
function toUnpackedAsarPath(filePath) {
|
||||
return filePath.replace(/app\.asar([\\/])/, "app.asar.unpacked$1");
|
||||
}
|
||||
|
||||
function getDefaultAppDataDirName(options = {}) {
|
||||
const packageJsonPaths = Array.isArray(options.packageJsonPaths) && options.packageJsonPaths.length > 0
|
||||
? options.packageJsonPaths
|
||||
: [
|
||||
process.resourcesPath ? path.join(process.resourcesPath, "app.asar", "package.json") : null,
|
||||
path.resolve(__dirname, "../../package.json"),
|
||||
path.join(process.cwd(), "package.json"),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const packageJsonPath of packageJsonPaths) {
|
||||
try {
|
||||
const packageJson = require(packageJsonPath);
|
||||
if (typeof packageJson?.productName === "string" && packageJson.productName) {
|
||||
return packageJson.productName;
|
||||
}
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer Electron productName casing over package.json "name" (netcatty).
|
||||
return FALLBACK_APP_DATA_DIR_NAME;
|
||||
}
|
||||
|
||||
function getPlatformUserDataRoot(appDataDirName) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(os.homedir(), "Library", "Application Support", appDataDirName);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, appDataDirName);
|
||||
}
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
return path.join(xdgConfigHome, appDataDirName);
|
||||
}
|
||||
|
||||
function getDefaultUserDataDir() {
|
||||
return getPlatformUserDataRoot(getDefaultAppDataDirName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate userData roots the launcher may need when env is missing.
|
||||
* Includes packaged (Netcatty), lowercase package name, and Electron Dev (/dev).
|
||||
*/
|
||||
function listCandidateUserDataDirs(options = {}) {
|
||||
const names = Array.from(new Set([
|
||||
getDefaultAppDataDirName(options),
|
||||
"Netcatty",
|
||||
"netcatty",
|
||||
"Netcatty Dev",
|
||||
].filter(Boolean)));
|
||||
const roots = [];
|
||||
for (const name of names) {
|
||||
const root = getPlatformUserDataRoot(name);
|
||||
roots.push(root);
|
||||
roots.push(path.join(root, "dev"));
|
||||
}
|
||||
return Array.from(new Set(roots));
|
||||
}
|
||||
|
||||
function getConfiguredDiscoveryFilePath() {
|
||||
return process.env[EXTERNAL_MCP_DISCOVERY_ENV_VAR] || null;
|
||||
}
|
||||
|
||||
function getExternalMcpStateDir(options = {}) {
|
||||
const discoveryFilePath = getConfiguredDiscoveryFilePath();
|
||||
if (discoveryFilePath) {
|
||||
return path.dirname(discoveryFilePath);
|
||||
}
|
||||
const userDataDir = typeof options.userDataDir === "string" && options.userDataDir
|
||||
? options.userDataDir
|
||||
: getDefaultUserDataDir();
|
||||
return path.join(userDataDir, EXTERNAL_MCP_STATE_DIR_NAME);
|
||||
}
|
||||
|
||||
function getExternalMcpDiscoveryFilePath(options = {}) {
|
||||
const discoveryFilePath = getConfiguredDiscoveryFilePath();
|
||||
if (discoveryFilePath) {
|
||||
return discoveryFilePath;
|
||||
}
|
||||
return path.join(getExternalMcpStateDir(options), "discovery.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an existing discovery file for launcher/bootstrap use.
|
||||
* Prefers the env override, then the default path, then common Electron userData variants.
|
||||
*/
|
||||
function resolveExistingExternalMcpDiscoveryFilePath(options = {}) {
|
||||
const configured = getConfiguredDiscoveryFilePath();
|
||||
// Explicit client env must not silently fall back to another profile's file.
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
const primary = getExternalMcpDiscoveryFilePath(
|
||||
options.userDataDir ? { userDataDir: options.userDataDir } : {},
|
||||
);
|
||||
if (fs.existsSync(primary)) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
for (const userDataDir of listCandidateUserDataDirs(options)) {
|
||||
const candidate = path.join(userDataDir, EXTERNAL_MCP_STATE_DIR_NAME, "discovery.json");
|
||||
if (candidate !== primary && fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
function getExternalMcpLauncherPath() {
|
||||
const fileName = process.platform === "win32"
|
||||
? "netcatty-external-mcp.cmd"
|
||||
: "netcatty-external-mcp";
|
||||
return toUnpackedAsarPath(path.join(__dirname, fileName));
|
||||
}
|
||||
|
||||
function buildDiscoveryEnv(discoveryFilePath) {
|
||||
if (!discoveryFilePath) return {};
|
||||
return { [EXTERNAL_MCP_DISCOVERY_ENV_VAR]: discoveryFilePath };
|
||||
}
|
||||
|
||||
function formatDiscoveryEnvCliFlags(discoveryEnv, style = "codex") {
|
||||
const entries = Object.entries(discoveryEnv || {}).filter(([, value]) => typeof value === "string" && value);
|
||||
if (entries.length === 0) return [];
|
||||
if (style === "claude" || style === "grok") {
|
||||
return entries.flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
||||
}
|
||||
// Codex: --env KEY=VALUE
|
||||
return entries.flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDefaultAppDataDirName,
|
||||
getExternalMcpStateDir,
|
||||
getExternalMcpDiscoveryFilePath,
|
||||
resolveExistingExternalMcpDiscoveryFilePath,
|
||||
getExternalMcpLauncherPath,
|
||||
listCandidateUserDataDirs,
|
||||
buildDiscoveryEnv,
|
||||
formatDiscoveryEnvCliFlags,
|
||||
EXTERNAL_MCP_DISCOVERY_ENV_VAR,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
EXTERNAL_MCP_STATE_DIR_NAME,
|
||||
};
|
||||
102
electron/cli/externalMcpDiscoveryPath.test.cjs
Normal file
102
electron/cli/externalMcpDiscoveryPath.test.cjs
Normal file
@@ -0,0 +1,102 @@
|
||||
"use strict";
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
getExternalMcpDiscoveryFilePath,
|
||||
getExternalMcpLauncherPath,
|
||||
resolveExistingExternalMcpDiscoveryFilePath,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
EXTERNAL_MCP_DISCOVERY_ENV_VAR,
|
||||
buildDiscoveryEnv,
|
||||
formatDiscoveryEnvCliFlags,
|
||||
} = require("./externalMcpDiscoveryPath.cjs");
|
||||
const {
|
||||
buildExternalDiscoveryPayload,
|
||||
writeExternalDiscovery,
|
||||
removeExternalDiscovery,
|
||||
readExternalDiscovery,
|
||||
} = require("./externalMcpDiscovery.cjs");
|
||||
|
||||
describe("externalMcpDiscoveryPath", () => {
|
||||
it("returns a discovery path under the external-mcp state dir", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ext-mcp-"));
|
||||
const discoveryPath = getExternalMcpDiscoveryFilePath({ userDataDir: tmp });
|
||||
assert.equal(discoveryPath, path.join(tmp, "external-mcp", "discovery.json"));
|
||||
assert.equal(EXTERNAL_MCP_CHAT_SESSION_ID, "__external_mcp__");
|
||||
assert.ok(getExternalMcpLauncherPath().includes("netcatty-external-mcp"));
|
||||
});
|
||||
|
||||
it("honors NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE without falling back", () => {
|
||||
const previous = process.env[EXTERNAL_MCP_DISCOVERY_ENV_VAR];
|
||||
const custom = path.join(os.tmpdir(), "missing-external-discovery.json");
|
||||
process.env[EXTERNAL_MCP_DISCOVERY_ENV_VAR] = custom;
|
||||
try {
|
||||
assert.equal(getExternalMcpDiscoveryFilePath(), custom);
|
||||
assert.equal(resolveExistingExternalMcpDiscoveryFilePath(), custom);
|
||||
} finally {
|
||||
if (previous == null) delete process.env[EXTERNAL_MCP_DISCOVERY_ENV_VAR];
|
||||
else process.env[EXTERNAL_MCP_DISCOVERY_ENV_VAR] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves an existing discovery under candidate userData dirs", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ext-mcp-"));
|
||||
const discoveryPath = path.join(tmp, "external-mcp", "discovery.json");
|
||||
writeExternalDiscovery(discoveryPath, {
|
||||
port: 1,
|
||||
token: "t",
|
||||
pid: 1,
|
||||
});
|
||||
const resolved = resolveExistingExternalMcpDiscoveryFilePath({ userDataDir: tmp });
|
||||
assert.equal(resolved, discoveryPath);
|
||||
});
|
||||
|
||||
it("builds discovery env CLI flags for clients", () => {
|
||||
const env = buildDiscoveryEnv("/tmp/discovery.json");
|
||||
assert.deepEqual(
|
||||
formatDiscoveryEnvCliFlags(env, "codex"),
|
||||
["--env", `${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=/tmp/discovery.json`],
|
||||
);
|
||||
assert.deepEqual(
|
||||
formatDiscoveryEnvCliFlags(env, "claude"),
|
||||
["-e", `${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=/tmp/discovery.json`],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("externalMcpDiscovery", () => {
|
||||
it("writes and reads discovery with chatSessionId", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ext-mcp-"));
|
||||
const filePath = path.join(tmp, "discovery.json");
|
||||
const payload = writeExternalDiscovery(filePath, {
|
||||
port: 41234,
|
||||
token: "abc123",
|
||||
pid: 99,
|
||||
permissionMode: "confirm",
|
||||
chatSessionId: EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
});
|
||||
assert.equal(payload.port, 41234);
|
||||
assert.equal(payload.chatSessionId, EXTERNAL_MCP_CHAT_SESSION_ID);
|
||||
const read = readExternalDiscovery(filePath);
|
||||
assert.equal(read.port, 41234);
|
||||
assert.equal(read.token, "abc123");
|
||||
assert.equal(read.chatSessionId, EXTERNAL_MCP_CHAT_SESSION_ID);
|
||||
removeExternalDiscovery(filePath);
|
||||
assert.equal(fs.existsSync(filePath), false);
|
||||
});
|
||||
|
||||
it("buildExternalDiscoveryPayload defaults chatSessionId", () => {
|
||||
const payload = buildExternalDiscoveryPayload({
|
||||
port: 1,
|
||||
token: "t",
|
||||
pid: 1,
|
||||
});
|
||||
assert.equal(payload.chatSessionId, "__external_mcp__");
|
||||
assert.equal(payload.host, "127.0.0.1");
|
||||
});
|
||||
});
|
||||
29
electron/cli/netcatty-external-mcp
Normal file
29
electron/cli/netcatty-external-mcp
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
SERVER_SCRIPT="$SCRIPT_DIR/../mcp/netcatty-external-mcp-server.cjs"
|
||||
|
||||
APP_BIN=""
|
||||
if [ -n "${NETCATTY_CLI_ELECTRON_EXEC_PATH:-}" ] && [ -x "${NETCATTY_CLI_ELECTRON_EXEC_PATH}" ]; then
|
||||
APP_BIN="${NETCATTY_CLI_ELECTRON_EXEC_PATH}"
|
||||
elif [ -x "$SCRIPT_DIR/../../../../MacOS/Netcatty" ]; then
|
||||
APP_BIN="$SCRIPT_DIR/../../../../MacOS/Netcatty"
|
||||
elif [ -x "$SCRIPT_DIR/../../../../Netcatty" ]; then
|
||||
APP_BIN="$SCRIPT_DIR/../../../../Netcatty"
|
||||
elif [ -x "$SCRIPT_DIR/../../../../netcatty" ]; then
|
||||
APP_BIN="$SCRIPT_DIR/../../../../netcatty"
|
||||
fi
|
||||
|
||||
if [ -n "$APP_BIN" ]; then
|
||||
export ELECTRON_RUN_AS_NODE=1
|
||||
exec "$APP_BIN" "$SERVER_SCRIPT" "$@"
|
||||
fi
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
exec node "$SERVER_SCRIPT" "$@"
|
||||
fi
|
||||
|
||||
printf '%s\n' "Failed to locate the bundled Netcatty runtime for netcatty-external-mcp." >&2
|
||||
exit 1
|
||||
25
electron/cli/netcatty-external-mcp.cmd
Normal file
25
electron/cli/netcatty-external-mcp.cmd
Normal file
@@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "SERVER_SCRIPT=%SCRIPT_DIR%..\mcp\netcatty-external-mcp-server.cjs"
|
||||
set "APP_EXE="
|
||||
|
||||
if defined NETCATTY_CLI_ELECTRON_EXEC_PATH if exist "%NETCATTY_CLI_ELECTRON_EXEC_PATH%" set "APP_EXE=%NETCATTY_CLI_ELECTRON_EXEC_PATH%"
|
||||
if not defined APP_EXE if exist "%SCRIPT_DIR%..\..\..\..\Netcatty.exe" set "APP_EXE=%SCRIPT_DIR%..\..\..\..\Netcatty.exe"
|
||||
if not defined APP_EXE if exist "%SCRIPT_DIR%..\..\..\..\netcatty.exe" set "APP_EXE=%SCRIPT_DIR%..\..\..\..\netcatty.exe"
|
||||
|
||||
if defined APP_EXE (
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXE%" "%SERVER_SCRIPT%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
where node >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
node "%SERVER_SCRIPT%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
echo Failed to locate the bundled Netcatty runtime for netcatty-external-mcp. 1>&2
|
||||
exit /b 1
|
||||
29
electron/cli/netcatty-tool-cli
Normal file
29
electron/cli/netcatty-tool-cli
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
CLI_SCRIPT="$SCRIPT_DIR/netcatty-tool-cli.cjs"
|
||||
|
||||
APP_BIN=""
|
||||
if [ -n "${NETCATTY_CLI_ELECTRON_EXEC_PATH:-}" ] && [ -x "${NETCATTY_CLI_ELECTRON_EXEC_PATH}" ]; then
|
||||
APP_BIN="${NETCATTY_CLI_ELECTRON_EXEC_PATH}"
|
||||
elif [ -x "$SCRIPT_DIR/../../../../MacOS/Netcatty" ]; then
|
||||
APP_BIN="$SCRIPT_DIR/../../../../MacOS/Netcatty"
|
||||
elif [ -x "$SCRIPT_DIR/../../../../Netcatty" ]; then
|
||||
APP_BIN="$SCRIPT_DIR/../../../../Netcatty"
|
||||
elif [ -x "$SCRIPT_DIR/../../../../netcatty" ]; then
|
||||
APP_BIN="$SCRIPT_DIR/../../../../netcatty"
|
||||
fi
|
||||
|
||||
if [ -n "$APP_BIN" ]; then
|
||||
export ELECTRON_RUN_AS_NODE=1
|
||||
exec "$APP_BIN" "$CLI_SCRIPT" "$@"
|
||||
fi
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
exec node "$CLI_SCRIPT" "$@"
|
||||
fi
|
||||
|
||||
printf '%s\n' "Failed to locate the bundled Netcatty runtime for netcatty-tool-cli." >&2
|
||||
exit 1
|
||||
51
electron/cli/netcatty-tool-cli.capabilities.test.cjs
Normal file
51
electron/cli/netcatty-tool-cli.capabilities.test.cjs
Normal file
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
test("netcatty-tool-cli capabilities lists implemented commands without app connection", () => {
|
||||
const cliPath = path.join(__dirname, "..", "cli", "netcatty-tool-cli.cjs");
|
||||
const result = spawnSync(process.execPath, [cliPath, "capabilities", "--json"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.ok(payload.capabilities.some((entry) => entry.id === "terminal.execute"));
|
||||
assert.ok(payload.capabilities.some((entry) => entry.id === "vault.host.get"));
|
||||
assert.ok(payload.capabilities.some((entry) => entry.id === "portforward.rules.list"));
|
||||
});
|
||||
|
||||
test("netcatty-tool-cli capabilities runs from unpacked CLI runtime without app services", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-runtime-"));
|
||||
try {
|
||||
const electronDir = path.join(tmpDir, "electron");
|
||||
const cliDir = path.join(electronDir, "cli");
|
||||
const capabilitiesDir = path.join(electronDir, "capabilities");
|
||||
|
||||
fs.mkdirSync(electronDir, { recursive: true });
|
||||
fs.cpSync(path.join(__dirname), cliDir, { recursive: true });
|
||||
fs.cpSync(path.join(__dirname, "..", "capabilities"), capabilitiesDir, { recursive: true });
|
||||
fs.rmSync(path.join(capabilitiesDir, "index.cjs"));
|
||||
fs.rmSync(path.join(capabilitiesDir, "services"), { recursive: true });
|
||||
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(cliDir, "netcatty-tool-cli.cjs"),
|
||||
"capabilities",
|
||||
"--json",
|
||||
], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.ok(payload.capabilities.some((entry) => entry.id === "terminal.execute"));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
787
electron/cli/netcatty-tool-cli.cjs
Normal file
787
electron/cli/netcatty-tool-cli.cjs
Normal file
@@ -0,0 +1,787 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const { connectClient, createError } = require("./netcattyRpcClient.cjs");
|
||||
const {
|
||||
buildCatalogCliParams,
|
||||
formatCliHelpLines,
|
||||
getCliRpcMethod,
|
||||
listCliCapabilities,
|
||||
} = require("../capabilities/adapters/cliAdapter.cjs");
|
||||
const { getCapabilityByCliCommand } = require("../capabilities/registry.cjs");
|
||||
const { CAPABILITY_STATUS } = require("../capabilities/constants.cjs");
|
||||
|
||||
function printHelp() {
|
||||
const catalogLines = formatCliHelpLines().join("\n");
|
||||
process.stdout.write(
|
||||
"Netcatty Tool CLI\n\n" +
|
||||
"Usage:\n" +
|
||||
catalogLines + "\n\n" +
|
||||
"Examples:\n" +
|
||||
" netcatty-tool-cli status --json\n" +
|
||||
" netcatty-tool-cli env --chat-session ai_123 --json\n" +
|
||||
" netcatty-tool-cli attachment list --chat-session ai_123 --json\n" +
|
||||
" netcatty-tool-cli attachment read --filename hosts.csv --chat-session ai_123 --json\n" +
|
||||
" netcatty-tool-cli session --session sess_123 --json --chat-session ai_123\n" +
|
||||
" netcatty-tool-cli exec --session sess_123 --chat-session ai_123 --json -- \"pwd\"\n" +
|
||||
" netcatty-tool-cli vault host get --host-id host_123 --json\n" +
|
||||
" netcatty-tool-cli vault host open --host-id host_123 --json\n" +
|
||||
" netcatty-tool-cli snippets run --snippet-id snip_1 --session sess_123 --chat-session ai_123 --json\n" +
|
||||
" netcatty-tool-cli portforward rules list --json\n\n" +
|
||||
"Notes:\n" +
|
||||
" - Start the Netcatty desktop app before using this CLI.\n" +
|
||||
" - This CLI is intended as an internal Skills + CLI transport, not a general customer-facing shell tool.\n" +
|
||||
" - `env` and `session` always require --chat-session <id>.\n" +
|
||||
" - `exec` always requires both --session <id> and --chat-session <id>.\n" +
|
||||
" - `job-start` always requires both --session <id> and --chat-session <id>.\n" +
|
||||
" - `job-poll` and `job-stop` always require both --job <id> and --chat-session <id>.\n" +
|
||||
" - Every `sftp <op>` always requires both --session <id> and --chat-session <id>, and only works on connected SSH-backed sessions.\n" +
|
||||
" - Vault/portforward/snippet commands use catalog-driven dispatch; see `capabilities --json` for the full list.\n" +
|
||||
" - After `--`, pass exactly one shell-ready command string. Preserve quoting inside that one argument.\n" +
|
||||
" - `cancel` stops in-flight execs, session-backed SFTP transfers, and running jobs for that chat session, then blocks further execs until `resume`.\n",
|
||||
);
|
||||
}
|
||||
|
||||
function toErrorPayload(err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: err?.code || "UNKNOWN_ERROR",
|
||||
message: err?.message || String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readFlagValue(args, index) {
|
||||
return index < args.length ? args[index] : null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const opts = {
|
||||
json: false,
|
||||
chatSessionId: null,
|
||||
scopedSessionIds: [],
|
||||
sessionId: null,
|
||||
jobId: null,
|
||||
offset: null,
|
||||
remotePath: null,
|
||||
localPath: null,
|
||||
oldRemotePath: null,
|
||||
newRemotePath: null,
|
||||
content: null,
|
||||
mode: null,
|
||||
encoding: null,
|
||||
hostId: null,
|
||||
filename: null,
|
||||
snippetId: null,
|
||||
scriptId: null,
|
||||
ruleId: null,
|
||||
notes: null,
|
||||
variables: null,
|
||||
targetGroups: null,
|
||||
multiLineRunMode: null,
|
||||
command: [],
|
||||
};
|
||||
|
||||
const positionals = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "--") {
|
||||
opts.command = args.slice(i + 1);
|
||||
break;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
opts.json = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--chat-session") {
|
||||
opts.chatSessionId = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--scope-session") {
|
||||
const value = readFlagValue(args, i + 1);
|
||||
if (value) opts.scopedSessionIds.push(value);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--session") {
|
||||
opts.sessionId = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--job") {
|
||||
opts.jobId = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--offset") {
|
||||
const value = readFlagValue(args, i + 1);
|
||||
opts.offset = value == null ? null : Number(value);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--remote-path") {
|
||||
opts.remotePath = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--local-path") {
|
||||
opts.localPath = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--old-remote-path") {
|
||||
opts.oldRemotePath = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--new-remote-path") {
|
||||
opts.newRemotePath = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--content") {
|
||||
opts.content = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--mode") {
|
||||
opts.mode = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--encoding") {
|
||||
opts.encoding = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--host-id") {
|
||||
opts.hostId = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--filename") {
|
||||
opts.filename = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--snippet-id") {
|
||||
opts.snippetId = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--script-id") {
|
||||
opts.scriptId = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--rule-id") {
|
||||
opts.ruleId = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--notes") {
|
||||
opts.notes = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--variables") {
|
||||
opts.variables = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--target-groups") {
|
||||
opts.targetGroups = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--multi-line-run-mode") {
|
||||
opts.multiLineRunMode = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
positionals.push(arg);
|
||||
}
|
||||
|
||||
return { positionals, opts };
|
||||
}
|
||||
|
||||
function formatEnvText(ctx) {
|
||||
const header = [
|
||||
`Environment: ${ctx.environment || "netcatty-terminal"}`,
|
||||
`Hosts: ${ctx.hostCount || 0}`,
|
||||
];
|
||||
if (!Array.isArray(ctx.hosts) || ctx.hosts.length === 0) {
|
||||
return `${header.join("\n")}\n\nNo hosts are available in the current scope.\n`;
|
||||
}
|
||||
const rows = ctx.hosts.map((host) => {
|
||||
const details = [
|
||||
host.sessionId,
|
||||
host.label || host.hostname || "(unnamed)",
|
||||
host.protocol || "unknown",
|
||||
host.os || host.deviceType || host.shellType || "unknown",
|
||||
host.connected === false ? "disconnected" : "connected",
|
||||
];
|
||||
return details.join("\t");
|
||||
});
|
||||
return `${header.join("\n")}\n\n${rows.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatExecText(result) {
|
||||
const parts = [];
|
||||
if (result.stdout) parts.push(result.stdout.replace(/\n$/, ""));
|
||||
if (result.stderr) parts.push(`[stderr] ${result.stderr.replace(/\n$/, "")}`);
|
||||
if (result.exitCode != null) parts.push(`[exit code: ${result.exitCode}]`);
|
||||
if (parts.length === 0) {
|
||||
parts.push("[no output]");
|
||||
}
|
||||
return `${parts.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatJobText(result) {
|
||||
const lines = [
|
||||
`Job: ${result.jobId || ""}`,
|
||||
`Session: ${result.sessionId || ""}`,
|
||||
`Status: ${result.status || "unknown"}`,
|
||||
];
|
||||
if (result.startedAt) lines.push(`Started: ${new Date(result.startedAt).toISOString()}`);
|
||||
if (result.updatedAt) lines.push(`Updated: ${new Date(result.updatedAt).toISOString()}`);
|
||||
if (typeof result.exitCode === "number") lines.push(`Exit Code: ${result.exitCode}`);
|
||||
if (result.error) lines.push(`Error: ${result.error}`);
|
||||
const outputText = typeof result.output === "string" ? result.output : "";
|
||||
if (outputText) {
|
||||
lines.push("");
|
||||
lines.push(outputText.replace(/\n$/, ""));
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function buildScopeParams(opts) {
|
||||
const params = {};
|
||||
if (opts.chatSessionId) {
|
||||
params.chatSessionId = opts.chatSessionId;
|
||||
}
|
||||
if (Array.isArray(opts.scopedSessionIds) && opts.scopedSessionIds.length > 0) {
|
||||
params.scopedSessionIds = opts.scopedSessionIds;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function findHostOrThrow(ctx, sessionId) {
|
||||
const host = Array.isArray(ctx?.hosts)
|
||||
? ctx.hosts.find((item) => item.sessionId === sessionId)
|
||||
: null;
|
||||
if (!host) {
|
||||
throw createError("SESSION_NOT_FOUND", `Session "${sessionId}" is not available in the current scope.`);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
async function resolveTargetHost(client, opts) {
|
||||
const ctx = await client.call("netcatty/getContext", buildScopeParams(opts));
|
||||
if (opts.sessionId) {
|
||||
return findHostOrThrow(ctx, opts.sessionId);
|
||||
}
|
||||
throw createError(
|
||||
"INVALID_ARGUMENT",
|
||||
"Missing required --session <id>. Run env --json to inspect available sessions first.",
|
||||
);
|
||||
}
|
||||
|
||||
function getSftpCapabilityError(host) {
|
||||
if (!host) return "SFTP target session is unavailable.";
|
||||
if (host.connected === false) {
|
||||
return `Session "${host.sessionId}" is not connected. Reconnect it before using SFTP.`;
|
||||
}
|
||||
const protocol = String(host.protocol || "").toLowerCase();
|
||||
const deviceType = String(host.deviceType || "").toLowerCase();
|
||||
if (protocol === "ssh") {
|
||||
return null;
|
||||
}
|
||||
if (protocol === "local") {
|
||||
return "SFTP is not available for local sessions. Use normal local filesystem tools instead.";
|
||||
}
|
||||
if (protocol === "mosh") {
|
||||
return "SFTP is not available for Mosh sessions. Open an SSH session for this host or use another transfer path.";
|
||||
}
|
||||
if (protocol === "telnet") {
|
||||
return "SFTP is not available for Telnet sessions. Open an SSH session for this host or use another transfer path.";
|
||||
}
|
||||
if (protocol === "serial" || deviceType === "network") {
|
||||
return "SFTP is not available for serial or network-device sessions. Use exec/vendor CLI commands or another transfer path.";
|
||||
}
|
||||
if (protocol) {
|
||||
return `SFTP is not available for ${protocol} sessions. Open an SSH session for this host or use another transfer path.`;
|
||||
}
|
||||
return "SFTP is only available for connected SSH-backed sessions.";
|
||||
}
|
||||
|
||||
function formatSessionText(host) {
|
||||
const lines = [
|
||||
`Session: ${host.sessionId}`,
|
||||
`Label: ${host.label || "(unnamed)"}`,
|
||||
`Hostname: ${host.hostname || ""}`,
|
||||
`Protocol: ${host.protocol || "unknown"}`,
|
||||
`OS: ${host.os || ""}`,
|
||||
`Username: ${host.username || ""}`,
|
||||
`Shell Type: ${host.shellType || ""}`,
|
||||
`Device Type: ${host.deviceType || ""}`,
|
||||
`Connected: ${host.connected === false ? "false" : "true"}`,
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatStatusText(status) {
|
||||
const lines = [
|
||||
"Netcatty Tool Status",
|
||||
`Permission Mode: ${status.permissionMode || "unknown"}`,
|
||||
`Command Timeout (ms): ${status.commandTimeoutMs ?? "unknown"}`,
|
||||
`Max Iterations: ${status.maxIterations ?? "unknown"}`,
|
||||
`Sessions: ${status.sessionCount ?? 0}`,
|
||||
`Scoped Contexts: ${status.scopedContextCount ?? 0}`,
|
||||
`Active Executions: ${status.activeExecutionCount ?? 0}`,
|
||||
`Active Chat Execution Locks: ${status.activeChatExecutionCount ?? 0}`,
|
||||
`Pending Approvals: ${status.pendingApprovalCount ?? 0}`,
|
||||
`Discovery File: ${status.discoveryFilePath || "(none)"}`,
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatSftpListText(entries) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return "No entries.\n";
|
||||
}
|
||||
const rows = entries.map((entry) => [
|
||||
entry.type || "file",
|
||||
entry.name || "",
|
||||
entry.size || "",
|
||||
entry.permissions || "",
|
||||
entry.lastModified || "",
|
||||
].join("\t"));
|
||||
return `Type\tName\tSize\tPermissions\tModified\n${rows.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function getSingleCommandOrThrow(opts, commandName) {
|
||||
if (!opts.command.length) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing command after --.");
|
||||
}
|
||||
if (opts.command.length !== 1) {
|
||||
throw createError(
|
||||
"INVALID_ARGUMENT",
|
||||
`${commandName} expects exactly one shell-ready command string after --. Preserve quoting in a single argument instead of passing multiple tokens.`,
|
||||
);
|
||||
}
|
||||
return opts.command[0];
|
||||
}
|
||||
|
||||
function ensureBridgeCallOk(result, defaultCode, defaultMessage) {
|
||||
if (!result || result.ok !== false) {
|
||||
return result;
|
||||
}
|
||||
const err = createError(result.code || defaultCode, result.error || defaultMessage);
|
||||
err.details = result;
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const { positionals, opts } = parseArgs(process.argv);
|
||||
const [command, subcommand] = positionals;
|
||||
|
||||
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "capabilities") {
|
||||
const hasStatusFlag = process.argv.includes("--status");
|
||||
const statusArg = hasStatusFlag
|
||||
? process.argv[process.argv.indexOf("--status") + 1]
|
||||
: CAPABILITY_STATUS.IMPLEMENTED;
|
||||
const status = statusArg === "all" ? null : statusArg;
|
||||
const payload = {
|
||||
ok: true,
|
||||
capabilities: listCliCapabilities(
|
||||
hasStatusFlag && statusArg === "all"
|
||||
? { status: null }
|
||||
: { status },
|
||||
),
|
||||
};
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(payload, null, 2)}\n`
|
||||
: `${payload.capabilities.map((entry) => entry.command.join(" ")).join("\n")}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
let client = null;
|
||||
try {
|
||||
client = await connectClient();
|
||||
|
||||
if (command === "status") {
|
||||
const result = await client.call("netcatty/getStatus", {});
|
||||
const output = opts.json ? JSON.stringify(result, null, 2) : formatStatusText(result);
|
||||
process.stdout.write(`${output}${opts.json ? "\n" : ""}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "env") {
|
||||
if (!opts.chatSessionId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for env.");
|
||||
}
|
||||
const params = buildScopeParams(opts);
|
||||
const result = await client.call("netcatty/getContext", params);
|
||||
const output = opts.json ? JSON.stringify({ ok: true, ...result }, null, 2) : formatEnvText(result);
|
||||
process.stdout.write(`${output}${opts.json ? "\n" : ""}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "session") {
|
||||
if (!opts.chatSessionId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for session.");
|
||||
}
|
||||
const host = await resolveTargetHost(client, opts);
|
||||
const payload = { ok: true, host };
|
||||
const output = opts.json ? JSON.stringify(payload, null, 2) : formatSessionText(host);
|
||||
process.stdout.write(`${output}${opts.json ? "\n" : ""}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "exec") {
|
||||
if (!opts.chatSessionId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for exec.");
|
||||
}
|
||||
const shellCommand = getSingleCommandOrThrow(opts, "exec");
|
||||
const host = await resolveTargetHost(client, opts);
|
||||
const rpcParams = {
|
||||
sessionId: host.sessionId,
|
||||
command: shellCommand,
|
||||
chatSessionId: opts.chatSessionId,
|
||||
};
|
||||
const result = await client.call("netcatty/exec", rpcParams);
|
||||
if (result.ok === false) {
|
||||
const err = createError(result.code || "EXEC_FAILED", result.error || "Command failed");
|
||||
err.details = result;
|
||||
throw err;
|
||||
}
|
||||
if (opts.json) {
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(formatExecText(result));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "job-start") {
|
||||
if (!opts.chatSessionId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for job-start.");
|
||||
}
|
||||
const shellCommand = getSingleCommandOrThrow(opts, "job-start");
|
||||
const host = await resolveTargetHost(client, opts);
|
||||
const result = await client.call("netcatty/jobStart", {
|
||||
sessionId: host.sessionId,
|
||||
command: shellCommand,
|
||||
chatSessionId: opts.chatSessionId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw createError(result.code || "JOB_START_FAILED", result.error || "Failed to start long-running command");
|
||||
}
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: formatJobText(result));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "job-poll") {
|
||||
if (!opts.chatSessionId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for job-poll.");
|
||||
}
|
||||
if (!opts.jobId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --job <id> for job-poll.");
|
||||
}
|
||||
const offset = Number.isFinite(opts.offset) && opts.offset >= 0 ? opts.offset : 0;
|
||||
const result = await client.call("netcatty/jobPoll", {
|
||||
jobId: opts.jobId,
|
||||
offset,
|
||||
chatSessionId: opts.chatSessionId,
|
||||
...buildScopeParams(opts),
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw createError(result.code || "JOB_POLL_FAILED", result.error || "Failed to poll long-running command");
|
||||
}
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: formatJobText(result));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "job-stop") {
|
||||
if (!opts.chatSessionId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for job-stop.");
|
||||
}
|
||||
if (!opts.jobId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --job <id> for job-stop.");
|
||||
}
|
||||
const result = await client.call("netcatty/jobStop", {
|
||||
jobId: opts.jobId,
|
||||
chatSessionId: opts.chatSessionId,
|
||||
...buildScopeParams(opts),
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw createError(result.code || "JOB_STOP_FAILED", result.error || "Failed to stop long-running command");
|
||||
}
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: formatJobText(result));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "sftp") {
|
||||
if (!opts.chatSessionId) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for sftp.");
|
||||
}
|
||||
if (!subcommand || subcommand === "help") {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await resolveTargetHost(client, opts);
|
||||
const sftpCapabilityError = getSftpCapabilityError(host);
|
||||
if (sftpCapabilityError) {
|
||||
throw createError("SFTP_UNSUPPORTED_SESSION", sftpCapabilityError);
|
||||
}
|
||||
const buildSftpParams = () => {
|
||||
const params = {
|
||||
sessionId: host.sessionId,
|
||||
chatSessionId: opts.chatSessionId,
|
||||
...buildScopeParams(opts),
|
||||
};
|
||||
if (opts.remotePath) params.remotePath = opts.remotePath;
|
||||
if (opts.localPath) params.localPath = path.resolve(opts.localPath);
|
||||
if (opts.remotePath) params.path = opts.remotePath;
|
||||
if (opts.oldRemotePath) params.oldPath = opts.oldRemotePath;
|
||||
if (opts.newRemotePath) params.newPath = opts.newRemotePath;
|
||||
if (opts.content != null) params.content = opts.content;
|
||||
if (opts.mode) params.mode = opts.mode;
|
||||
if (opts.encoding) params.encoding = opts.encoding;
|
||||
return params;
|
||||
};
|
||||
|
||||
if (subcommand === "list") {
|
||||
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp list.");
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/list", buildSftpParams()),
|
||||
"SFTP_LIST_FAILED",
|
||||
"Failed to list remote directory",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: formatSftpListText(result.entries));
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "read") {
|
||||
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp read.");
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/read", buildSftpParams()),
|
||||
"SFTP_READ_FAILED",
|
||||
"Failed to read remote file",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `${result.content}${result.content?.endsWith("\n") ? "" : "\n"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "write") {
|
||||
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp write.");
|
||||
if (opts.content == null) throw createError("INVALID_ARGUMENT", "Missing required --content <text> for sftp write.");
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/write", buildSftpParams()),
|
||||
"SFTP_WRITE_FAILED",
|
||||
"Failed to write remote file",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `Wrote ${opts.remotePath}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "download") {
|
||||
if (!opts.remotePath || !opts.localPath) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --remote-path and --local-path for sftp download.");
|
||||
}
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/download", buildSftpParams()),
|
||||
"SFTP_DOWNLOAD_FAILED",
|
||||
"Failed to download remote file",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `Downloaded ${opts.remotePath} -> ${opts.localPath}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "upload") {
|
||||
if (!opts.remotePath || !opts.localPath) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --local-path and --remote-path for sftp upload.");
|
||||
}
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/upload", buildSftpParams()),
|
||||
"SFTP_UPLOAD_FAILED",
|
||||
"Failed to upload local file",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `Uploaded ${opts.localPath} -> ${opts.remotePath}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "mkdir") {
|
||||
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp mkdir.");
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/mkdir", buildSftpParams()),
|
||||
"SFTP_MKDIR_FAILED",
|
||||
"Failed to create remote directory",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `Created ${opts.remotePath}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "delete") {
|
||||
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp delete.");
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/delete", buildSftpParams()),
|
||||
"SFTP_DELETE_FAILED",
|
||||
"Failed to delete remote path",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `Deleted ${opts.remotePath}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "rename") {
|
||||
if (!opts.oldRemotePath || !opts.newRemotePath) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --old-remote-path and --new-remote-path for sftp rename.");
|
||||
}
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/rename", buildSftpParams()),
|
||||
"SFTP_RENAME_FAILED",
|
||||
"Failed to rename remote path",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `Renamed ${opts.oldRemotePath} -> ${opts.newRemotePath}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "stat") {
|
||||
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp stat.");
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/stat", buildSftpParams()),
|
||||
"SFTP_STAT_FAILED",
|
||||
"Failed to stat remote path",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `${JSON.stringify(result.stat, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "chmod") {
|
||||
if (!opts.remotePath || !opts.mode) {
|
||||
throw createError("INVALID_ARGUMENT", "Missing required --remote-path and --mode for sftp chmod.");
|
||||
}
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/chmod", buildSftpParams()),
|
||||
"SFTP_CHMOD_FAILED",
|
||||
"Failed to chmod remote path",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `Changed mode of ${opts.remotePath} to ${opts.mode}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "home") {
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call("netcatty/sftp/home", buildSftpParams()),
|
||||
"SFTP_HOME_FAILED",
|
||||
"Failed to resolve remote home directory",
|
||||
);
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: `${result.homeDir}\n`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (command === "cancel" || command === "resume") {
|
||||
if (!opts.chatSessionId) {
|
||||
throw createError("INVALID_ARGUMENT", `Missing required --chat-session <id> for ${command}.`);
|
||||
}
|
||||
const cancelled = command === "cancel";
|
||||
const result = await client.call("netcatty/setCancelled", {
|
||||
chatSessionId: opts.chatSessionId,
|
||||
cancelled,
|
||||
});
|
||||
const payload = { ok: true, ...result };
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(payload, null, 2)}\n`
|
||||
: `Chat session ${opts.chatSessionId} ${cancelled ? "cancelled" : "resumed"}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const catalogCapability = getCapabilityByCliCommand(positionals);
|
||||
if (catalogCapability) {
|
||||
const rpcMethod = getCliRpcMethod(positionals);
|
||||
if (!rpcMethod) {
|
||||
throw createError("INVALID_ARGUMENT", `No RPC mapping for command: ${positionals.join(" ")}`);
|
||||
}
|
||||
if (catalogCapability.policy?.requiresChatSession && !opts.chatSessionId) {
|
||||
throw createError(
|
||||
"INVALID_ARGUMENT",
|
||||
`Missing required --chat-session <id> for ${positionals.join(" ")}.`,
|
||||
);
|
||||
}
|
||||
const params = buildCatalogCliParams(catalogCapability.id, opts, createError);
|
||||
const result = ensureBridgeCallOk(
|
||||
await client.call(rpcMethod, { ...params, ...buildScopeParams(opts) }),
|
||||
"CAPABILITY_RPC_FAILED",
|
||||
`Failed to execute ${catalogCapability.id}`,
|
||||
);
|
||||
const payload = { ok: true, ...result };
|
||||
process.stdout.write(opts.json
|
||||
? `${JSON.stringify(payload, null, 2)}\n`
|
||||
: `${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw createError("INVALID_ARGUMENT", `Unknown command: ${positionals.join(" ")}`);
|
||||
} catch (err) {
|
||||
const payload = toErrorPayload(err);
|
||||
if (err?.details && typeof err.details === "object") {
|
||||
payload.error = {
|
||||
...payload.error,
|
||||
...err.details,
|
||||
};
|
||||
}
|
||||
process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client?.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
run();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseArgs,
|
||||
};
|
||||
25
electron/cli/netcatty-tool-cli.cmd
Normal file
25
electron/cli/netcatty-tool-cli.cmd
Normal file
@@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "CLI_SCRIPT=%SCRIPT_DIR%netcatty-tool-cli.cjs"
|
||||
set "APP_EXE="
|
||||
|
||||
if defined NETCATTY_CLI_ELECTRON_EXEC_PATH if exist "%NETCATTY_CLI_ELECTRON_EXEC_PATH%" set "APP_EXE=%NETCATTY_CLI_ELECTRON_EXEC_PATH%"
|
||||
if not defined APP_EXE if exist "%SCRIPT_DIR%..\..\..\..\Netcatty.exe" set "APP_EXE=%SCRIPT_DIR%..\..\..\..\Netcatty.exe"
|
||||
if not defined APP_EXE if exist "%SCRIPT_DIR%..\..\..\..\netcatty.exe" set "APP_EXE=%SCRIPT_DIR%..\..\..\..\netcatty.exe"
|
||||
|
||||
if defined APP_EXE (
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXE%" "%CLI_SCRIPT%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
where node >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
node "%CLI_SCRIPT%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
echo Failed to locate the bundled Netcatty runtime for netcatty-tool-cli. 1>&2
|
||||
exit /b 1
|
||||
64
electron/cli/netcatty-tool-cli.test.cjs
Normal file
64
electron/cli/netcatty-tool-cli.test.cjs
Normal file
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { parseArgs } = require("./netcatty-tool-cli.cjs");
|
||||
|
||||
test("parseArgs consumes attachment filename flag", () => {
|
||||
const { positionals, opts } = parseArgs([
|
||||
"node",
|
||||
"netcatty-tool-cli",
|
||||
"attachment",
|
||||
"read",
|
||||
"--filename",
|
||||
"hosts.csv",
|
||||
"--chat-session",
|
||||
"chat-1",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert.deepEqual(positionals, ["attachment", "read"]);
|
||||
assert.equal(opts.filename, "hosts.csv");
|
||||
assert.equal(opts.chatSessionId, "chat-1");
|
||||
assert.equal(opts.json, true);
|
||||
});
|
||||
|
||||
test("parseArgs consumes snippet multi-line run mode flag", () => {
|
||||
const { positionals, opts } = parseArgs([
|
||||
"node",
|
||||
"netcatty-tool-cli",
|
||||
"snippets",
|
||||
"update",
|
||||
"--snippet-id",
|
||||
"snippet-1",
|
||||
"--multi-line-run-mode",
|
||||
"lineDelay",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert.deepEqual(positionals, ["snippets", "update"]);
|
||||
assert.equal(opts.snippetId, "snippet-1");
|
||||
assert.equal(opts.multiLineRunMode, "lineDelay");
|
||||
assert.equal(opts.json, true);
|
||||
});
|
||||
|
||||
test("parseArgs consumes dynamic script group targets", () => {
|
||||
const { positionals, opts } = parseArgs([
|
||||
"node",
|
||||
"netcatty-tool-cli",
|
||||
"scripts",
|
||||
"targets",
|
||||
"set",
|
||||
"--script-id",
|
||||
"script-1",
|
||||
"--target-groups",
|
||||
'["Production","Staging/Web"]',
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert.deepEqual(positionals, ["scripts", "targets", "set"]);
|
||||
assert.equal(opts.scriptId, "script-1");
|
||||
assert.equal(opts.targetGroups, '["Production","Staging/Web"]');
|
||||
assert.equal(opts.json, true);
|
||||
});
|
||||
101
electron/cli/netcattyRpcClient.cjs
Normal file
101
electron/cli/netcattyRpcClient.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
|
||||
const { getCliDiscoveryFilePath } = require("./discoveryPath.cjs");
|
||||
const { CAPABILITY_SURFACES } = require("../capabilities/constants.cjs");
|
||||
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
|
||||
|
||||
function createError(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
function loadDiscovery() {
|
||||
const discoveryPath = getCliDiscoveryFilePath();
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(discoveryPath, "utf8");
|
||||
} catch (err) {
|
||||
throw createError(
|
||||
"APP_NOT_RUNNING",
|
||||
`Netcatty is not running or discovery file is missing at ${discoveryPath}. Start Netcatty first.`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw createError(
|
||||
"DISCOVERY_INVALID",
|
||||
`Netcatty discovery file at ${discoveryPath} is invalid JSON.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed?.port || !parsed?.token) {
|
||||
throw createError(
|
||||
"DISCOVERY_INVALID",
|
||||
`Netcatty discovery file at ${discoveryPath} is missing required port/token fields.`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function connectClient() {
|
||||
const discovery = loadDiscovery();
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const sock = net.createConnection({ host: "127.0.0.1", port: discovery.port }, () => resolve(sock));
|
||||
sock.setEncoding("utf8");
|
||||
sock.once("error", (err) => {
|
||||
reject(createError("CONNECT_FAILED", `Failed to connect to Netcatty TCP bridge: ${err?.message || err}`));
|
||||
});
|
||||
});
|
||||
|
||||
const client = createNdjsonRpcClient({
|
||||
socket,
|
||||
surface: CAPABILITY_SURFACES.BUILTIN,
|
||||
createError,
|
||||
messages: {
|
||||
connectionClosed: "Connection to Netcatty TCP bridge closed.",
|
||||
connectionClosedWhileCall: "Connection to Netcatty TCP bridge is closed.",
|
||||
connectionError: (error) => `Connection to Netcatty TCP bridge failed: ${error?.message || error}`,
|
||||
rpcTimeout: (method, timeoutMs) => (
|
||||
`Timed out waiting for Netcatty RPC response to "${method}" after ${timeoutMs}ms.`
|
||||
),
|
||||
writeFailed: (method, error) => (
|
||||
`Failed to send Netcatty RPC "${method}": ${error?.message || error}`
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const authResult = await client.call("auth/verify", { token: discovery.token });
|
||||
if (!authResult?.ok) {
|
||||
throw createError("AUTH_FAILED", "Failed to authenticate to Netcatty TCP bridge.");
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResult = await client.call("netcatty/getStatus", {});
|
||||
client.ingestBridgeStatus(statusResult);
|
||||
} catch {
|
||||
// Keep the default RPC timeout when bridge status cannot be fetched.
|
||||
}
|
||||
|
||||
return {
|
||||
discovery,
|
||||
async call(method, params) {
|
||||
return await client.call(method, params);
|
||||
},
|
||||
close() {
|
||||
client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
connectClient,
|
||||
createError,
|
||||
};
|
||||
Reference in New Issue
Block a user