mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-16 13:53:15 +08:00
Restore missing dsh-netxops host bridge after dirty reinstalls (0.1.53).
Preset-only self-heal left tools waiting forever when the bundle host row was dropped; insert the full host block into cordis.patch.yml and require one restart. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e2e65b0324
commit
777589b702
12 changed files with 1623 additions and 73 deletions
|
|
@ -44,6 +44,7 @@ bun run link:peers
|
||||||
| `Cannot find package '@deepseek-ai/schemastery'` / **netxops 未运行** | 未跑 `link:peers` |
|
| `Cannot find package '@deepseek-ai/schemastery'` / **netxops 未运行** | 未跑 `link:peers` |
|
||||||
| **web boot: dsh-netxops failed** | peers 版本错乱(例如链了 Desktop 的 schemastery 3.18.4)→ 重跑 `bun run link:peers` 再重启 |
|
| **web boot: dsh-netxops failed** | peers 版本错乱(例如链了 Desktop 的 schemastery 3.18.4)→ 重跑 `bun run link:peers` 再重启 |
|
||||||
| `pending (waiting for service: settingsScope)` → 会话无 `netx__*` / 无 `netx-ops` | 旧客户端仍硬/软等待已删除的 `settingsScope`(DSH 0.2);用 **≥0.1.50** 的 tgz 重装并完全重启 Desktop,勿用脏的 `link:` / 混锁 `.modules.yaml` |
|
| `pending (waiting for service: settingsScope)` → 会话无 `netx__*` / 无 `netx-ops` | 旧客户端仍硬/软等待已删除的 `settingsScope`(DSH 0.2);用 **≥0.1.50** 的 tgz 重装并完全重启 Desktop,勿用脏的 `link:` / 混锁 `.modules.yaml` |
|
||||||
|
| 日志 `no connection yet` / 会话无 `netx__*`,且 `profiles/*/cordis.yml` 只有 `preset-netxops`、**没有** `name: dsh-netxops` | 脏重装后 bundle 宿主行丢失、预设通道自愈不同步。**≥0.1.53** 会在预设 tools 启动时把宿主块写回 `cordis.patch.yml`;看日志 `host bridge … MISSING … wrote it back` 后**完全退出再开** Desktop/`dsh web`。仍不行则 `dsh plugin remove dsh-netxops` 再 `add` |
|
||||||
|
|
||||||
改 Host/Client 后照常 `bun run bundle`,一般不用重装;换机器或清过本仓库 `node_modules` 才需再 `link:peers`。
|
改 Host/Client 后照常 `bun run bundle`,一般不用重装;换机器或清过本仓库 `node_modules` 才需再 `link:peers`。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/netx/heal-profile-entry.ts
|
||||||
|
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join as join5 } from "node:path";
|
||||||
|
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
|
||||||
|
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
|
||||||
|
var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted");
|
||||||
|
var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
|
||||||
|
apiUrl: "http://127.0.0.1:8890",
|
||||||
|
lang: "zh",
|
||||||
|
thinkingLanguage: "auto",
|
||||||
|
replyLanguage: "follow-user",
|
||||||
|
nmsProvider: "zte-ume",
|
||||||
|
tokenCredentialRef: "NETX_API_TOKEN",
|
||||||
|
toolCallTimeoutMs: 120000,
|
||||||
|
installAgentPreset: true,
|
||||||
|
alarmPushEnabled: false,
|
||||||
|
alarmDeliverDsh: true,
|
||||||
|
alarmDeliverIm: false,
|
||||||
|
imBotId: "",
|
||||||
|
imTargetId: "",
|
||||||
|
imTargets: "",
|
||||||
|
groupOpsInPreset: true,
|
||||||
|
groupOpsPublic: true,
|
||||||
|
groupTopologyInPreset: false,
|
||||||
|
groupTopologyPublic: false,
|
||||||
|
groupBizMonitorInPreset: true,
|
||||||
|
groupBizMonitorPublic: false,
|
||||||
|
kbRoot: "",
|
||||||
|
groupKbInPreset: true,
|
||||||
|
groupKbPublic: false
|
||||||
|
});
|
||||||
|
function resolveDshHome() {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
|
if (fromEnv !== undefined && fromEnv.length > 0)
|
||||||
|
return fromEnv;
|
||||||
|
return join5(homedir(), ".dsh");
|
||||||
|
}
|
||||||
|
function managedLineMask(lines) {
|
||||||
|
const skip = lines.map(() => false);
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes(DESKTOP_PATCH_BEGIN))
|
||||||
|
inside = true;
|
||||||
|
if (inside)
|
||||||
|
skip[i] = true;
|
||||||
|
if (line.includes(DESKTOP_PATCH_END))
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
|
return skip;
|
||||||
|
}
|
||||||
|
function parseScalar(raw) {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (text === "true")
|
||||||
|
return true;
|
||||||
|
if (text === "false")
|
||||||
|
return false;
|
||||||
|
if (/^-?\d+$/.test(text))
|
||||||
|
return Number(text);
|
||||||
|
if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
function formatScalar(value) {
|
||||||
|
if (typeof value === "boolean" || typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
if (value === "")
|
||||||
|
return "''";
|
||||||
|
if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function findHostEntry(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (lines[i] !== "- id: netxops")
|
||||||
|
continue;
|
||||||
|
let end = i + 1;
|
||||||
|
while (end < lines.length) {
|
||||||
|
const line = lines[end];
|
||||||
|
if (skip[end])
|
||||||
|
break;
|
||||||
|
if (/^- id:/.test(line))
|
||||||
|
break;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
while (end > i + 1 && lines[end - 1].trim() === "")
|
||||||
|
end -= 1;
|
||||||
|
let disabled;
|
||||||
|
let name;
|
||||||
|
const config = {};
|
||||||
|
let inConfig = false;
|
||||||
|
for (let j = i + 1;j < end; j += 1) {
|
||||||
|
const line = lines[j];
|
||||||
|
const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (disabledMatch) {
|
||||||
|
disabled = parseScalar(disabledMatch[1]) === true;
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nameMatch = /^ name:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (nameMatch) {
|
||||||
|
name = String(parseScalar(nameMatch[1]));
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ config:\s*$/.test(line)) {
|
||||||
|
inConfig = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inConfig) {
|
||||||
|
const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line);
|
||||||
|
if (cfg) {
|
||||||
|
config[cfg[1]] = parseScalar(cfg[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ \S/.test(line))
|
||||||
|
inConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: i, end, disabled, name, config };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hostBridgeNamedElsewhere(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function needsHeal(entry) {
|
||||||
|
if (entry.name !== "dsh-netxops")
|
||||||
|
return true;
|
||||||
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||||
|
if (!Object.hasOwn(entry.config, key))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function renderHostEntry(entry) {
|
||||||
|
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config };
|
||||||
|
const out = [
|
||||||
|
"- id: netxops",
|
||||||
|
" name: dsh-netxops"
|
||||||
|
];
|
||||||
|
if (entry.disabled === true)
|
||||||
|
out.push(" disabled: true");
|
||||||
|
else
|
||||||
|
out.push(" disabled: false");
|
||||||
|
out.push(" config:");
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
out.push(` ${key}: ${formatScalar(value)}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function ensureNetxopsHostInProfilePatches(logger) {
|
||||||
|
let rewritten = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
for (const profileName of ["desktop", "web"]) {
|
||||||
|
const profileDir = join5(resolveDshHome(), "profiles", profileName);
|
||||||
|
const patchPath = join5(profileDir, "cordis.patch.yml");
|
||||||
|
if (!existsSync4(profileDir))
|
||||||
|
continue;
|
||||||
|
const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : "";
|
||||||
|
const nl = raw.includes(`\r
|
||||||
|
`) ? `\r
|
||||||
|
` : `
|
||||||
|
`;
|
||||||
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, `
|
||||||
|
`).split(`
|
||||||
|
`) : [];
|
||||||
|
const skip = managedLineMask(lines);
|
||||||
|
const entry = findHostEntry(lines, skip);
|
||||||
|
let nextLines = null;
|
||||||
|
let kind = null;
|
||||||
|
if (entry === null) {
|
||||||
|
if (hostBridgeNamedElsewhere(lines, skip))
|
||||||
|
continue;
|
||||||
|
const rendered = renderHostEntry({ disabled: false, config: {} });
|
||||||
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN));
|
||||||
|
if (beginIdx >= 0) {
|
||||||
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)];
|
||||||
|
} else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") {
|
||||||
|
nextLines = [...rendered, ""];
|
||||||
|
} else {
|
||||||
|
const body = [...lines];
|
||||||
|
while (body.length > 0 && body[body.length - 1].trim() === "")
|
||||||
|
body.pop();
|
||||||
|
nextLines = [...body, "", ...rendered, ""];
|
||||||
|
}
|
||||||
|
kind = "insert";
|
||||||
|
} else if (needsHeal(entry)) {
|
||||||
|
nextLines = [
|
||||||
|
...lines.slice(0, entry.start),
|
||||||
|
...renderHostEntry(entry),
|
||||||
|
...lines.slice(entry.end)
|
||||||
|
];
|
||||||
|
kind = "rewrite";
|
||||||
|
}
|
||||||
|
if (nextLines === null || kind === null)
|
||||||
|
continue;
|
||||||
|
const normalized = nextLines.join(nl);
|
||||||
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`;
|
||||||
|
if (withNl === raw)
|
||||||
|
continue;
|
||||||
|
writeFileSync2(patchPath, withNl, "utf8");
|
||||||
|
if (kind === "insert") {
|
||||||
|
inserted += 1;
|
||||||
|
logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
} else {
|
||||||
|
rewritten += 1;
|
||||||
|
logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rewritten, inserted };
|
||||||
|
}
|
||||||
|
function tryRecoverMissingHostBridge(logger, pluginName) {
|
||||||
|
const root = globalThis;
|
||||||
|
if (root[HOST_HEAL_ONCE] === true)
|
||||||
|
return;
|
||||||
|
root[HOST_HEAL_ONCE] = true;
|
||||||
|
try {
|
||||||
|
const result = ensureNetxopsHostInProfilePatches(logger);
|
||||||
|
if (result.inserted === 0 && result.rewritten === 0) {
|
||||||
|
logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("%s: host-bridge recover failed: %s", pluginName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/runtime.ts
|
||||||
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
function store2() {
|
function store2() {
|
||||||
|
|
@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
unregisterTools = undefined;
|
unregisterTools = undefined;
|
||||||
const connection = getNetxConnection();
|
const connection = getNetxConnection();
|
||||||
if (connection === undefined) {
|
if (connection === undefined) {
|
||||||
ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name);
|
tryRecoverMissingHostBridge(ctx.logger, options.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const enabled = resolveGroups();
|
const enabled = resolveGroups();
|
||||||
|
|
|
||||||
|
|
@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/netx/heal-profile-entry.ts
|
||||||
|
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join as join5 } from "node:path";
|
||||||
|
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
|
||||||
|
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
|
||||||
|
var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted");
|
||||||
|
var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
|
||||||
|
apiUrl: "http://127.0.0.1:8890",
|
||||||
|
lang: "zh",
|
||||||
|
thinkingLanguage: "auto",
|
||||||
|
replyLanguage: "follow-user",
|
||||||
|
nmsProvider: "zte-ume",
|
||||||
|
tokenCredentialRef: "NETX_API_TOKEN",
|
||||||
|
toolCallTimeoutMs: 120000,
|
||||||
|
installAgentPreset: true,
|
||||||
|
alarmPushEnabled: false,
|
||||||
|
alarmDeliverDsh: true,
|
||||||
|
alarmDeliverIm: false,
|
||||||
|
imBotId: "",
|
||||||
|
imTargetId: "",
|
||||||
|
imTargets: "",
|
||||||
|
groupOpsInPreset: true,
|
||||||
|
groupOpsPublic: true,
|
||||||
|
groupTopologyInPreset: false,
|
||||||
|
groupTopologyPublic: false,
|
||||||
|
groupBizMonitorInPreset: true,
|
||||||
|
groupBizMonitorPublic: false,
|
||||||
|
kbRoot: "",
|
||||||
|
groupKbInPreset: true,
|
||||||
|
groupKbPublic: false
|
||||||
|
});
|
||||||
|
function resolveDshHome() {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
|
if (fromEnv !== undefined && fromEnv.length > 0)
|
||||||
|
return fromEnv;
|
||||||
|
return join5(homedir(), ".dsh");
|
||||||
|
}
|
||||||
|
function managedLineMask(lines) {
|
||||||
|
const skip = lines.map(() => false);
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes(DESKTOP_PATCH_BEGIN))
|
||||||
|
inside = true;
|
||||||
|
if (inside)
|
||||||
|
skip[i] = true;
|
||||||
|
if (line.includes(DESKTOP_PATCH_END))
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
|
return skip;
|
||||||
|
}
|
||||||
|
function parseScalar(raw) {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (text === "true")
|
||||||
|
return true;
|
||||||
|
if (text === "false")
|
||||||
|
return false;
|
||||||
|
if (/^-?\d+$/.test(text))
|
||||||
|
return Number(text);
|
||||||
|
if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
function formatScalar(value) {
|
||||||
|
if (typeof value === "boolean" || typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
if (value === "")
|
||||||
|
return "''";
|
||||||
|
if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function findHostEntry(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (lines[i] !== "- id: netxops")
|
||||||
|
continue;
|
||||||
|
let end = i + 1;
|
||||||
|
while (end < lines.length) {
|
||||||
|
const line = lines[end];
|
||||||
|
if (skip[end])
|
||||||
|
break;
|
||||||
|
if (/^- id:/.test(line))
|
||||||
|
break;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
while (end > i + 1 && lines[end - 1].trim() === "")
|
||||||
|
end -= 1;
|
||||||
|
let disabled;
|
||||||
|
let name;
|
||||||
|
const config = {};
|
||||||
|
let inConfig = false;
|
||||||
|
for (let j = i + 1;j < end; j += 1) {
|
||||||
|
const line = lines[j];
|
||||||
|
const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (disabledMatch) {
|
||||||
|
disabled = parseScalar(disabledMatch[1]) === true;
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nameMatch = /^ name:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (nameMatch) {
|
||||||
|
name = String(parseScalar(nameMatch[1]));
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ config:\s*$/.test(line)) {
|
||||||
|
inConfig = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inConfig) {
|
||||||
|
const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line);
|
||||||
|
if (cfg) {
|
||||||
|
config[cfg[1]] = parseScalar(cfg[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ \S/.test(line))
|
||||||
|
inConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: i, end, disabled, name, config };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hostBridgeNamedElsewhere(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function needsHeal(entry) {
|
||||||
|
if (entry.name !== "dsh-netxops")
|
||||||
|
return true;
|
||||||
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||||
|
if (!Object.hasOwn(entry.config, key))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function renderHostEntry(entry) {
|
||||||
|
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config };
|
||||||
|
const out = [
|
||||||
|
"- id: netxops",
|
||||||
|
" name: dsh-netxops"
|
||||||
|
];
|
||||||
|
if (entry.disabled === true)
|
||||||
|
out.push(" disabled: true");
|
||||||
|
else
|
||||||
|
out.push(" disabled: false");
|
||||||
|
out.push(" config:");
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
out.push(` ${key}: ${formatScalar(value)}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function ensureNetxopsHostInProfilePatches(logger) {
|
||||||
|
let rewritten = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
for (const profileName of ["desktop", "web"]) {
|
||||||
|
const profileDir = join5(resolveDshHome(), "profiles", profileName);
|
||||||
|
const patchPath = join5(profileDir, "cordis.patch.yml");
|
||||||
|
if (!existsSync4(profileDir))
|
||||||
|
continue;
|
||||||
|
const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : "";
|
||||||
|
const nl = raw.includes(`\r
|
||||||
|
`) ? `\r
|
||||||
|
` : `
|
||||||
|
`;
|
||||||
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, `
|
||||||
|
`).split(`
|
||||||
|
`) : [];
|
||||||
|
const skip = managedLineMask(lines);
|
||||||
|
const entry = findHostEntry(lines, skip);
|
||||||
|
let nextLines = null;
|
||||||
|
let kind = null;
|
||||||
|
if (entry === null) {
|
||||||
|
if (hostBridgeNamedElsewhere(lines, skip))
|
||||||
|
continue;
|
||||||
|
const rendered = renderHostEntry({ disabled: false, config: {} });
|
||||||
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN));
|
||||||
|
if (beginIdx >= 0) {
|
||||||
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)];
|
||||||
|
} else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") {
|
||||||
|
nextLines = [...rendered, ""];
|
||||||
|
} else {
|
||||||
|
const body = [...lines];
|
||||||
|
while (body.length > 0 && body[body.length - 1].trim() === "")
|
||||||
|
body.pop();
|
||||||
|
nextLines = [...body, "", ...rendered, ""];
|
||||||
|
}
|
||||||
|
kind = "insert";
|
||||||
|
} else if (needsHeal(entry)) {
|
||||||
|
nextLines = [
|
||||||
|
...lines.slice(0, entry.start),
|
||||||
|
...renderHostEntry(entry),
|
||||||
|
...lines.slice(entry.end)
|
||||||
|
];
|
||||||
|
kind = "rewrite";
|
||||||
|
}
|
||||||
|
if (nextLines === null || kind === null)
|
||||||
|
continue;
|
||||||
|
const normalized = nextLines.join(nl);
|
||||||
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`;
|
||||||
|
if (withNl === raw)
|
||||||
|
continue;
|
||||||
|
writeFileSync2(patchPath, withNl, "utf8");
|
||||||
|
if (kind === "insert") {
|
||||||
|
inserted += 1;
|
||||||
|
logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
} else {
|
||||||
|
rewritten += 1;
|
||||||
|
logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rewritten, inserted };
|
||||||
|
}
|
||||||
|
function tryRecoverMissingHostBridge(logger, pluginName) {
|
||||||
|
const root = globalThis;
|
||||||
|
if (root[HOST_HEAL_ONCE] === true)
|
||||||
|
return;
|
||||||
|
root[HOST_HEAL_ONCE] = true;
|
||||||
|
try {
|
||||||
|
const result = ensureNetxopsHostInProfilePatches(logger);
|
||||||
|
if (result.inserted === 0 && result.rewritten === 0) {
|
||||||
|
logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("%s: host-bridge recover failed: %s", pluginName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/runtime.ts
|
||||||
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
function store2() {
|
function store2() {
|
||||||
|
|
@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
unregisterTools = undefined;
|
unregisterTools = undefined;
|
||||||
const connection = getNetxConnection();
|
const connection = getNetxConnection();
|
||||||
if (connection === undefined) {
|
if (connection === undefined) {
|
||||||
ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name);
|
tryRecoverMissingHostBridge(ctx.logger, options.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const enabled = resolveGroups();
|
const enabled = resolveGroups();
|
||||||
|
|
|
||||||
|
|
@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/netx/heal-profile-entry.ts
|
||||||
|
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join as join5 } from "node:path";
|
||||||
|
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
|
||||||
|
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
|
||||||
|
var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted");
|
||||||
|
var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
|
||||||
|
apiUrl: "http://127.0.0.1:8890",
|
||||||
|
lang: "zh",
|
||||||
|
thinkingLanguage: "auto",
|
||||||
|
replyLanguage: "follow-user",
|
||||||
|
nmsProvider: "zte-ume",
|
||||||
|
tokenCredentialRef: "NETX_API_TOKEN",
|
||||||
|
toolCallTimeoutMs: 120000,
|
||||||
|
installAgentPreset: true,
|
||||||
|
alarmPushEnabled: false,
|
||||||
|
alarmDeliverDsh: true,
|
||||||
|
alarmDeliverIm: false,
|
||||||
|
imBotId: "",
|
||||||
|
imTargetId: "",
|
||||||
|
imTargets: "",
|
||||||
|
groupOpsInPreset: true,
|
||||||
|
groupOpsPublic: true,
|
||||||
|
groupTopologyInPreset: false,
|
||||||
|
groupTopologyPublic: false,
|
||||||
|
groupBizMonitorInPreset: true,
|
||||||
|
groupBizMonitorPublic: false,
|
||||||
|
kbRoot: "",
|
||||||
|
groupKbInPreset: true,
|
||||||
|
groupKbPublic: false
|
||||||
|
});
|
||||||
|
function resolveDshHome() {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
|
if (fromEnv !== undefined && fromEnv.length > 0)
|
||||||
|
return fromEnv;
|
||||||
|
return join5(homedir(), ".dsh");
|
||||||
|
}
|
||||||
|
function managedLineMask(lines) {
|
||||||
|
const skip = lines.map(() => false);
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes(DESKTOP_PATCH_BEGIN))
|
||||||
|
inside = true;
|
||||||
|
if (inside)
|
||||||
|
skip[i] = true;
|
||||||
|
if (line.includes(DESKTOP_PATCH_END))
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
|
return skip;
|
||||||
|
}
|
||||||
|
function parseScalar(raw) {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (text === "true")
|
||||||
|
return true;
|
||||||
|
if (text === "false")
|
||||||
|
return false;
|
||||||
|
if (/^-?\d+$/.test(text))
|
||||||
|
return Number(text);
|
||||||
|
if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
function formatScalar(value) {
|
||||||
|
if (typeof value === "boolean" || typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
if (value === "")
|
||||||
|
return "''";
|
||||||
|
if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function findHostEntry(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (lines[i] !== "- id: netxops")
|
||||||
|
continue;
|
||||||
|
let end = i + 1;
|
||||||
|
while (end < lines.length) {
|
||||||
|
const line = lines[end];
|
||||||
|
if (skip[end])
|
||||||
|
break;
|
||||||
|
if (/^- id:/.test(line))
|
||||||
|
break;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
while (end > i + 1 && lines[end - 1].trim() === "")
|
||||||
|
end -= 1;
|
||||||
|
let disabled;
|
||||||
|
let name;
|
||||||
|
const config = {};
|
||||||
|
let inConfig = false;
|
||||||
|
for (let j = i + 1;j < end; j += 1) {
|
||||||
|
const line = lines[j];
|
||||||
|
const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (disabledMatch) {
|
||||||
|
disabled = parseScalar(disabledMatch[1]) === true;
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nameMatch = /^ name:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (nameMatch) {
|
||||||
|
name = String(parseScalar(nameMatch[1]));
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ config:\s*$/.test(line)) {
|
||||||
|
inConfig = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inConfig) {
|
||||||
|
const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line);
|
||||||
|
if (cfg) {
|
||||||
|
config[cfg[1]] = parseScalar(cfg[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ \S/.test(line))
|
||||||
|
inConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: i, end, disabled, name, config };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hostBridgeNamedElsewhere(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function needsHeal(entry) {
|
||||||
|
if (entry.name !== "dsh-netxops")
|
||||||
|
return true;
|
||||||
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||||
|
if (!Object.hasOwn(entry.config, key))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function renderHostEntry(entry) {
|
||||||
|
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config };
|
||||||
|
const out = [
|
||||||
|
"- id: netxops",
|
||||||
|
" name: dsh-netxops"
|
||||||
|
];
|
||||||
|
if (entry.disabled === true)
|
||||||
|
out.push(" disabled: true");
|
||||||
|
else
|
||||||
|
out.push(" disabled: false");
|
||||||
|
out.push(" config:");
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
out.push(` ${key}: ${formatScalar(value)}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function ensureNetxopsHostInProfilePatches(logger) {
|
||||||
|
let rewritten = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
for (const profileName of ["desktop", "web"]) {
|
||||||
|
const profileDir = join5(resolveDshHome(), "profiles", profileName);
|
||||||
|
const patchPath = join5(profileDir, "cordis.patch.yml");
|
||||||
|
if (!existsSync4(profileDir))
|
||||||
|
continue;
|
||||||
|
const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : "";
|
||||||
|
const nl = raw.includes(`\r
|
||||||
|
`) ? `\r
|
||||||
|
` : `
|
||||||
|
`;
|
||||||
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, `
|
||||||
|
`).split(`
|
||||||
|
`) : [];
|
||||||
|
const skip = managedLineMask(lines);
|
||||||
|
const entry = findHostEntry(lines, skip);
|
||||||
|
let nextLines = null;
|
||||||
|
let kind = null;
|
||||||
|
if (entry === null) {
|
||||||
|
if (hostBridgeNamedElsewhere(lines, skip))
|
||||||
|
continue;
|
||||||
|
const rendered = renderHostEntry({ disabled: false, config: {} });
|
||||||
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN));
|
||||||
|
if (beginIdx >= 0) {
|
||||||
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)];
|
||||||
|
} else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") {
|
||||||
|
nextLines = [...rendered, ""];
|
||||||
|
} else {
|
||||||
|
const body = [...lines];
|
||||||
|
while (body.length > 0 && body[body.length - 1].trim() === "")
|
||||||
|
body.pop();
|
||||||
|
nextLines = [...body, "", ...rendered, ""];
|
||||||
|
}
|
||||||
|
kind = "insert";
|
||||||
|
} else if (needsHeal(entry)) {
|
||||||
|
nextLines = [
|
||||||
|
...lines.slice(0, entry.start),
|
||||||
|
...renderHostEntry(entry),
|
||||||
|
...lines.slice(entry.end)
|
||||||
|
];
|
||||||
|
kind = "rewrite";
|
||||||
|
}
|
||||||
|
if (nextLines === null || kind === null)
|
||||||
|
continue;
|
||||||
|
const normalized = nextLines.join(nl);
|
||||||
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`;
|
||||||
|
if (withNl === raw)
|
||||||
|
continue;
|
||||||
|
writeFileSync2(patchPath, withNl, "utf8");
|
||||||
|
if (kind === "insert") {
|
||||||
|
inserted += 1;
|
||||||
|
logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
} else {
|
||||||
|
rewritten += 1;
|
||||||
|
logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rewritten, inserted };
|
||||||
|
}
|
||||||
|
function tryRecoverMissingHostBridge(logger, pluginName) {
|
||||||
|
const root = globalThis;
|
||||||
|
if (root[HOST_HEAL_ONCE] === true)
|
||||||
|
return;
|
||||||
|
root[HOST_HEAL_ONCE] = true;
|
||||||
|
try {
|
||||||
|
const result = ensureNetxopsHostInProfilePatches(logger);
|
||||||
|
if (result.inserted === 0 && result.rewritten === 0) {
|
||||||
|
logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("%s: host-bridge recover failed: %s", pluginName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/runtime.ts
|
||||||
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
function store2() {
|
function store2() {
|
||||||
|
|
@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
unregisterTools = undefined;
|
unregisterTools = undefined;
|
||||||
const connection = getNetxConnection();
|
const connection = getNetxConnection();
|
||||||
if (connection === undefined) {
|
if (connection === undefined) {
|
||||||
ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name);
|
tryRecoverMissingHostBridge(ctx.logger, options.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const enabled = resolveGroups();
|
const enabled = resolveGroups();
|
||||||
|
|
|
||||||
|
|
@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/netx/heal-profile-entry.ts
|
||||||
|
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join as join5 } from "node:path";
|
||||||
|
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
|
||||||
|
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
|
||||||
|
var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted");
|
||||||
|
var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
|
||||||
|
apiUrl: "http://127.0.0.1:8890",
|
||||||
|
lang: "zh",
|
||||||
|
thinkingLanguage: "auto",
|
||||||
|
replyLanguage: "follow-user",
|
||||||
|
nmsProvider: "zte-ume",
|
||||||
|
tokenCredentialRef: "NETX_API_TOKEN",
|
||||||
|
toolCallTimeoutMs: 120000,
|
||||||
|
installAgentPreset: true,
|
||||||
|
alarmPushEnabled: false,
|
||||||
|
alarmDeliverDsh: true,
|
||||||
|
alarmDeliverIm: false,
|
||||||
|
imBotId: "",
|
||||||
|
imTargetId: "",
|
||||||
|
imTargets: "",
|
||||||
|
groupOpsInPreset: true,
|
||||||
|
groupOpsPublic: true,
|
||||||
|
groupTopologyInPreset: false,
|
||||||
|
groupTopologyPublic: false,
|
||||||
|
groupBizMonitorInPreset: true,
|
||||||
|
groupBizMonitorPublic: false,
|
||||||
|
kbRoot: "",
|
||||||
|
groupKbInPreset: true,
|
||||||
|
groupKbPublic: false
|
||||||
|
});
|
||||||
|
function resolveDshHome() {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
|
if (fromEnv !== undefined && fromEnv.length > 0)
|
||||||
|
return fromEnv;
|
||||||
|
return join5(homedir(), ".dsh");
|
||||||
|
}
|
||||||
|
function managedLineMask(lines) {
|
||||||
|
const skip = lines.map(() => false);
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes(DESKTOP_PATCH_BEGIN))
|
||||||
|
inside = true;
|
||||||
|
if (inside)
|
||||||
|
skip[i] = true;
|
||||||
|
if (line.includes(DESKTOP_PATCH_END))
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
|
return skip;
|
||||||
|
}
|
||||||
|
function parseScalar(raw) {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (text === "true")
|
||||||
|
return true;
|
||||||
|
if (text === "false")
|
||||||
|
return false;
|
||||||
|
if (/^-?\d+$/.test(text))
|
||||||
|
return Number(text);
|
||||||
|
if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
function formatScalar(value) {
|
||||||
|
if (typeof value === "boolean" || typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
if (value === "")
|
||||||
|
return "''";
|
||||||
|
if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function findHostEntry(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (lines[i] !== "- id: netxops")
|
||||||
|
continue;
|
||||||
|
let end = i + 1;
|
||||||
|
while (end < lines.length) {
|
||||||
|
const line = lines[end];
|
||||||
|
if (skip[end])
|
||||||
|
break;
|
||||||
|
if (/^- id:/.test(line))
|
||||||
|
break;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
while (end > i + 1 && lines[end - 1].trim() === "")
|
||||||
|
end -= 1;
|
||||||
|
let disabled;
|
||||||
|
let name;
|
||||||
|
const config = {};
|
||||||
|
let inConfig = false;
|
||||||
|
for (let j = i + 1;j < end; j += 1) {
|
||||||
|
const line = lines[j];
|
||||||
|
const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (disabledMatch) {
|
||||||
|
disabled = parseScalar(disabledMatch[1]) === true;
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nameMatch = /^ name:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (nameMatch) {
|
||||||
|
name = String(parseScalar(nameMatch[1]));
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ config:\s*$/.test(line)) {
|
||||||
|
inConfig = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inConfig) {
|
||||||
|
const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line);
|
||||||
|
if (cfg) {
|
||||||
|
config[cfg[1]] = parseScalar(cfg[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ \S/.test(line))
|
||||||
|
inConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: i, end, disabled, name, config };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hostBridgeNamedElsewhere(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function needsHeal(entry) {
|
||||||
|
if (entry.name !== "dsh-netxops")
|
||||||
|
return true;
|
||||||
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||||
|
if (!Object.hasOwn(entry.config, key))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function renderHostEntry(entry) {
|
||||||
|
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config };
|
||||||
|
const out = [
|
||||||
|
"- id: netxops",
|
||||||
|
" name: dsh-netxops"
|
||||||
|
];
|
||||||
|
if (entry.disabled === true)
|
||||||
|
out.push(" disabled: true");
|
||||||
|
else
|
||||||
|
out.push(" disabled: false");
|
||||||
|
out.push(" config:");
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
out.push(` ${key}: ${formatScalar(value)}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function ensureNetxopsHostInProfilePatches(logger) {
|
||||||
|
let rewritten = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
for (const profileName of ["desktop", "web"]) {
|
||||||
|
const profileDir = join5(resolveDshHome(), "profiles", profileName);
|
||||||
|
const patchPath = join5(profileDir, "cordis.patch.yml");
|
||||||
|
if (!existsSync4(profileDir))
|
||||||
|
continue;
|
||||||
|
const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : "";
|
||||||
|
const nl = raw.includes(`\r
|
||||||
|
`) ? `\r
|
||||||
|
` : `
|
||||||
|
`;
|
||||||
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, `
|
||||||
|
`).split(`
|
||||||
|
`) : [];
|
||||||
|
const skip = managedLineMask(lines);
|
||||||
|
const entry = findHostEntry(lines, skip);
|
||||||
|
let nextLines = null;
|
||||||
|
let kind = null;
|
||||||
|
if (entry === null) {
|
||||||
|
if (hostBridgeNamedElsewhere(lines, skip))
|
||||||
|
continue;
|
||||||
|
const rendered = renderHostEntry({ disabled: false, config: {} });
|
||||||
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN));
|
||||||
|
if (beginIdx >= 0) {
|
||||||
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)];
|
||||||
|
} else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") {
|
||||||
|
nextLines = [...rendered, ""];
|
||||||
|
} else {
|
||||||
|
const body = [...lines];
|
||||||
|
while (body.length > 0 && body[body.length - 1].trim() === "")
|
||||||
|
body.pop();
|
||||||
|
nextLines = [...body, "", ...rendered, ""];
|
||||||
|
}
|
||||||
|
kind = "insert";
|
||||||
|
} else if (needsHeal(entry)) {
|
||||||
|
nextLines = [
|
||||||
|
...lines.slice(0, entry.start),
|
||||||
|
...renderHostEntry(entry),
|
||||||
|
...lines.slice(entry.end)
|
||||||
|
];
|
||||||
|
kind = "rewrite";
|
||||||
|
}
|
||||||
|
if (nextLines === null || kind === null)
|
||||||
|
continue;
|
||||||
|
const normalized = nextLines.join(nl);
|
||||||
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`;
|
||||||
|
if (withNl === raw)
|
||||||
|
continue;
|
||||||
|
writeFileSync2(patchPath, withNl, "utf8");
|
||||||
|
if (kind === "insert") {
|
||||||
|
inserted += 1;
|
||||||
|
logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
} else {
|
||||||
|
rewritten += 1;
|
||||||
|
logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rewritten, inserted };
|
||||||
|
}
|
||||||
|
function tryRecoverMissingHostBridge(logger, pluginName) {
|
||||||
|
const root = globalThis;
|
||||||
|
if (root[HOST_HEAL_ONCE] === true)
|
||||||
|
return;
|
||||||
|
root[HOST_HEAL_ONCE] = true;
|
||||||
|
try {
|
||||||
|
const result = ensureNetxopsHostInProfilePatches(logger);
|
||||||
|
if (result.inserted === 0 && result.rewritten === 0) {
|
||||||
|
logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("%s: host-bridge recover failed: %s", pluginName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/runtime.ts
|
||||||
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
function store2() {
|
function store2() {
|
||||||
|
|
@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
unregisterTools = undefined;
|
unregisterTools = undefined;
|
||||||
const connection = getNetxConnection();
|
const connection = getNetxConnection();
|
||||||
if (connection === undefined) {
|
if (connection === undefined) {
|
||||||
ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name);
|
tryRecoverMissingHostBridge(ctx.logger, options.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const enabled = resolveGroups();
|
const enabled = resolveGroups();
|
||||||
|
|
|
||||||
|
|
@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/netx/heal-profile-entry.ts
|
||||||
|
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join as join5 } from "node:path";
|
||||||
|
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
|
||||||
|
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
|
||||||
|
var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted");
|
||||||
|
var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
|
||||||
|
apiUrl: "http://127.0.0.1:8890",
|
||||||
|
lang: "zh",
|
||||||
|
thinkingLanguage: "auto",
|
||||||
|
replyLanguage: "follow-user",
|
||||||
|
nmsProvider: "zte-ume",
|
||||||
|
tokenCredentialRef: "NETX_API_TOKEN",
|
||||||
|
toolCallTimeoutMs: 120000,
|
||||||
|
installAgentPreset: true,
|
||||||
|
alarmPushEnabled: false,
|
||||||
|
alarmDeliverDsh: true,
|
||||||
|
alarmDeliverIm: false,
|
||||||
|
imBotId: "",
|
||||||
|
imTargetId: "",
|
||||||
|
imTargets: "",
|
||||||
|
groupOpsInPreset: true,
|
||||||
|
groupOpsPublic: true,
|
||||||
|
groupTopologyInPreset: false,
|
||||||
|
groupTopologyPublic: false,
|
||||||
|
groupBizMonitorInPreset: true,
|
||||||
|
groupBizMonitorPublic: false,
|
||||||
|
kbRoot: "",
|
||||||
|
groupKbInPreset: true,
|
||||||
|
groupKbPublic: false
|
||||||
|
});
|
||||||
|
function resolveDshHome() {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
|
if (fromEnv !== undefined && fromEnv.length > 0)
|
||||||
|
return fromEnv;
|
||||||
|
return join5(homedir(), ".dsh");
|
||||||
|
}
|
||||||
|
function managedLineMask(lines) {
|
||||||
|
const skip = lines.map(() => false);
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes(DESKTOP_PATCH_BEGIN))
|
||||||
|
inside = true;
|
||||||
|
if (inside)
|
||||||
|
skip[i] = true;
|
||||||
|
if (line.includes(DESKTOP_PATCH_END))
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
|
return skip;
|
||||||
|
}
|
||||||
|
function parseScalar(raw) {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (text === "true")
|
||||||
|
return true;
|
||||||
|
if (text === "false")
|
||||||
|
return false;
|
||||||
|
if (/^-?\d+$/.test(text))
|
||||||
|
return Number(text);
|
||||||
|
if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
function formatScalar(value) {
|
||||||
|
if (typeof value === "boolean" || typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
if (value === "")
|
||||||
|
return "''";
|
||||||
|
if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function findHostEntry(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (lines[i] !== "- id: netxops")
|
||||||
|
continue;
|
||||||
|
let end = i + 1;
|
||||||
|
while (end < lines.length) {
|
||||||
|
const line = lines[end];
|
||||||
|
if (skip[end])
|
||||||
|
break;
|
||||||
|
if (/^- id:/.test(line))
|
||||||
|
break;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
while (end > i + 1 && lines[end - 1].trim() === "")
|
||||||
|
end -= 1;
|
||||||
|
let disabled;
|
||||||
|
let name;
|
||||||
|
const config = {};
|
||||||
|
let inConfig = false;
|
||||||
|
for (let j = i + 1;j < end; j += 1) {
|
||||||
|
const line = lines[j];
|
||||||
|
const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (disabledMatch) {
|
||||||
|
disabled = parseScalar(disabledMatch[1]) === true;
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nameMatch = /^ name:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (nameMatch) {
|
||||||
|
name = String(parseScalar(nameMatch[1]));
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ config:\s*$/.test(line)) {
|
||||||
|
inConfig = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inConfig) {
|
||||||
|
const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line);
|
||||||
|
if (cfg) {
|
||||||
|
config[cfg[1]] = parseScalar(cfg[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ \S/.test(line))
|
||||||
|
inConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: i, end, disabled, name, config };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hostBridgeNamedElsewhere(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function needsHeal(entry) {
|
||||||
|
if (entry.name !== "dsh-netxops")
|
||||||
|
return true;
|
||||||
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||||
|
if (!Object.hasOwn(entry.config, key))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function renderHostEntry(entry) {
|
||||||
|
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config };
|
||||||
|
const out = [
|
||||||
|
"- id: netxops",
|
||||||
|
" name: dsh-netxops"
|
||||||
|
];
|
||||||
|
if (entry.disabled === true)
|
||||||
|
out.push(" disabled: true");
|
||||||
|
else
|
||||||
|
out.push(" disabled: false");
|
||||||
|
out.push(" config:");
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
out.push(` ${key}: ${formatScalar(value)}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function ensureNetxopsHostInProfilePatches(logger) {
|
||||||
|
let rewritten = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
for (const profileName of ["desktop", "web"]) {
|
||||||
|
const profileDir = join5(resolveDshHome(), "profiles", profileName);
|
||||||
|
const patchPath = join5(profileDir, "cordis.patch.yml");
|
||||||
|
if (!existsSync4(profileDir))
|
||||||
|
continue;
|
||||||
|
const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : "";
|
||||||
|
const nl = raw.includes(`\r
|
||||||
|
`) ? `\r
|
||||||
|
` : `
|
||||||
|
`;
|
||||||
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, `
|
||||||
|
`).split(`
|
||||||
|
`) : [];
|
||||||
|
const skip = managedLineMask(lines);
|
||||||
|
const entry = findHostEntry(lines, skip);
|
||||||
|
let nextLines = null;
|
||||||
|
let kind = null;
|
||||||
|
if (entry === null) {
|
||||||
|
if (hostBridgeNamedElsewhere(lines, skip))
|
||||||
|
continue;
|
||||||
|
const rendered = renderHostEntry({ disabled: false, config: {} });
|
||||||
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN));
|
||||||
|
if (beginIdx >= 0) {
|
||||||
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)];
|
||||||
|
} else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") {
|
||||||
|
nextLines = [...rendered, ""];
|
||||||
|
} else {
|
||||||
|
const body = [...lines];
|
||||||
|
while (body.length > 0 && body[body.length - 1].trim() === "")
|
||||||
|
body.pop();
|
||||||
|
nextLines = [...body, "", ...rendered, ""];
|
||||||
|
}
|
||||||
|
kind = "insert";
|
||||||
|
} else if (needsHeal(entry)) {
|
||||||
|
nextLines = [
|
||||||
|
...lines.slice(0, entry.start),
|
||||||
|
...renderHostEntry(entry),
|
||||||
|
...lines.slice(entry.end)
|
||||||
|
];
|
||||||
|
kind = "rewrite";
|
||||||
|
}
|
||||||
|
if (nextLines === null || kind === null)
|
||||||
|
continue;
|
||||||
|
const normalized = nextLines.join(nl);
|
||||||
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`;
|
||||||
|
if (withNl === raw)
|
||||||
|
continue;
|
||||||
|
writeFileSync2(patchPath, withNl, "utf8");
|
||||||
|
if (kind === "insert") {
|
||||||
|
inserted += 1;
|
||||||
|
logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
} else {
|
||||||
|
rewritten += 1;
|
||||||
|
logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rewritten, inserted };
|
||||||
|
}
|
||||||
|
function tryRecoverMissingHostBridge(logger, pluginName) {
|
||||||
|
const root = globalThis;
|
||||||
|
if (root[HOST_HEAL_ONCE] === true)
|
||||||
|
return;
|
||||||
|
root[HOST_HEAL_ONCE] = true;
|
||||||
|
try {
|
||||||
|
const result = ensureNetxopsHostInProfilePatches(logger);
|
||||||
|
if (result.inserted === 0 && result.rewritten === 0) {
|
||||||
|
logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("%s: host-bridge recover failed: %s", pluginName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/runtime.ts
|
||||||
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
function store2() {
|
function store2() {
|
||||||
|
|
@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
unregisterTools = undefined;
|
unregisterTools = undefined;
|
||||||
const connection = getNetxConnection();
|
const connection = getNetxConnection();
|
||||||
if (connection === undefined) {
|
if (connection === undefined) {
|
||||||
ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name);
|
tryRecoverMissingHostBridge(ctx.logger, options.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const enabled = resolveGroups();
|
const enabled = resolveGroups();
|
||||||
|
|
|
||||||
|
|
@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/netx/heal-profile-entry.ts
|
||||||
|
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join as join5 } from "node:path";
|
||||||
|
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
|
||||||
|
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
|
||||||
|
var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted");
|
||||||
|
var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
|
||||||
|
apiUrl: "http://127.0.0.1:8890",
|
||||||
|
lang: "zh",
|
||||||
|
thinkingLanguage: "auto",
|
||||||
|
replyLanguage: "follow-user",
|
||||||
|
nmsProvider: "zte-ume",
|
||||||
|
tokenCredentialRef: "NETX_API_TOKEN",
|
||||||
|
toolCallTimeoutMs: 120000,
|
||||||
|
installAgentPreset: true,
|
||||||
|
alarmPushEnabled: false,
|
||||||
|
alarmDeliverDsh: true,
|
||||||
|
alarmDeliverIm: false,
|
||||||
|
imBotId: "",
|
||||||
|
imTargetId: "",
|
||||||
|
imTargets: "",
|
||||||
|
groupOpsInPreset: true,
|
||||||
|
groupOpsPublic: true,
|
||||||
|
groupTopologyInPreset: false,
|
||||||
|
groupTopologyPublic: false,
|
||||||
|
groupBizMonitorInPreset: true,
|
||||||
|
groupBizMonitorPublic: false,
|
||||||
|
kbRoot: "",
|
||||||
|
groupKbInPreset: true,
|
||||||
|
groupKbPublic: false
|
||||||
|
});
|
||||||
|
function resolveDshHome() {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
|
if (fromEnv !== undefined && fromEnv.length > 0)
|
||||||
|
return fromEnv;
|
||||||
|
return join5(homedir(), ".dsh");
|
||||||
|
}
|
||||||
|
function managedLineMask(lines) {
|
||||||
|
const skip = lines.map(() => false);
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes(DESKTOP_PATCH_BEGIN))
|
||||||
|
inside = true;
|
||||||
|
if (inside)
|
||||||
|
skip[i] = true;
|
||||||
|
if (line.includes(DESKTOP_PATCH_END))
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
|
return skip;
|
||||||
|
}
|
||||||
|
function parseScalar(raw) {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (text === "true")
|
||||||
|
return true;
|
||||||
|
if (text === "false")
|
||||||
|
return false;
|
||||||
|
if (/^-?\d+$/.test(text))
|
||||||
|
return Number(text);
|
||||||
|
if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
function formatScalar(value) {
|
||||||
|
if (typeof value === "boolean" || typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
if (value === "")
|
||||||
|
return "''";
|
||||||
|
if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function findHostEntry(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (lines[i] !== "- id: netxops")
|
||||||
|
continue;
|
||||||
|
let end = i + 1;
|
||||||
|
while (end < lines.length) {
|
||||||
|
const line = lines[end];
|
||||||
|
if (skip[end])
|
||||||
|
break;
|
||||||
|
if (/^- id:/.test(line))
|
||||||
|
break;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
while (end > i + 1 && lines[end - 1].trim() === "")
|
||||||
|
end -= 1;
|
||||||
|
let disabled;
|
||||||
|
let name;
|
||||||
|
const config = {};
|
||||||
|
let inConfig = false;
|
||||||
|
for (let j = i + 1;j < end; j += 1) {
|
||||||
|
const line = lines[j];
|
||||||
|
const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (disabledMatch) {
|
||||||
|
disabled = parseScalar(disabledMatch[1]) === true;
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nameMatch = /^ name:\s*(.+)\s*$/.exec(line);
|
||||||
|
if (nameMatch) {
|
||||||
|
name = String(parseScalar(nameMatch[1]));
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ config:\s*$/.test(line)) {
|
||||||
|
inConfig = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inConfig) {
|
||||||
|
const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line);
|
||||||
|
if (cfg) {
|
||||||
|
config[cfg[1]] = parseScalar(cfg[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^ \S/.test(line))
|
||||||
|
inConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: i, end, disabled, name, config };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hostBridgeNamedElsewhere(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function needsHeal(entry) {
|
||||||
|
if (entry.name !== "dsh-netxops")
|
||||||
|
return true;
|
||||||
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||||
|
if (!Object.hasOwn(entry.config, key))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function renderHostEntry(entry) {
|
||||||
|
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config };
|
||||||
|
const out = [
|
||||||
|
"- id: netxops",
|
||||||
|
" name: dsh-netxops"
|
||||||
|
];
|
||||||
|
if (entry.disabled === true)
|
||||||
|
out.push(" disabled: true");
|
||||||
|
else
|
||||||
|
out.push(" disabled: false");
|
||||||
|
out.push(" config:");
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
out.push(` ${key}: ${formatScalar(value)}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function ensureNetxopsHostInProfilePatches(logger) {
|
||||||
|
let rewritten = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
for (const profileName of ["desktop", "web"]) {
|
||||||
|
const profileDir = join5(resolveDshHome(), "profiles", profileName);
|
||||||
|
const patchPath = join5(profileDir, "cordis.patch.yml");
|
||||||
|
if (!existsSync4(profileDir))
|
||||||
|
continue;
|
||||||
|
const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : "";
|
||||||
|
const nl = raw.includes(`\r
|
||||||
|
`) ? `\r
|
||||||
|
` : `
|
||||||
|
`;
|
||||||
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, `
|
||||||
|
`).split(`
|
||||||
|
`) : [];
|
||||||
|
const skip = managedLineMask(lines);
|
||||||
|
const entry = findHostEntry(lines, skip);
|
||||||
|
let nextLines = null;
|
||||||
|
let kind = null;
|
||||||
|
if (entry === null) {
|
||||||
|
if (hostBridgeNamedElsewhere(lines, skip))
|
||||||
|
continue;
|
||||||
|
const rendered = renderHostEntry({ disabled: false, config: {} });
|
||||||
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN));
|
||||||
|
if (beginIdx >= 0) {
|
||||||
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)];
|
||||||
|
} else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") {
|
||||||
|
nextLines = [...rendered, ""];
|
||||||
|
} else {
|
||||||
|
const body = [...lines];
|
||||||
|
while (body.length > 0 && body[body.length - 1].trim() === "")
|
||||||
|
body.pop();
|
||||||
|
nextLines = [...body, "", ...rendered, ""];
|
||||||
|
}
|
||||||
|
kind = "insert";
|
||||||
|
} else if (needsHeal(entry)) {
|
||||||
|
nextLines = [
|
||||||
|
...lines.slice(0, entry.start),
|
||||||
|
...renderHostEntry(entry),
|
||||||
|
...lines.slice(entry.end)
|
||||||
|
];
|
||||||
|
kind = "rewrite";
|
||||||
|
}
|
||||||
|
if (nextLines === null || kind === null)
|
||||||
|
continue;
|
||||||
|
const normalized = nextLines.join(nl);
|
||||||
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`;
|
||||||
|
if (withNl === raw)
|
||||||
|
continue;
|
||||||
|
writeFileSync2(patchPath, withNl, "utf8");
|
||||||
|
if (kind === "insert") {
|
||||||
|
inserted += 1;
|
||||||
|
logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
} else {
|
||||||
|
rewritten += 1;
|
||||||
|
logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rewritten, inserted };
|
||||||
|
}
|
||||||
|
function tryRecoverMissingHostBridge(logger, pluginName) {
|
||||||
|
const root = globalThis;
|
||||||
|
if (root[HOST_HEAL_ONCE] === true)
|
||||||
|
return;
|
||||||
|
root[HOST_HEAL_ONCE] = true;
|
||||||
|
try {
|
||||||
|
const result = ensureNetxopsHostInProfilePatches(logger);
|
||||||
|
if (result.inserted === 0 && result.rewritten === 0) {
|
||||||
|
logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("%s: host-bridge recover failed: %s", pluginName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/runtime.ts
|
||||||
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
function store2() {
|
function store2() {
|
||||||
|
|
@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
unregisterTools = undefined;
|
unregisterTools = undefined;
|
||||||
const connection = getNetxConnection();
|
const connection = getNetxConnection();
|
||||||
if (connection === undefined) {
|
if (connection === undefined) {
|
||||||
ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name);
|
tryRecoverMissingHostBridge(ctx.logger, options.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const enabled = resolveGroups();
|
const enabled = resolveGroups();
|
||||||
|
|
|
||||||
82
lib/index.js
82
lib/index.js
|
|
@ -26,6 +26,7 @@ import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
|
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
|
||||||
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
|
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
|
||||||
|
var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted");
|
||||||
var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
|
var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
|
||||||
apiUrl: "http://127.0.0.1:8890",
|
apiUrl: "http://127.0.0.1:8890",
|
||||||
lang: "zh",
|
lang: "zh",
|
||||||
|
|
@ -147,6 +148,15 @@ function findHostEntry(lines, skip) {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
function hostBridgeNamedElsewhere(lines, skip) {
|
||||||
|
for (let i = 0;i < lines.length; i += 1) {
|
||||||
|
if (skip[i])
|
||||||
|
continue;
|
||||||
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
function needsHeal(entry) {
|
function needsHeal(entry) {
|
||||||
if (entry.name !== "dsh-netxops")
|
if (entry.name !== "dsh-netxops")
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -164,7 +174,7 @@ function renderHostEntry(entry) {
|
||||||
];
|
];
|
||||||
if (entry.disabled === true)
|
if (entry.disabled === true)
|
||||||
out.push(" disabled: true");
|
out.push(" disabled: true");
|
||||||
else if (entry.disabled === false)
|
else
|
||||||
out.push(" disabled: false");
|
out.push(" disabled: false");
|
||||||
out.push(" config:");
|
out.push(" config:");
|
||||||
for (const [key, value] of Object.entries(config)) {
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
|
@ -172,40 +182,66 @@ function renderHostEntry(entry) {
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
function healNetxopsProfileEntries(logger) {
|
function ensureNetxopsHostInProfilePatches(logger) {
|
||||||
let rewritten = 0;
|
let rewritten = 0;
|
||||||
|
let inserted = 0;
|
||||||
for (const profileName of ["desktop", "web"]) {
|
for (const profileName of ["desktop", "web"]) {
|
||||||
const patchPath = join(resolveDshHome(), "profiles", profileName, "cordis.patch.yml");
|
const profileDir = join(resolveDshHome(), "profiles", profileName);
|
||||||
if (!existsSync(patchPath))
|
const patchPath = join(profileDir, "cordis.patch.yml");
|
||||||
|
if (!existsSync(profileDir))
|
||||||
continue;
|
continue;
|
||||||
const raw = readFileSync(patchPath, "utf8");
|
const raw = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
|
||||||
const nl = raw.includes(`\r
|
const nl = raw.includes(`\r
|
||||||
`) ? `\r
|
`) ? `\r
|
||||||
` : `
|
` : `
|
||||||
`;
|
`;
|
||||||
const lines = raw.replace(/\r\n/g, `
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, `
|
||||||
`).split(`
|
`).split(`
|
||||||
`);
|
`) : [];
|
||||||
const skip = managedLineMask(lines);
|
const skip = managedLineMask(lines);
|
||||||
const entry = findHostEntry(lines, skip);
|
const entry = findHostEntry(lines, skip);
|
||||||
if (entry === null)
|
let nextLines = null;
|
||||||
|
let kind = null;
|
||||||
|
if (entry === null) {
|
||||||
|
if (hostBridgeNamedElsewhere(lines, skip))
|
||||||
|
continue;
|
||||||
|
const rendered = renderHostEntry({ disabled: false, config: {} });
|
||||||
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN));
|
||||||
|
if (beginIdx >= 0) {
|
||||||
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)];
|
||||||
|
} else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") {
|
||||||
|
nextLines = [...rendered, ""];
|
||||||
|
} else {
|
||||||
|
const body = [...lines];
|
||||||
|
while (body.length > 0 && body[body.length - 1].trim() === "")
|
||||||
|
body.pop();
|
||||||
|
nextLines = [...body, "", ...rendered, ""];
|
||||||
|
}
|
||||||
|
kind = "insert";
|
||||||
|
} else if (needsHeal(entry)) {
|
||||||
|
nextLines = [
|
||||||
|
...lines.slice(0, entry.start),
|
||||||
|
...renderHostEntry(entry),
|
||||||
|
...lines.slice(entry.end)
|
||||||
|
];
|
||||||
|
kind = "rewrite";
|
||||||
|
}
|
||||||
|
if (nextLines === null || kind === null)
|
||||||
continue;
|
continue;
|
||||||
if (!needsHeal(entry))
|
const normalized = nextLines.join(nl);
|
||||||
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`;
|
||||||
|
if (withNl === raw)
|
||||||
continue;
|
continue;
|
||||||
const rendered = renderHostEntry(entry);
|
writeFileSync(patchPath, withNl, "utf8");
|
||||||
const next = [
|
if (kind === "insert") {
|
||||||
...lines.slice(0, entry.start),
|
inserted += 1;
|
||||||
...rendered,
|
logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
...lines.slice(entry.end)
|
} else {
|
||||||
].join(nl);
|
rewritten += 1;
|
||||||
const normalized = next.endsWith(nl) ? next : `${next}${nl}`;
|
logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
||||||
if (normalized === raw)
|
}
|
||||||
continue;
|
|
||||||
writeFileSync(patchPath, normalized, "utf8");
|
|
||||||
rewritten += 1;
|
|
||||||
logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing after heal)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
|
|
||||||
}
|
}
|
||||||
return rewritten;
|
return { rewritten, inserted };
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/agent-preset-install.ts
|
// src/agent-preset-install.ts
|
||||||
|
|
@ -695,7 +731,7 @@ function ensureAgentPresetInstalled(logger) {
|
||||||
logger.error("netxops: failed to sync declarative preset into profile patch: %s", error);
|
logger.error("netxops: failed to sync declarative preset into profile patch: %s", error);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
healNetxopsProfileEntries(logger);
|
ensureNetxopsHostInProfilePatches(logger);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("netxops: failed to heal host profile entry: %s", error);
|
logger.error("netxops: failed to heal host profile entry: %s", error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-netxops",
|
"name": "dsh-netxops",
|
||||||
"version": "0.1.52",
|
"version": "0.1.53",
|
||||||
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
|
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { homedir } from 'node:os'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import { healNetxopsProfileEntries } from './netx/heal-profile-entry.ts'
|
import { ensureNetxopsHostInProfilePatches } from './netx/heal-profile-entry.ts'
|
||||||
|
|
||||||
export const NETXOPS_PRESET_ID = 'netxops'
|
export const NETXOPS_PRESET_ID = 'netxops'
|
||||||
|
|
||||||
|
|
@ -612,10 +612,10 @@ export function ensureAgentPresetInstalled(logger: Context['logger']): void {
|
||||||
logger.error('netxops: failed to sync declarative preset into profile patch: %s', error)
|
logger.error('netxops: failed to sync declarative preset into profile patch: %s', error)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Plugin Manager often leaves `- id: netxops` / `disabled: false` with no
|
// (1) Sparse `- id: netxops` from Plugin Manager → expand missing config keys.
|
||||||
// config; DSH 0.2 replaces the whole Config → capability/public flags never
|
// (2) Missing host row after dirty reinstall (preset channel self-healed,
|
||||||
// apply. Expand missing keys from package defaults on every Host apply.
|
// bundle host insert lost) → insert full `name: dsh-netxops` block.
|
||||||
healNetxopsProfileEntries(logger)
|
ensureNetxopsHostInProfilePatches(logger)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('netxops: failed to heal host profile entry: %s', error)
|
logger.error('netxops: failed to heal host profile entry: %s', error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { registerKbContextSkill } from './kb-context-skill.ts'
|
||||||
import { registerKbLocalTools } from './kb-local-tools.ts'
|
import { registerKbLocalTools } from './kb-local-tools.ts'
|
||||||
import { registerKbPackSkills } from './kb-pack-skills.ts'
|
import { registerKbPackSkills } from './kb-pack-skills.ts'
|
||||||
import { getKbContext, watchKbContext } from './kb-runtime.ts'
|
import { getKbContext, watchKbContext } from './kb-runtime.ts'
|
||||||
|
import { tryRecoverMissingHostBridge } from './heal-profile-entry.ts'
|
||||||
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
||||||
import { registerNetxTools } from './tools.ts'
|
import { registerNetxTools } from './tools.ts'
|
||||||
|
|
||||||
|
|
@ -51,7 +52,9 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
||||||
unregisterTools = undefined
|
unregisterTools = undefined
|
||||||
const connection = getNetxConnection()
|
const connection = getNetxConnection()
|
||||||
if (connection === undefined) {
|
if (connection === undefined) {
|
||||||
ctx.logger.warn('%s: no connection yet — waiting for host settings bridge', options.name)
|
// Preset channel can self-heal while the Host bundle row is gone — restore
|
||||||
|
// `dsh-netxops` into cordis.patch.yml once, then require a full restart.
|
||||||
|
tryRecoverMissingHostBridge(ctx.logger, options.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const enabled = resolveGroups()
|
const enabled = resolveGroups()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
/**
|
/**
|
||||||
* Heal sparse Desktop/Web profile overrides for the host `netxops` row.
|
* Ensure the Host bridge row `dsh-netxops` exists in desktop/web profile patches.
|
||||||
*
|
*
|
||||||
* Plugin Manager often writes only:
|
* Two independent install channels drift apart after dirty reinstalls:
|
||||||
* - id: netxops
|
* - Preset channel: `ensureAgentPresetInstalled` rewrites the managed
|
||||||
* disabled: false
|
* `preset-netxops` block into `cordis.patch.yml` on every Host apply.
|
||||||
* On DSH 0.2 that override **replaces the whole Config**, so capability /
|
* - Host channel: normally comes from the package `cordis.bundle.patch.yml`
|
||||||
* public flags never land unless we re-expand the entry. Call this on Host
|
* insert, merged only while the bundle stays selected.
|
||||||
* apply so every install (not just hand-edited machines) gets a usable row.
|
*
|
||||||
|
* When the bundle host insert is lost, Netx Ops preset + `netxops-tools` can
|
||||||
|
* still load, but `getNetxConnection()` stays undefined forever → no tools.
|
||||||
|
* Call this from Host apply **and** from the preset tools plugin (chicken-egg
|
||||||
|
* recovery when only the preset side is still alive).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
|
@ -17,6 +21,8 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||||
const DESKTOP_PATCH_BEGIN = '# BEGIN dsh-netxops-preset (managed)'
|
const DESKTOP_PATCH_BEGIN = '# BEGIN dsh-netxops-preset (managed)'
|
||||||
const DESKTOP_PATCH_END = '# END dsh-netxops-preset (managed)'
|
const DESKTOP_PATCH_END = '# END dsh-netxops-preset (managed)'
|
||||||
|
|
||||||
|
const HOST_HEAL_ONCE = Symbol.for('dsh-netxops.host-bridge-heal-attempted')
|
||||||
|
|
||||||
/** Defaults merged into missing keys only — never overwrite user values. */
|
/** Defaults merged into missing keys only — never overwrite user values. */
|
||||||
export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | boolean | number>> = Object.freeze({
|
export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | boolean | number>> = Object.freeze({
|
||||||
apiUrl: 'http://127.0.0.1:8890',
|
apiUrl: 'http://127.0.0.1:8890',
|
||||||
|
|
@ -45,6 +51,11 @@ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | bool
|
||||||
groupKbPublic: false,
|
groupKbPublic: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export interface HostBridgeHealResult {
|
||||||
|
rewritten: number
|
||||||
|
inserted: number
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDshHome(): string {
|
function resolveDshHome(): string {
|
||||||
const fromEnv = process.env.DSH_HOME?.trim()
|
const fromEnv = process.env.DSH_HOME?.trim()
|
||||||
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
|
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
|
||||||
|
|
@ -143,6 +154,15 @@ function findHostEntry(lines: string[], skip: boolean[]): HostEntry | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when any non-managed row already names the host package (insert or id form). */
|
||||||
|
function hostBridgeNamedElsewhere(lines: string[], skip: boolean[]): boolean {
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
if (skip[i]) continue
|
||||||
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]!)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function needsHeal(entry: HostEntry): boolean {
|
function needsHeal(entry: HostEntry): boolean {
|
||||||
if (entry.name !== 'dsh-netxops') return true
|
if (entry.name !== 'dsh-netxops') return true
|
||||||
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||||
|
|
@ -151,14 +171,14 @@ function needsHeal(entry: HostEntry): boolean {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHostEntry(entry: HostEntry): string[] {
|
function renderHostEntry(entry: Pick<HostEntry, 'disabled' | 'name' | 'config'>): string[] {
|
||||||
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }
|
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }
|
||||||
const out: string[] = [
|
const out: string[] = [
|
||||||
'- id: netxops',
|
'- id: netxops',
|
||||||
" name: dsh-netxops",
|
' name: dsh-netxops',
|
||||||
]
|
]
|
||||||
if (entry.disabled === true) out.push(' disabled: true')
|
if (entry.disabled === true) out.push(' disabled: true')
|
||||||
else if (entry.disabled === false) out.push(' disabled: false')
|
else out.push(' disabled: false')
|
||||||
out.push(' config:')
|
out.push(' config:')
|
||||||
for (const [key, value] of Object.entries(config)) {
|
for (const [key, value] of Object.entries(config)) {
|
||||||
out.push(` ${key}: ${formatScalar(value)}`)
|
out.push(` ${key}: ${formatScalar(value)}`)
|
||||||
|
|
@ -167,38 +187,100 @@ function renderHostEntry(entry: HostEntry): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand sparse `netxops` host overrides in desktop/web profile patches.
|
* Insert or expand the Host `dsh-netxops` row in desktop/web `cordis.patch.yml`.
|
||||||
* @returns number of profiles rewritten.
|
* @returns counts of profiles rewritten (sparse) vs freshly inserted (missing).
|
||||||
*/
|
*/
|
||||||
export function healNetxopsProfileEntries(logger: Context['logger']): number {
|
export function ensureNetxopsHostInProfilePatches(logger: Context['logger']): HostBridgeHealResult {
|
||||||
let rewritten = 0
|
let rewritten = 0
|
||||||
|
let inserted = 0
|
||||||
for (const profileName of ['desktop', 'web'] as const) {
|
for (const profileName of ['desktop', 'web'] as const) {
|
||||||
const patchPath = join(resolveDshHome(), 'profiles', profileName, 'cordis.patch.yml')
|
const profileDir = join(resolveDshHome(), 'profiles', profileName)
|
||||||
if (!existsSync(patchPath)) continue
|
const patchPath = join(profileDir, 'cordis.patch.yml')
|
||||||
const raw = readFileSync(patchPath, 'utf8')
|
if (!existsSync(profileDir)) continue
|
||||||
|
const raw = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
|
||||||
const nl = raw.includes('\r\n') ? '\r\n' : '\n'
|
const nl = raw.includes('\r\n') ? '\r\n' : '\n'
|
||||||
const lines = raw.replace(/\r\n/g, '\n').split('\n')
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, '\n').split('\n') : []
|
||||||
// Drop trailing empty split artifact for rewrite math; re-join with file nl.
|
|
||||||
const skip = managedLineMask(lines)
|
const skip = managedLineMask(lines)
|
||||||
const entry = findHostEntry(lines, skip)
|
const entry = findHostEntry(lines, skip)
|
||||||
if (entry === null) continue
|
|
||||||
if (!needsHeal(entry)) continue
|
let nextLines: string[] | null = null
|
||||||
const rendered = renderHostEntry(entry)
|
let kind: 'insert' | 'rewrite' | null = null
|
||||||
const next = [
|
|
||||||
...lines.slice(0, entry.start),
|
if (entry === null) {
|
||||||
...rendered,
|
if (hostBridgeNamedElsewhere(lines, skip)) continue
|
||||||
...lines.slice(entry.end),
|
const rendered = renderHostEntry({ disabled: false, config: {} })
|
||||||
].join(nl)
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN))
|
||||||
const normalized = next.endsWith(nl) ? next : `${next}${nl}`
|
if (beginIdx >= 0) {
|
||||||
if (normalized === raw) continue
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, '', ...lines.slice(beginIdx)]
|
||||||
writeFileSync(patchPath, normalized, 'utf8')
|
} else if (lines.length === 0 || (lines.length === 1 && lines[0]!.trim() === '')) {
|
||||||
rewritten += 1
|
nextLines = [...rendered, '']
|
||||||
logger.info(
|
} else {
|
||||||
'netxops: healed sparse host entry in profiles/%s/cordis.patch.yml '
|
const body = [...lines]
|
||||||
+ '(public toggles hot-reload via settings; restart %s only if tools still missing after heal)',
|
while (body.length > 0 && body[body.length - 1]!.trim() === '') body.pop()
|
||||||
profileName,
|
nextLines = [...body, '', ...rendered, '']
|
||||||
profileName === 'desktop' ? 'Desktop' : 'dsh web',
|
}
|
||||||
)
|
kind = 'insert'
|
||||||
|
} else if (needsHeal(entry)) {
|
||||||
|
nextLines = [
|
||||||
|
...lines.slice(0, entry.start),
|
||||||
|
...renderHostEntry(entry),
|
||||||
|
...lines.slice(entry.end),
|
||||||
|
]
|
||||||
|
kind = 'rewrite'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextLines === null || kind === null) continue
|
||||||
|
const normalized = nextLines.join(nl)
|
||||||
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`
|
||||||
|
if (withNl === raw) continue
|
||||||
|
writeFileSync(patchPath, withNl, 'utf8')
|
||||||
|
if (kind === 'insert') {
|
||||||
|
inserted += 1
|
||||||
|
logger.error(
|
||||||
|
'netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — '
|
||||||
|
+ 'wrote it back. Fully quit and restart %s once so the host publishes the connection '
|
||||||
|
+ '(preset-only installs leave netxops-tools waiting forever).',
|
||||||
|
profileName,
|
||||||
|
profileName === 'desktop' ? 'Desktop' : 'dsh web',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rewritten += 1
|
||||||
|
logger.info(
|
||||||
|
'netxops: healed sparse host entry in profiles/%s/cordis.patch.yml '
|
||||||
|
+ '(public toggles hot-reload via settings; restart %s only if tools still missing)',
|
||||||
|
profileName,
|
||||||
|
profileName === 'desktop' ? 'Desktop' : 'dsh web',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rewritten, inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use {@link ensureNetxopsHostInProfilePatches}. */
|
||||||
|
export function healNetxopsProfileEntries(logger: Context['logger']): number {
|
||||||
|
const result = ensureNetxopsHostInProfilePatches(logger)
|
||||||
|
return result.rewritten + result.inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset-side chicken-egg recovery: when tools mount with no connection, try
|
||||||
|
* once per process to restore the missing Host bridge into the profile patch.
|
||||||
|
*/
|
||||||
|
export function tryRecoverMissingHostBridge(logger: Context['logger'], pluginName: string): void {
|
||||||
|
const root = globalThis as typeof globalThis & { [HOST_HEAL_ONCE]?: boolean }
|
||||||
|
if (root[HOST_HEAL_ONCE] === true) return
|
||||||
|
root[HOST_HEAL_ONCE] = true
|
||||||
|
try {
|
||||||
|
const result = ensureNetxopsHostInProfilePatches(logger)
|
||||||
|
if (result.inserted === 0 && result.rewritten === 0) {
|
||||||
|
logger.warn(
|
||||||
|
'%s: no connection yet — host settings bridge not publishing. '
|
||||||
|
+ 'Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). '
|
||||||
|
+ 'Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.',
|
||||||
|
pluginName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('%s: host-bridge recover failed: %s', pluginName, error)
|
||||||
}
|
}
|
||||||
return rewritten
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue