refactor(cli): split tjwater cli modules
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
export const SCHEMA_VERSION = "tjwater-cli/v1";
|
||||
export const DEFAULT_TIMEOUT = 180;
|
||||
export const DEFAULT_SERVER = "http://192.168.1.114:8000";
|
||||
|
||||
export const PIPE_FIELDS = ["flow", "friction", "headloss", "quality", "reaction", "setting", "status", "velocity"] as const;
|
||||
export const JUNCTION_FIELDS = ["actual_demand", "total_head", "pressure", "quality"] as const;
|
||||
export const SCADA_FIELDS = ["monitored_value", "cleaned_value"] as const;
|
||||
|
||||
export type ElementType = "pipe" | "junction";
|
||||
export type PipeField = (typeof PIPE_FIELDS)[number];
|
||||
export type JunctionField = (typeof JUNCTION_FIELDS)[number];
|
||||
export type ScadaField = (typeof SCADA_FIELDS)[number];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export class CliError extends Error {
|
||||
summary: string;
|
||||
code: string;
|
||||
exitCode: number;
|
||||
retryable: boolean;
|
||||
data: unknown;
|
||||
nextCommands: string[];
|
||||
|
||||
constructor(
|
||||
summary: string,
|
||||
code: string,
|
||||
message: string,
|
||||
exitCode = 2,
|
||||
retryable = false,
|
||||
data: unknown = null,
|
||||
nextCommands: string[] = [],
|
||||
) {
|
||||
super(message);
|
||||
this.summary = summary;
|
||||
this.code = code;
|
||||
this.exitCode = exitCode;
|
||||
this.retryable = retryable;
|
||||
this.data = data;
|
||||
this.nextCommands = nextCommands;
|
||||
}
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CliError } from "./errors.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function readJsonFile(path: string, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
throw new CliError("CLI 参数错误", "INPUT_NOT_FOUND", `${label} file not found: ${path}`, 2);
|
||||
}
|
||||
if (error instanceof SyntaxError) throw new CliError("CLI 参数错误", "INPUT_INVALID_JSON", `${label} file must be valid JSON: ${path}`, 2);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBurstFile(path: string): [string[], number[]] {
|
||||
let raw = readJsonFile(path, "burst");
|
||||
if (isRecord(raw) && "bursts" in raw) raw = raw.bursts;
|
||||
if (isRecord(raw) && "burst_ID" in raw && "burst_size" in raw && Array.isArray(raw.burst_ID) && Array.isArray(raw.burst_size)) {
|
||||
const ids = raw.burst_ID.map(String);
|
||||
const sizes = raw.burst_size.map(Number);
|
||||
if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_ID and burst_size must have the same length", 2);
|
||||
return [ids, sizes];
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return [
|
||||
raw.map((item) => {
|
||||
if (!isRecord(item) || !("id" in item) || !("size" in item)) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file items must contain id and size", 2);
|
||||
return String(item.id);
|
||||
}),
|
||||
raw.map((item) => Number((item as Record<string, unknown>).size)),
|
||||
];
|
||||
}
|
||||
throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_ID/burst_size", 2);
|
||||
}
|
||||
|
||||
export function parseValveSettingFile(path: string): [string[], number[]] {
|
||||
const raw = readJsonFile(path, "valve-setting");
|
||||
if (isRecord(raw) && "valves" in raw && "valves_k" in raw && Array.isArray(raw.valves) && Array.isArray(raw.valves_k)) {
|
||||
const valves = raw.valves.map(String);
|
||||
const openings = raw.valves_k.map(Number);
|
||||
if (valves.length !== openings.length) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valves and valves_k must have the same length", 2);
|
||||
return [valves, openings];
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return [
|
||||
raw.map((item) => {
|
||||
if (!isRecord(item) || !("valve" in item) || !("opening" in item)) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting items must contain valve and opening", 2);
|
||||
return String(item.valve);
|
||||
}),
|
||||
raw.map((item) => Number((item as Record<string, unknown>).opening)),
|
||||
];
|
||||
}
|
||||
throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting file must be a JSON array or object with valves/valves_k", 2);
|
||||
}
|
||||
|
||||
export function assignDatasetKeys(target: Record<string, unknown>, path: string, keys: string[], label: string): void {
|
||||
const payload = readJsonFile(path, label);
|
||||
if (isRecord(payload)) {
|
||||
for (const key of keys) {
|
||||
if (key in payload) target[key] = payload[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { CliError, errorMessage } from "./errors.js";
|
||||
import { requireNetwork, requireUsername } from "./runtime.js";
|
||||
import { success } from "./output.js";
|
||||
import type { RequestOptions, RuntimeContext } from "./types.js";
|
||||
|
||||
function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: boolean): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"X-Request-Id": ctx.requestId,
|
||||
...ctx.auth.headers,
|
||||
};
|
||||
if (requireAuth) {
|
||||
if (!ctx.auth.accessToken) {
|
||||
throw new CliError("认证失败", "UNAUTHENTICATED", "missing access token for agent context", 3, false, null, ["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"]);
|
||||
}
|
||||
out.Authorization = `Bearer ${ctx.auth.accessToken}`;
|
||||
} else if (ctx.auth.accessToken) out.Authorization = `Bearer ${ctx.auth.accessToken}`;
|
||||
if (requireProject) {
|
||||
if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]);
|
||||
out["X-Project-Id"] = ctx.auth.projectId;
|
||||
} else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId;
|
||||
if (ctx.auth.userId) out["X-User-Id"] = ctx.auth.userId;
|
||||
return out;
|
||||
}
|
||||
|
||||
function stringifyParam(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "True" : "False";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function appendParams(url: URL, params: Record<string, unknown> = {}): void {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, stringifyParam(item)));
|
||||
else url.searchParams.set(key, stringifyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
|
||||
const { method, path, params, body, requireAuth = true, requireProject = false, requireNetworkCtx = false, requireUsernameCtx = false } = request;
|
||||
if (requireNetworkCtx) requireNetwork(ctx);
|
||||
if (requireUsernameCtx) requireUsername(ctx);
|
||||
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
|
||||
appendParams(url, params);
|
||||
const started = performance.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
...headers(ctx, requireAuth, requireProject),
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") throw new CliError("请求超时", "REQUEST_TIMEOUT", `request timed out after ${ctx.timeout} seconds`, 7, true);
|
||||
throw new CliError("连接失败", "REQUEST_FAILED", errorMessage(error), 7, true);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const durationMs = Math.trunc(performance.now() - started);
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
const text = response.status === 204 ? "" : await response.text();
|
||||
let payload: unknown = {};
|
||||
if (contentType.includes("application/json") && text) payload = JSON.parse(text);
|
||||
else if (text) payload = { report: text };
|
||||
if (!response.ok) {
|
||||
const record = payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record<string, unknown>) : {};
|
||||
const message = typeof record.detail === "string" ? record.detail : typeof record.message === "string" ? record.message : text || `http ${response.status}`;
|
||||
throw new CliError("请求失败", `HTTP_${response.status}`, message, mapStatus(response.status), response.status >= 500);
|
||||
}
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (record.status === "error") throw new CliError("服务端错误", "SERVER_ERROR", String(record.message || "server returned error status"), 7, false, payload);
|
||||
}
|
||||
return [payload, durationMs];
|
||||
}
|
||||
|
||||
function mapStatus(status: number): number {
|
||||
if (status === 400 || status === 422) return 2;
|
||||
if (status === 401) return 3;
|
||||
if (status === 403) return 4;
|
||||
if (status === 404) return 5;
|
||||
if (status === 409 || status === 412) return 6;
|
||||
return 7;
|
||||
}
|
||||
|
||||
export async function emitApi(ctx: RuntimeContext, summary: string, request: RequestOptions, nextCommands: string[] = []): Promise<void> {
|
||||
const [data, durationMs] = await requestJson(ctx, request);
|
||||
success(summary, data, ctx, durationMs, nextCommands);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { JUNCTION_FIELDS, PIPE_FIELDS, type ElementType } from "./constants.js";
|
||||
import { CliError } from "./errors.js";
|
||||
import type { OptionSchema, ParsedOptionValue, ParsedOptions } from "./types.js";
|
||||
|
||||
export function requireValue(argv: string[], index: number, option: string): string {
|
||||
const value = argv[index];
|
||||
if (!value || value.startsWith("--")) throw new CliError("CLI 参数错误", "MISSING_OPTION_VALUE", `${option} requires a value`, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseIntStrict(value: string, option: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_INTEGER", `${option} must be an integer`, 2);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseFloatStrict(value: string, option: string): number {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_NUMBER", `${option} must be a number`, 2);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseOptions(argv: string[], schema: OptionSchema = {}): ParsedOptions {
|
||||
const values: Record<string, ParsedOptionValue> = {};
|
||||
const positionals: string[] = [];
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!;
|
||||
if (!arg.startsWith("--")) {
|
||||
positionals.push(arg);
|
||||
continue;
|
||||
}
|
||||
const name = arg.slice(2);
|
||||
const kind = schema[name] ?? "string";
|
||||
if (kind === "boolean") {
|
||||
values[name] = true;
|
||||
} else {
|
||||
const raw = requireValue(argv, ++i, arg);
|
||||
if (kind === "repeat") values[name] = [...asStringArray(values[name]), raw];
|
||||
else if (kind === "number") values[name] = parseFloatStrict(raw, arg);
|
||||
else if (kind === "integer") values[name] = parseIntStrict(raw, arg);
|
||||
else values[name] = raw;
|
||||
}
|
||||
}
|
||||
return { values, positionals };
|
||||
}
|
||||
|
||||
export function required(values: Record<string, ParsedOptionValue>, name: string): string | number | boolean | string[] {
|
||||
const value = values[name];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
throw new CliError("CLI 参数错误", "MISSING_PARAMETER", `Missing option '--${name}'`, 2);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requiredString(values: Record<string, ParsedOptionValue>, name: string): string {
|
||||
const value = required(values, name);
|
||||
if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requiredNumber(values: Record<string, ParsedOptionValue>, name: string): number {
|
||||
const value = required(values, name);
|
||||
if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requiredStringArray(values: Record<string, ParsedOptionValue>, name: string): string[] {
|
||||
const value = required(values, name);
|
||||
if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalString(values: Record<string, ParsedOptionValue>, name: string): string | undefined {
|
||||
const value = values[name];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalNumber(values: Record<string, ParsedOptionValue>, name: string): number | undefined {
|
||||
const value = values[name];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalStringArray(values: Record<string, ParsedOptionValue>, name: string): string[] | undefined {
|
||||
const value = values[name];
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function asStringArray(value: ParsedOptionValue | undefined): string[] {
|
||||
if (value === undefined) return [];
|
||||
if (Array.isArray(value)) return value;
|
||||
throw new CliError("CLI 参数错误", "INVALID_PARAMETER", "repeat option received a non-array value", 2);
|
||||
}
|
||||
|
||||
export function validateChoice<T extends readonly string[]>(value: string, valid: T, option: string): T[number] {
|
||||
if (!valid.includes(value)) {
|
||||
throw new CliError("CLI 参数错误", `INVALID_${option.replace(/^--/, "").replaceAll("-", "_").toUpperCase()}`, `${option} must be one of: ${valid.join(", ")}`, 2);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
export function fieldsFor(type: ElementType): typeof PIPE_FIELDS | typeof JUNCTION_FIELDS {
|
||||
if (type === "pipe") return PIPE_FIELDS;
|
||||
if (type === "junction") return JUNCTION_FIELDS;
|
||||
throw new CliError("CLI 参数错误", "INVALID_TYPE", "--type must be one of: pipe, junction", 2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { SCHEMA_VERSION } from "./constants.js";
|
||||
import type { RuntimeContext } from "./types.js";
|
||||
|
||||
export function json(value: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
export function generatedAt(): string {
|
||||
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
export function success(summary: string, data: unknown, ctx: RuntimeContext, durationMs: number, nextCommands: string[] = []): void {
|
||||
json({
|
||||
ok: true,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
summary,
|
||||
data,
|
||||
metadata: {
|
||||
request_id: ctx.requestId,
|
||||
server: ctx.server,
|
||||
duration_ms: durationMs,
|
||||
generated_at: generatedAt(),
|
||||
},
|
||||
next_commands: nextCommands,
|
||||
});
|
||||
}
|
||||
|
||||
export function failure({
|
||||
summary,
|
||||
code,
|
||||
message,
|
||||
retryable = false,
|
||||
server = null,
|
||||
requestId = null,
|
||||
data = null,
|
||||
nextCommands = [],
|
||||
}: {
|
||||
summary: string;
|
||||
code: string;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
server?: string | null;
|
||||
requestId?: string | null;
|
||||
data?: unknown;
|
||||
nextCommands?: string[];
|
||||
}): void {
|
||||
json({
|
||||
ok: false,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
summary,
|
||||
error: { code, message, retryable },
|
||||
data,
|
||||
metadata: {
|
||||
request_id: requestId,
|
||||
server,
|
||||
generated_at: generatedAt(),
|
||||
},
|
||||
next_commands: nextCommands,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { DEFAULT_SERVER, DEFAULT_TIMEOUT } from "./constants.js";
|
||||
import { CliError } from "./errors.js";
|
||||
import { requireValue, parseIntStrict } from "./options.js";
|
||||
import type { AuthContext, GlobalArgs, ParsedGlobalArgs, RuntimeContext } from "./types.js";
|
||||
|
||||
function pick(source: Record<string, unknown>, ...keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value !== undefined && value !== null && value !== "") return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => resolve(body));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadAuthContext(authStdin: boolean): Promise<AuthContext> {
|
||||
const raw = authStdin
|
||||
? (JSON.parse(await readStdin()) as Record<string, unknown>)
|
||||
: {
|
||||
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: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
|
||||
};
|
||||
const headers = (raw.headers ?? {}) as unknown;
|
||||
if (!headers || Array.isArray(headers) || typeof headers !== "object") {
|
||||
throw new CliError("认证失败", "AUTH_CONTEXT_INVALID", "auth context headers must be a JSON object", 3);
|
||||
}
|
||||
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: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGlobalArgs(argv: string[]): ParsedGlobalArgs {
|
||||
const globals: GlobalArgs = {
|
||||
server: null,
|
||||
authStdin: false,
|
||||
scheme: null,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
requestId: null,
|
||||
};
|
||||
const rest: string[] = [];
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--auth-stdin") globals.authStdin = true;
|
||||
else if (arg === "--server") globals.server = requireValue(argv, ++i, "--server");
|
||||
else if (arg === "--scheme") globals.scheme = requireValue(argv, ++i, "--scheme");
|
||||
else if (arg === "--timeout") globals.timeout = parseIntStrict(requireValue(argv, ++i, "--timeout"), "--timeout");
|
||||
else if (arg === "--request-id") globals.requestId = requireValue(argv, ++i, "--request-id");
|
||||
else rest.push(arg);
|
||||
}
|
||||
return { globals, rest };
|
||||
}
|
||||
|
||||
export async function buildRuntime(globals: GlobalArgs): Promise<RuntimeContext> {
|
||||
const auth = await loadAuthContext(globals.authStdin);
|
||||
return {
|
||||
server: globals.server || auth.server || DEFAULT_SERVER,
|
||||
auth,
|
||||
scheme: globals.scheme,
|
||||
timeout: globals.timeout,
|
||||
requestId: globals.requestId || randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
export function requireNetwork(ctx: RuntimeContext): string {
|
||||
if (ctx.auth.network) return ctx.auth.network;
|
||||
throw new CliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context for legacy network-based endpoints", 3, false, null, ["add network to auth context"]);
|
||||
}
|
||||
|
||||
export function requireUsername(ctx: RuntimeContext): string {
|
||||
if (ctx.auth.username) return ctx.auth.username;
|
||||
throw new CliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3, false, null, ["add username to auth context"]);
|
||||
}
|
||||
|
||||
export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null {
|
||||
const scheme = explicit || ctx.scheme;
|
||||
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
|
||||
return scheme ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CliError } from "./errors.js";
|
||||
|
||||
export function parseTime(value: string, option: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new CliError("CLI 参数错误", "INVALID_TIME", `${option} must be a valid ISO 8601 / RFC 3339 timestamp`, 2);
|
||||
}
|
||||
if (!/[zZ]|[+-]\d\d:\d\d$/.test(value)) {
|
||||
throw new CliError("CLI 参数错误", "TIMEZONE_REQUIRED", `${option} must include an explicit timezone offset`, 2);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function addMinutesPreservingOffset(value: string, minutes: number): string {
|
||||
const match = value.match(/([+-])(\d\d):(\d\d)$/);
|
||||
const end = new Date(new Date(value).getTime() + minutes * 60_000);
|
||||
if (!match) return end.toISOString().replace(".000Z", "Z");
|
||||
const sign = match[1] === "+" ? 1 : -1;
|
||||
const offsetMinutes = sign * (Number(match[2]) * 60 + Number(match[3]));
|
||||
const local = new Date(end.getTime() + offsetMinutes * 60_000);
|
||||
return `${local.toISOString().replace(".000Z", "")}${match[1]}${match[2]}:${match[3]}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type Dict<T = unknown> = Record<string, T>;
|
||||
|
||||
export interface AuthContext {
|
||||
server: string | null;
|
||||
accessToken: string | null;
|
||||
projectId: string | null;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
network: string | null;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RuntimeContext {
|
||||
server: string;
|
||||
auth: AuthContext;
|
||||
scheme: string | null;
|
||||
timeout: number;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface GlobalArgs {
|
||||
server: string | null;
|
||||
authStdin: boolean;
|
||||
scheme: string | null;
|
||||
timeout: number;
|
||||
requestId: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedGlobalArgs {
|
||||
globals: GlobalArgs;
|
||||
rest: string[];
|
||||
}
|
||||
|
||||
export type OptionKind = "string" | "boolean" | "repeat" | "number" | "integer";
|
||||
export type OptionSchema = Record<string, OptionKind>;
|
||||
export type ParsedOptionValue = string | number | boolean | string[];
|
||||
|
||||
export interface ParsedOptions {
|
||||
values: Record<string, ParsedOptionValue>;
|
||||
positionals: string[];
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method: string;
|
||||
path: string;
|
||||
params?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
requireAuth?: boolean;
|
||||
requireProject?: boolean;
|
||||
requireNetworkCtx?: boolean;
|
||||
requireUsernameCtx?: boolean;
|
||||
}
|
||||
|
||||
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
|
||||
export type HandlerMap = Record<string, Handler>;
|
||||
|
||||
export interface CommandDoc {
|
||||
ok: true;
|
||||
schema_version: string;
|
||||
command: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
usage: string;
|
||||
options: CommandOptionDoc[];
|
||||
examples: string[];
|
||||
output: string;
|
||||
next_commands: string[];
|
||||
}
|
||||
|
||||
export interface CommandOptionDoc {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
repeated: boolean;
|
||||
default: null;
|
||||
}
|
||||
|
||||
export type HelpPayload =
|
||||
| CommandDoc
|
||||
| {
|
||||
ok: true;
|
||||
schema_version: string;
|
||||
summary: string;
|
||||
menu_level?: number;
|
||||
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user