#!/usr/bin/env bun const SCHEMA_VERSION = "tjwater-cli/v1"; const DEFAULT_SERVER = "http://192.168.1.114:8000"; const DEFAULT_TIMEOUT = 180; const GROUPS = { network: "管网节点、管线等基础属性查询命令。", component: "组件选项与配置读取命令。", simulation: "模拟运行与调度相关命令。", analysis: "分析计算与诊断相关命令。", data: "时序、SCADA 和方案数据查询命令。", }; const NETWORK_COMMANDS = { "get-junction-properties": { summary: "读取节点属性成功", path: "/getjunctionproperties/", options: { junction: { required: true } }, params: ({ args, ctx }) => ({ network: requireNetwork(ctx), junction: args.junction }), }, "get-pipe-properties": { summary: "读取管道属性成功", path: "/getpipeproperties/", options: { pipe: { required: true } }, params: ({ args, ctx }) => ({ network: requireNetwork(ctx), pipe: args.pipe }), }, "get-all-pipes-properties": { summary: "读取全部管道属性成功", path: "/getallpipeproperties/", params: ({ ctx }) => ({ network: requireNetwork(ctx) }), }, "get-reservoir-properties": { summary: "读取水库属性成功", path: "/getreservoirproperties/", options: { reservoir: { required: true } }, params: ({ args, ctx }) => ({ network: requireNetwork(ctx), reservoir: args.reservoir }), }, "get-all-reservoirs-properties": { summary: "读取全部水库属性成功", path: "/getallreservoirproperties/", params: ({ ctx }) => ({ network: requireNetwork(ctx) }), }, "get-tank-properties": { summary: "读取水箱属性成功", path: "/gettankproperties/", options: { tank: { required: true } }, params: ({ args, ctx }) => ({ network: requireNetwork(ctx), tank: args.tank }), }, "get-all-tanks-properties": { summary: "读取全部水箱属性成功", path: "/getalltankproperties/", params: ({ ctx }) => ({ network: requireNetwork(ctx) }), }, "get-pump-properties": { summary: "读取水泵属性成功", path: "/getpumpproperties/", options: { pump: { required: true } }, params: ({ args, ctx }) => ({ network: requireNetwork(ctx), pump: args.pump }), }, "get-all-pumps-properties": { summary: "读取全部水泵属性成功", path: "/getallpumpproperties/", params: ({ ctx }) => ({ network: requireNetwork(ctx) }), }, "get-valve-properties": { summary: "读取阀门属性成功", path: "/getvalveproperties/", options: { valve: { required: true } }, params: ({ args, ctx }) => ({ network: requireNetwork(ctx), valve: args.valve }), }, "get-all-valves-properties": { summary: "读取全部阀门属性成功", path: "/getallvalveproperties/", params: ({ ctx }) => ({ network: requireNetwork(ctx) }), }, }; const DATA_REALTIME_COMMANDS = { links: { summary: "读取实时管道数据成功", path: "/realtime/links", options: { "start-time": { required: true }, "end-time": { required: true } }, requireProject: true, params: ({ args }) => ({ start_time: parseTime(args["start-time"], "--start-time"), end_time: parseTime(args["end-time"], "--end-time"), }), }, nodes: { summary: "读取实时节点数据成功", path: "/realtime/nodes", options: { "start-time": { required: true }, "end-time": { required: true } }, requireProject: true, params: ({ args }) => ({ start_time: parseTime(args["start-time"], "--start-time"), end_time: parseTime(args["end-time"], "--end-time"), }), }, "simulation-by-id-time": { summary: "读取实时模拟数据成功", path: "/realtime/query/by-id-time", options: { id: { required: true }, type: { required: true }, time: { required: true } }, requireProject: true, params: ({ args }) => ({ id: args.id, type: args.type, query_time: parseTime(args.time, "--time"), }), }, "simulation-by-time-property": { summary: "读取实时属性聚合数据成功", path: "/realtime/query/by-time-property", options: { type: { required: true }, time: { required: true }, property: { required: true } }, requireProject: true, params: ({ args }) => ({ type: args.type, query_time: parseTime(args.time, "--time"), property: args.property, }), }, }; const DATA_SCHEME_COMMANDS = { list: { summary: "读取方案列表成功", path: "/schemes", requireProject: true, params: ({ args }) => maybe({ scheme_type: args["scheme-type"] }), }, get: { summary: "读取方案详情成功", path: ({ positionals }) => `/schemes/${encodeURIComponent(requirePos(positionals, 0, "scheme name"))}`, requireProject: true, params: ({ args }) => maybe({ scheme_type: args["scheme-type"] }), }, links: { summary: "读取方案管道数据成功", path: "/scheme/links", options: { "start-time": { required: true }, "end-time": { required: true } }, requireProject: true, params: ({ args, ctx }) => ({ scheme_name: resolveScheme(ctx, args.scheme, true), scheme_type: args["scheme-type"] ?? "simulation", start_time: parseTime(args["start-time"], "--start-time"), end_time: parseTime(args["end-time"], "--end-time"), }), }, }; const DATA_SCADA_COMMANDS = { list: { summary: "读取 SCADA 元数据成功", path: "/scada/devices", requireProject: true, params: ({ args }) => maybe({ kind: args.kind }), }, query: { summary: "读取 SCADA 时序成功", path: ({ args }) => (args.field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range"), options: { "device-id": { required: true, repeated: true }, "start-time": { required: true }, "end-time": { required: true } }, requireProject: true, params: ({ args }) => ({ device_ids: toArray(args["device-id"]).join(","), start_time: parseTime(args["start-time"], "--start-time"), end_time: parseTime(args["end-time"], "--end-time"), ...maybe({ field: args.field }), }), }, }; async function main() { const parsed = parseArgs(Bun.argv.slice(2)); if (parsed.help || parsed.command.length === 0) { emitHelp(parsed.command); return 0; } if (parsed.command[0] === "help") { emitHelp(parsed.command.slice(1)); return 0; } const ctx = await buildContext(parsed.global); const startedAt = Date.now(); const result = await dispatch(parsed, ctx); const data = await requestJson(ctx, result); emitSuccess({ summary: result.summary, data, ctx, durationMs: Date.now() - startedAt, nextCommands: result.nextCommands, }); return 0; } async function dispatch(parsed, ctx) { const [group, ...rest] = parsed.command; if (group === "network") { return commandFromMap(NETWORK_COMMANDS, rest, parsed, ctx); } if (group === "component" && rest[0] === "option") { return componentOption(rest.slice(1), parsed, ctx); } if (group === "simulation" && rest[0] === "run") { requireOptions(parsed.args, { "start-time": { required: true }, duration: { required: true } }); const start = parseTime(parsed.args["start-time"], "--start-time"); const duration = Number(parsed.args.duration); const endTime = new Date(Date.parse(start) + duration * 60_000).toISOString(); return { summary: "触发模拟成功", method: "POST", path: "/runsimulationmanuallybydate/", body: { name: requireNetwork(ctx), start_time: start, duration }, nextCommands: [ `tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${endTime}`, `tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${endTime}`, ], }; } if (group === "analysis") { return analysis(rest, parsed, ctx); } if (group === "data") { return dataCommand(rest, parsed, ctx); } throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown command: ${parsed.command.join(" ")}`, 2, { next_commands: ["tjwater-cli help"], }); } function commandFromMap(map, commandPath, parsed, ctx) { const name = commandPath[0]; const spec = map[name]; if (!spec) { throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown command: ${parsed.command.join(" ")}`, 2, { next_commands: ["tjwater-cli help"], }); } requireOptions(parsed.args, spec.options ?? {}); const positionals = [...commandPath.slice(1), ...parsed.positionals]; return { summary: spec.summary, method: spec.method ?? "GET", path: valueOf(spec.path, { args: parsed.args, positionals, ctx }), params: spec.params?.({ args: parsed.args, positionals, ctx }) ?? {}, body: spec.body?.({ args: parsed.args, positionals, ctx }), requireProject: spec.requireProject, nextCommands: spec.nextCommands, }; } function componentOption(rest, parsed, ctx) { const action = rest[0]; if (action !== "schema" && action !== "get") { throw cliError("未找到命令", "COMMAND_NOT_FOUND", "unknown component option command", 2); } requireOptions(parsed.args, { kind: { required: true } }); const kind = parsed.args.kind; const routes = { "time:schema": "/gettimeschema", "time:get": "/gettimeproperties/", "energy:schema": "/getenergyschema/", "energy:get": "/getenergyproperties/", "pump-energy:schema": "/getpumpenergyschema/", "pump-energy:get": "/getpumpenergyproperties//", "network:schema": "/getoptionschema/", "network:get": "/getoptionproperties/", }; const path = routes[`${kind}:${action}`]; if (!path) { throw cliError("CLI 参数错误", "INVALID_KIND", "--kind must be one of time, energy, pump-energy, network", 2); } if (kind === "pump-energy" && action === "get" && !parsed.args.pump) { throw cliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2); } return { summary: action === "schema" ? "读取选项 schema 成功" : "读取选项属性成功", method: "GET", path, params: { network: requireNetwork(ctx), ...maybe({ pump: parsed.args.pump }) }, }; } function analysis(rest, parsed, ctx) { const [domain, sub, leaf] = rest; if (domain === "age") { requireOptions(parsed.args, { "start-time": { required: true }, duration: { required: true } }); return { summary: "水龄分析执行成功", method: "GET", path: "/age_analysis/", params: { network: requireNetwork(ctx), start_time: parseTime(parsed.args["start-time"], "--start-time"), duration: Number(parsed.args.duration), }, }; } if (domain === "leakage" && sub === "schemes") { return schemesCommand("漏损", "/leakage/schemes", leaf, rest[3], parsed, ctx); } if (domain === "burst-detection" && sub === "schemes") { return schemesCommand("爆管检测", "/burst-detection/schemes", leaf, rest[3], parsed, ctx); } if (domain === "leakage" && sub === "identify") { return detectionCommand("漏损识别执行成功", "/leakage/identify/", parsed, ctx); } if (domain === "burst-detection" && sub === "detect") { return detectionCommand("爆管检测执行成功", "/burst-detection/detect/", parsed, ctx); } if (domain === "sensor-placement" && sub === "kmeans") { requireOptions(parsed.args, { count: { required: true } }); return { summary: "传感器选址执行成功", method: "POST", path: "/pressure_sensor_placement_kmeans/", body: { name: requireNetwork(ctx), scheme_name: resolveScheme(ctx, parsed.args.scheme, true), sensor_number: Number(parsed.args.count), min_diameter: Number(parsed.args["min-diameter"] ?? 0), username: requireUsername(ctx), }, }; } throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown analysis command: ${rest.join(" ")}`, 2); } function schemesCommand(label, basePath, action, inlineName, parsed, ctx) { if (action === "list") { return { summary: `读取${label}方案列表成功`, method: "GET", path: `${basePath}/`, params: { network: requireNetwork(ctx) }, }; } if (action === "get") { const name = inlineName ?? requirePos(parsed.positionals, 0, "scheme name"); return { summary: `读取${label}方案详情成功`, method: "GET", path: `${basePath}/${encodeURIComponent(name)}`, params: { network: requireNetwork(ctx) }, }; } throw cliError("未找到命令", "COMMAND_NOT_FOUND", "unknown schemes command", 2); } function detectionCommand(summary, path, parsed, ctx) { requireOptions(parsed.args, { "start-time": { required: true }, "end-time": { required: true } }); return { summary, method: "POST", path, body: { network: requireNetwork(ctx), scada_start: parseTime(parsed.args["start-time"], "--start-time"), scada_end: parseTime(parsed.args["end-time"], "--end-time"), scheme_name: resolveScheme(ctx, parsed.args.scheme, true), }, }; } function dataCommand(rest, parsed, ctx) { if (rest[0] === "timeseries" && rest[1] === "realtime") { return commandFromMap(DATA_REALTIME_COMMANDS, rest.slice(2), parsed, ctx); } if (rest[0] === "timeseries" && rest[1] === "scheme") { return commandFromMap(DATA_SCHEME_COMMANDS, rest.slice(2), parsed, ctx); } if (rest[0] === "timeseries" && rest[1] === "scada") { return commandFromMap(DATA_SCADA_COMMANDS, rest.slice(2), parsed, ctx); } if (rest[0] === "scada") { return commandFromMap(DATA_SCADA_COMMANDS, rest.slice(1), parsed, ctx); } if (rest[0] === "scheme") { return commandFromMap(DATA_SCHEME_COMMANDS, rest.slice(1), parsed, ctx); } throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown data command: ${rest.join(" ")}`, 2); } async function requestJson(ctx, request) { if (request.requireProject) { requireProject(ctx); } const url = new URL(valueOf(request.path, { args: {}, positionals: [], ctx }), ctx.server); for (const [key, value] of Object.entries(request.params ?? {})) { if (value === undefined || value === null || value === "") continue; if (Array.isArray(value)) { for (const item of value) url.searchParams.append(key, String(item)); continue; } url.searchParams.set(key, String(value)); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ctx.timeout * 1000); const headers = { Accept: "application/json", "X-Request-Id": ctx.requestId, ...ctx.auth.headers, }; if (ctx.auth.accessToken) headers.Authorization = `Bearer ${ctx.auth.accessToken}`; if (ctx.auth.projectId) headers["X-Project-Id"] = ctx.auth.projectId; if (ctx.auth.userId) headers["X-User-Id"] = ctx.auth.userId; if (request.body !== undefined) headers["Content-Type"] = "application/json"; try { const response = await fetch(url, { method: request.method ?? "GET", headers, body: request.body === undefined ? undefined : JSON.stringify(request.body), signal: controller.signal, }); const text = await response.text(); let data = text; try { data = text ? JSON.parse(text) : null; } catch {} if (!response.ok) { throw cliError("服务端请求失败", "HTTP_ERROR", `server returned HTTP ${response.status}`, response.status >= 500 ? 4 : 2, { retryable: response.status >= 500, data: { status: response.status, body: data }, }); } return data; } catch (error) { if (error?.name === "AbortError") { throw cliError("命令超时", "TIMEOUT", `request timed out after ${ctx.timeout}s`, 4, { retryable: true }); } throw error; } finally { clearTimeout(timeout); } } async function buildContext(global) { const auth = await loadAuth(global.authStdin); return { server: (global.server ?? auth.server ?? DEFAULT_SERVER).replace(/\/+$/, ""), auth, scheme: global.scheme, timeout: Number(global.timeout ?? DEFAULT_TIMEOUT), requestId: global.requestId ?? crypto.randomUUID(), }; } async function loadAuth(authStdin) { if (authStdin) { const raw = await new Response(Bun.stdin.stream()).text(); return normalizeAuth(JSON.parse(raw || "{}")); } const extraHeaders = process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {}; return normalizeAuth({ server: process.env.TJWATER_SERVER, access_token: process.env.TJWATER_ACCESS_TOKEN, project_id: process.env.TJWATER_PROJECT_ID, user_id: process.env.TJWATER_USER_ID, username: process.env.TJWATER_USERNAME, network: process.env.TJWATER_NETWORK, headers: extraHeaders, }); } function normalizeAuth(raw) { return { server: pick(raw, "server", "base_url"), accessToken: pick(raw, "access_token", "token", "accessToken"), projectId: pick(raw, "project_id", "projectId", "x_project_id"), userId: pick(raw, "user_id", "userId", "x_user_id"), username: pick(raw, "username", "preferred_username"), network: pick(raw, "network", "project_code", "projectCode", "project"), headers: typeof raw.headers === "object" && raw.headers !== null ? raw.headers : {}, }; } function parseArgs(argv) { const global = {}; const command = []; const args = {}; const positionals = []; let help = false; let commandStarted = false; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (token === "--help" || token === "-h") { help = true; continue; } if (!commandStarted && token.startsWith("--")) { const key = camel(token.slice(2)); if (["authStdin"].includes(key)) { global[key] = true; } else { global[key] = argv[++i]; } continue; } commandStarted = true; if (token.startsWith("--")) { const key = token.slice(2); const next = argv[i + 1]; const value = next && !next.startsWith("--") ? argv[++i] : true; if (args[key] === undefined) { args[key] = value; } else if (Array.isArray(args[key])) { args[key].push(value); } else { args[key] = [args[key], value]; } continue; } if (Object.keys(args).length === 0 && positionals.length === 0) { command.push(token); } else { positionals.push(token); } } return { global, command, args, positionals, help }; } function requireOptions(args, spec) { for (const [name, option] of Object.entries(spec)) { if (option.required && (args[name] === undefined || args[name] === "")) { throw cliError("CLI 参数错误", "MISSING_OPTION", `missing required option --${name}`, 2); } } } function emitHelp(command = []) { const payload = { ok: true, schema_version: SCHEMA_VERSION, summary: command.length ? `命令帮助:${command.join(" ")}` : "TJWater agent CLI", data: { command: command.join(" "), groups: GROUPS, examples: [ "tjwater-cli help", "tjwater-cli network get-all-pipes-properties", "tjwater-cli data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00", "tjwater-cli analysis leakage schemes list", ], }, }; console.log(JSON.stringify(payload, null, 2)); } function emitSuccess({ summary, data, ctx, durationMs, nextCommands }) { console.log(JSON.stringify({ ok: true, schema_version: SCHEMA_VERSION, summary, data, metadata: { server: ctx.server, request_id: ctx.requestId, duration_ms: durationMs, }, next_commands: nextCommands, })); } function emitFailure(error) { const payload = { ok: false, schema_version: SCHEMA_VERSION, summary: error.summary ?? "命令失败", error: { code: error.code ?? "UNKNOWN", message: error.message ?? String(error), retryable: Boolean(error.retryable), }, data: error.data, next_commands: error.next_commands, }; console.error(JSON.stringify(payload)); return error.exitCode ?? 1; } function cliError(summary, code, message, exitCode, extra = {}) { const error = new Error(message); error.summary = summary; error.code = code; error.exitCode = exitCode; Object.assign(error, extra); return error; } function requireNetwork(ctx) { if (ctx.auth.network) return ctx.auth.network; throw cliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context", 3); } function requireProject(ctx) { if (ctx.auth.projectId) return ctx.auth.projectId; throw cliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3); } function requireUsername(ctx) { if (ctx.auth.username) return ctx.auth.username; throw cliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3); } function resolveScheme(ctx, explicit, required) { const scheme = explicit ?? ctx.scheme; if (required && !scheme) { throw cliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2); } return scheme; } function parseTime(value, optionName) { if (!value || Number.isNaN(Date.parse(value)) || !/[zZ]|[+-]\d\d:\d\d$/.test(value)) { throw cliError("CLI 参数错误", "INVALID_TIME", `${optionName} must be RFC3339 with timezone`, 2); } return value; } function requirePos(values, index, name) { const value = values[index]; if (!value) throw cliError("CLI 参数错误", "MISSING_ARGUMENT", `missing ${name}`, 2); return value; } function pick(raw, ...keys) { for (const key of keys) { if (raw?.[key] !== undefined && raw[key] !== "") return raw[key]; } return undefined; } function valueOf(value, args) { return typeof value === "function" ? value(args) : value; } function maybe(values) { return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== "")); } function toArray(value) { return Array.isArray(value) ? value : value === undefined ? [] : [value]; } function camel(value) { return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); } main() .then((code) => process.exit(code)) .catch((error) => process.exit(emitFailure(error)));