diff --git a/kit/src/index.js b/kit/src/index.js index 8dca2b6..ecc3688 100644 --- a/kit/src/index.js +++ b/kit/src/index.js @@ -18,6 +18,10 @@ const kitcommand = require("./kitcommand") kitcommand.init() kitcommand.regTo( parser ) +const registry = require("./registry") +registry.init() +registry.regTo( parser ) + // parser.addCmdLine("man", "打开在线帮助;", async function (){ // const uConfig = await $context.get("uConfig") diff --git a/kit/src/registry/client.js b/kit/src/registry/client.js new file mode 100644 index 0000000..bfb4c3c --- /dev/null +++ b/kit/src/registry/client.js @@ -0,0 +1,98 @@ +/** + * Registry HTTP 客户端 + * 调用 Registry 配置中心 REST API + */ +const http = require("http"); +const https = require("https"); + +class RegistryClient { + constructor(baseUrl, token) { + this.baseUrl = String(baseUrl || "").replace(/\/+$/, ""); + this.token = token || ""; + } + + /** + * 发起 HTTP 请求 + * @param {string} method - GET/POST/PUT/DELETE + * @param {string} path - API 路径 + * @param {object} body - 请求体 + * @returns {Promise} 响应 data + */ + request(method, path, body) { + return new Promise((resolve, reject) => { + let url; + try { + url = new URL(this.baseUrl + path); + } catch (e) { + reject(new Error("Registry 地址无效: " + this.baseUrl)); + return; + } + + const lib = url.protocol === "https:" ? https : http; + const options = { + method, + hostname: url.hostname, + port: url.port || (url.protocol === "https:" ? 443 : 80), + path: url.pathname + url.search, + headers: { "Content-Type": "application/json" }, + }; + if (this.token) { + options.headers.Authorization = "Bearer " + this.token; + } + + const req = lib.request(options, (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + let parsed = null; + try { + parsed = JSON.parse(data); + } catch (e) { + /* 非 JSON 响应 */ + } + if (parsed && parsed.success === true) { + resolve(parsed.data); + } else { + const msg = parsed && parsed.message + ? parsed.message + : "HTTP " + res.statusCode; + const err = new Error(msg); + err.status = res.statusCode; + reject(err); + } + }); + }); + req.on("error", (e) => { + reject(new Error("Registry 不可达: " + e.message)); + }); + req.setTimeout(10000, () => { + req.destroy(new Error("请求超时")); + }); + + if (body !== undefined) { + req.write(JSON.stringify(body)); + } + req.end(); + }); + } + + listProjects() { + return this.request("GET", "/api/projects"); + } + + listConfigs(projectId) { + return this.request("GET", `/api/projects/${encodeURIComponent(projectId)}/configs`); + } + + createConfig(projectId, data) { + return this.request("POST", `/api/projects/${encodeURIComponent(projectId)}/configs`, data); + } + + updateConfig(projectId, configId, data) { + return this.request("PUT", `/api/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configId)}`, data); + } +} + +module.exports = RegistryClient; \ No newline at end of file diff --git a/kit/src/registry/index.js b/kit/src/registry/index.js new file mode 100644 index 0000000..45ffd69 --- /dev/null +++ b/kit/src/registry/index.js @@ -0,0 +1,404 @@ +/** + * Registry 模块 + * kit 命令行对接 Registry 配置管理中心 + * + * 配置项(通过 kit config 设置): + * registryUrl Registry 服务地址 + * registryToken Registry 认证 Token + * + * 命令: + * kit registry 查看用法 + * kit registry list 列出项目 + * kit registry config 查看配置 + * kit registry config 设置配置 + * kit registry export [file] 导出配置 + * kit registry import 导入配置 + */ +const RegistryClient = require("./client"); + +const USAGE = [ + "registry 用法:", + " kit registry list 列出 Registry 项目", + " kit registry config 查看配置", + " kit registry config 设置配置", + " kit registry export [file] 导出配置为 JSON", + " kit registry import 从 JSON 导入配置", + "配置项: kit config registryUrl , kit config registryToken ", +].join("\n"); + +/** + * 解析 project:path 语法 + * @param {string} spec - 例如 CommonExternalService 或 CommonExternalService:data/windows + */ +function parseProjectPath(spec) { + spec = String(spec || ""); + const idx = spec.indexOf(":"); + if (idx === -1) { + return { project: spec, path: "" }; + } + return { + project: spec.substring(0, idx), + path: spec.substring(idx + 1).replace(/^\/+|\/+$/g, ""), + }; +} + +/** + * 规整 key:去空白、压缩连续斜杠、去首尾斜杠 + */ +function normalizeKey(key) { + return String(key || "") + .trim() + .split("/") + .map((s) => s.trim()) + .filter((s) => s !== "") + .join("/"); +} + +/** + * 获取配置并用 path 过滤 + * @returns {Promise<{all: Array, matched: Array}>} + */ +async function getConfigsFiltered(client, projectId, prefix) { + const configs = await client.listConfigs(projectId); + if (!prefix) { + return { all: configs, matched: configs }; + } + const matched = configs.filter((c) => { + const p = c.path || ""; + return p === prefix || p.startsWith(prefix + "/"); + }); + return { all: configs, matched }; +} + +/** + * 格式化配置值用于展示 + */ +function formatValue(value, type) { + if (value === undefined || value === null) { + return ""; + } + if (type === "json" && typeof value !== "string") { + return JSON.stringify(value); + } + return String(value); +} + +/** + * 解析项目(支持名称或 ID) + */ +async function resolveProject(client, name) { + const projects = await client.listProjects(); + const project = projects.find((p) => p.id === name || p.name === name); + if (!project) { + throw new Error("项目不存在: " + name); + } + return project; +} + +/** + * 打印配置表格 + */ +function printConfigTable(configs) { + if (!configs || configs.length === 0) { + $logger.info("无匹配的配置"); + return; + } + const table = configs.map((c) => ({ + 路径: c.path, + 值: formatValue(c.value, c.type), + 类型: c.type, + 必填: c.required ? "✓" : "", + 存在: c.exists === false ? "✗" : "✓", + })); + console.table(table); + $logger.info("共 " + configs.length + " 条配置"); +} + +/** + * 构建导出文件内容 + */ +function buildExport(project, prefix, configs) { + return { + project: project.name, + path: prefix, + exportedAt: new Date().toISOString(), + configs: configs.map((c) => { + const fullPath = c.path || ""; + let key = c.key || ""; + if (prefix && fullPath === prefix) { + key = ""; + } else if (prefix && fullPath.startsWith(prefix + "/")) { + key = fullPath.substring(prefix.length + 1); + } + return { + key, + path: fullPath, + value: c.value, + type: c.type, + description: c.description, + required: c.required === true, + exists: c.exists !== false, + defaultValue: c.defaultValue, + purpose: c.purpose, + valueRange: c.valueRange, + idc: c.idc || "", + environment: c.environment || "", + group: c.group || "", + }; + }), + }; +} + +/** + * 按完整路径查找已存在的配置 + */ +function findExistingByPath(configs, fullPath) { + const target = normalizeKey(fullPath); + return configs.find((c) => normalizeKey(c.path || "") === target); +} + +/** + * 将完整路径分解为 key + 已启用 path 维度的值 + * 通过前端剥离维度前缀还原存储 key + */ +function decomposeByDims(project, fullPath, dims) { + let key = normalizeKey(fullPath); + let idc = project.enableIdc ? String(dims.idc || "") : ""; + let environment = project.enableEnvironment ? String(dims.environment || "") : ""; + let group = project.enableGroup ? String(dims.group || "") : ""; + + if (idc && key.startsWith(idc + "/")) { + key = key.substring(idc.length + 1); + } + if (environment && key.startsWith(environment + "/")) { + key = key.substring(environment.length + 1); + } + if (group && key.startsWith(group + "/")) { + key = key.substring(group.length + 1); + } + return { key: normalizeKey(key), idc, environment, group }; +} + +/** + * 尝试解析 JSON 字符串 + * 对于 json 类型值自动从字符串转为对象 + */ +function maybeParseJson(value) { + if (typeof value !== "string") { + return value; + } + const trimmed = value.trim(); + if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]"))) { + try { + return JSON.parse(trimmed); + } catch (e) { + // 解析失败,保持原字符串 + } + } + return value; +} + +/** + * 获取 Registry 客户端 + */ +async function getClient() { + const uConfig = await $context.get("uConfig"); + const baseUrl = uConfig.get("registryUrl"); + if (!baseUrl) { + throw new Error("未配置 Registry 地址,请先运行: kit config registryUrl "); + } + const token = uConfig.get("registryToken") || ""; + return new RegistryClient(baseUrl, token); +} + +/** + * 默认导出文件名 + */ +function defaultExportFile(projectName, prefix) { + const safe = String(projectName).replace(/[^a-zA-Z0-9_-]/g, "_"); + const suffix = prefix ? "-" + String(prefix).replace(/[^a-zA-Z0-9_-]/g, "_") : ""; + return safe + suffix + "-" + new Date().toISOString().slice(0, 10) + ".json"; +} + +async function cmdList(client) { + const projects = await client.listProjects(); + if (!projects || projects.length === 0) { + $logger.info("Registry 上没有项目"); + return; + } + const table = []; + for (const p of projects) { + let configs = []; + try { + configs = await client.listConfigs(p.id); + } catch (e) { + /* 忽略单个项目配置数获取失败 */ + } + table.push({ + ID: p.id, + 名称: p.name, + 说明: p.description, + 机房: p.enableIdc ? "✓" : "✗", + 环境: p.enableEnvironment ? "✓" : "✗", + 分组: p.enableGroup ? "✓" : "✗", + 配置数: configs.length, + }); + } + console.table(table); + $logger.info("共 " + projects.length + " 个项目"); +} + +async function cmdConfig(client, spec, value) { + const { project: projectSpec, path: prefix } = parseProjectPath(spec); + if (!projectSpec) { + throw new Error("缺少 project,用法: kit registry config [value]"); + } + const project = await resolveProject(client, projectSpec); + const { matched } = await getConfigsFiltered(client, project.id, prefix); + + if (value === undefined || value === null) { + if (matched.length === 1 && matched[0].path === prefix) { + const c = matched[0]; + $logger.info(formatValue(c.value, c.type)); + } else { + printConfigTable(matched); + } + return; + } + + if (matched.length === 0) { + throw new Error("配置不存在: " + prefix + ",请先用 kit registry import 导入或在 Registry 界面创建"); + } + if (matched.length > 1) { + throw new Error("路径匹配到多条配置,请指定完整 key: " + prefix); + } + const target = matched[0]; + const newValue = maybeParseJson(value); + const updated = await client.updateConfig(project.id, target.id, { value: newValue }); + $logger.info("设置成功: " + updated.path + " = " + formatValue(updated.value, updated.type)); +} + +async function cmdExport(client, spec, file) { + const { project: projectSpec, path: prefix } = parseProjectPath(spec); + if (!projectSpec) { + throw new Error("缺少 project,用法: kit registry export [file]"); + } + const project = await resolveProject(client, projectSpec); + const { matched } = await getConfigsFiltered(client, project.id, prefix); + + const data = buildExport(project, prefix, matched); + const outFile = file || defaultExportFile(project.name, prefix); + const fs = require("fs"); + fs.writeFileSync(outFile, JSON.stringify(data, null, 4), "utf-8"); + $logger.info("已导出 " + matched.length + " 条配置到 " + outFile); +} + +async function cmdImport(client, spec, file) { + const fs = require("fs"); + if (!file) { + throw new Error("缺少 file,用法: kit registry import "); + } + if (!fs.existsSync(file)) { + throw new Error("文件不存在: " + file); + } + + let data; + try { + const raw = fs.readFileSync(file, "utf-8").replace(/^\uFEFF/, ""); + data = JSON.parse(raw); + } catch (e) { + throw new Error("JSON 解析失败: " + e.message); + } + if (!data || !Array.isArray(data.configs)) { + throw new Error("文件格式不正确,缺少 configs 数组"); + } + + const { project: projectSpec, path: prefix } = parseProjectPath(spec); + if (!projectSpec) { + throw new Error("缺少 project,用法: kit registry import "); + } + const project = await resolveProject(client, projectSpec); + const { all } = await getConfigsFiltered(client, project.id, ""); + + let created = 0; + let updated = 0; + let skipped = 0; + + for (const item of data.configs) { + const baseKey = normalizeKey(item.key); + // 指定前缀时按前缀+相对key 重定位;未指定前缀时沿用导出时的完整路径,保证幂等 + const targetPath = normalizeKey(prefix ? prefix + "/" + baseKey : item.path || baseKey); + if (!targetPath) { + skipped++; + continue; + } + + const existing = findExistingByPath(all, targetPath); + if (existing) { + const patch = { value: item.value }; + if (item.exists !== undefined) { + patch.exists = item.exists !== false; + } + await client.updateConfig(project.id, existing.id, patch); + updated++; + } else { + const { key, idc, environment, group } = decomposeByDims(project, targetPath, item); + await client.createConfig(project.id, { + key, + value: item.value, + type: item.type || "string", + description: item.description || "", + required: item.required === true, + defaultValue: item.defaultValue, + purpose: item.purpose, + valueRange: item.valueRange, + idc, + environment, + group, + }); + created++; + } + } + + $logger.info("导入完成: 新增 " + created + ",更新 " + updated + (skipped ? ",跳过 " + skipped : "") + " 条"); +} + +module.exports = { + init: function () { + return this; + }, + + regTo: function (parser) { + parser.addCmdLine("registry [sub] [project-path] [value]", "Registry 配置管理; sub: list/config/export/import", async function (cli) { + const sub = cli.getParamValue("sub"); + const spec = cli.getParamValue("project-path"); + const value = cli.getParamValue("value"); + + switch (sub) { + case "list": { + const client = await getClient(); + await cmdList(client); + break; + } + case "config": { + const client = await getClient(); + await cmdConfig(client, spec, value); + break; + } + case "export": { + const client = await getClient(); + await cmdExport(client, spec, value); + break; + } + case "import": { + const client = await getClient(); + await cmdImport(client, spec, value); + break; + } + default: + $logger.info(USAGE); + } + }); + }, +}; \ No newline at end of file