来源:紫鸟帮助中心/博客/主站资讯 + HubStudio API 文档/帮助中心 + 三个官方 skill 仓库。 只入库 Markdown 与元数据;原始 HTML 与 tarball 留在本机。
605 lines
20 KiB
JavaScript
605 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const YAML = require("yaml");
|
|
|
|
const GENERATED_PATH = path.join(__dirname, "commands.generated.json");
|
|
const OPENAPI_PATH = path.join(__dirname, "openapi.yaml");
|
|
const generated = JSON.parse(fs.readFileSync(GENERATED_PATH, "utf8"));
|
|
const BASE_URL = process.env.HUBSTUDIO_BASE_URL || generated.baseUrl || "http://127.0.0.1:6873";
|
|
const COMMANDS = generated.commands || [];
|
|
const COMMAND_MAP = new Map(COMMANDS.map((c) => [c.command, c]));
|
|
|
|
const ALIASES = {
|
|
browserCreate: "postV1EnvCreate",
|
|
browserStart: "postV1BrowserStart",
|
|
browserStop: "postV1BrowserStop",
|
|
browserStatus: "postV1BrowserAllBrowserStatus",
|
|
browserForeground: "postV1BrowserForeground",
|
|
browserStopAll: "postV1BrowserStopAll",
|
|
displayAll: "postV1DisplayAll",
|
|
browserArrange: "postV1BrowserArrange",
|
|
};
|
|
|
|
class InputValidationError extends Error {
|
|
constructor(message, fieldErrors = [], command = null) {
|
|
super(message);
|
|
this.name = "InputValidationError";
|
|
this.code = "VALIDATION_ERROR";
|
|
this.fieldErrors = fieldErrors;
|
|
this.command = command;
|
|
}
|
|
}
|
|
|
|
function loadOpenApi() {
|
|
try {
|
|
const content = fs.readFileSync(OPENAPI_PATH, "utf8");
|
|
return YAML.parse(content) || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
const OPENAPI = loadOpenApi();
|
|
|
|
function resolveSchema(schema) {
|
|
if (!schema || typeof schema !== "object") {
|
|
return schema;
|
|
}
|
|
if (!schema.$ref || typeof schema.$ref !== "string") {
|
|
return schema;
|
|
}
|
|
if (!schema.$ref.startsWith("#/")) {
|
|
return schema;
|
|
}
|
|
|
|
const parts = schema.$ref
|
|
.replace(/^#\//, "")
|
|
.split("/")
|
|
.map((x) => x.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
let current = OPENAPI;
|
|
for (const part of parts) {
|
|
if (!current || typeof current !== "object" || !(part in current)) {
|
|
return schema;
|
|
}
|
|
current = current[part];
|
|
}
|
|
if (!current || typeof current !== "object") {
|
|
return schema;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function getOperationSchema(method, endpointPath) {
|
|
const methodKey = String(method || "post").toLowerCase();
|
|
const op = OPENAPI && OPENAPI.paths && OPENAPI.paths[endpointPath] && OPENAPI.paths[endpointPath][methodKey];
|
|
if (!op) {
|
|
return { querySchema: null, bodySchema: null, requestBodyRequired: false };
|
|
}
|
|
|
|
const queryParams = Array.isArray(op.parameters) ? op.parameters.filter((p) => p && p.in === "query") : [];
|
|
const queryProperties = {};
|
|
const queryRequired = [];
|
|
for (const param of queryParams) {
|
|
if (!param || !param.name) continue;
|
|
queryProperties[param.name] = resolveSchema(param.schema || {});
|
|
if (param.required) {
|
|
queryRequired.push(param.name);
|
|
}
|
|
}
|
|
|
|
const querySchema = queryParams.length
|
|
? {
|
|
type: "object",
|
|
properties: queryProperties,
|
|
required: queryRequired,
|
|
}
|
|
: null;
|
|
|
|
const jsonBodySchema =
|
|
op.requestBody &&
|
|
op.requestBody.content &&
|
|
op.requestBody.content["application/json"] &&
|
|
op.requestBody.content["application/json"].schema
|
|
? resolveSchema(op.requestBody.content["application/json"].schema)
|
|
: null;
|
|
|
|
return {
|
|
querySchema,
|
|
bodySchema: jsonBodySchema,
|
|
requestBodyRequired: Boolean(op.requestBody && op.requestBody.required),
|
|
};
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function isEmptyValue(value) {
|
|
if (value === undefined || value === null) return true;
|
|
if (typeof value === "string") return value.trim().length === 0;
|
|
if (Array.isArray(value)) return value.length === 0;
|
|
if (isPlainObject(value)) return Object.keys(value).length === 0;
|
|
return false;
|
|
}
|
|
|
|
function pushValidationError(errors, field, reason, expected, actual) {
|
|
errors.push({
|
|
field,
|
|
reason,
|
|
expected,
|
|
actualType: actual,
|
|
});
|
|
}
|
|
|
|
function validateStringFormat(value, schema, fieldPath, errors) {
|
|
const format = schema.format;
|
|
if (!format) return;
|
|
|
|
if (format === "email") {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(value)) {
|
|
pushValidationError(errors, fieldPath, "invalid email format", "email", "string");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (format === "uri" || format === "url") {
|
|
try {
|
|
new URL(value);
|
|
} catch {
|
|
pushValidationError(errors, fieldPath, "invalid URL format", "url", "string");
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateBySchema(value, schema, fieldPath, errors, options = {}) {
|
|
const resolved = resolveSchema(schema || {});
|
|
if (!resolved || typeof resolved !== "object") return;
|
|
|
|
if (value === undefined) return;
|
|
if (value === null) {
|
|
if (resolved.nullable || resolved.type === "null") return;
|
|
pushValidationError(errors, fieldPath, "value cannot be null", resolved.type || "non-null", "null");
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(resolved.type)) {
|
|
const typePass = resolved.type.some((oneType) => {
|
|
const localErrors = [];
|
|
validateBySchema(value, { ...resolved, type: oneType }, fieldPath, localErrors, options);
|
|
return localErrors.length === 0;
|
|
});
|
|
if (!typePass) {
|
|
pushValidationError(errors, fieldPath, "type mismatch", resolved.type.join("|"), typeof value);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const inferredType = resolved.type || (resolved.properties ? "object" : undefined);
|
|
if (!inferredType) return;
|
|
|
|
if (inferredType === "string") {
|
|
if (typeof value !== "string") {
|
|
pushValidationError(errors, fieldPath, "type mismatch", "string", Array.isArray(value) ? "array" : typeof value);
|
|
return;
|
|
}
|
|
if (typeof resolved.minLength === "number" && value.length < resolved.minLength) {
|
|
pushValidationError(errors, fieldPath, "string too short", `minLength=${resolved.minLength}`, "string");
|
|
}
|
|
if (typeof resolved.maxLength === "number" && value.length > resolved.maxLength) {
|
|
pushValidationError(errors, fieldPath, "string too long", `maxLength=${resolved.maxLength}`, "string");
|
|
}
|
|
if (resolved.pattern) {
|
|
try {
|
|
const reg = new RegExp(resolved.pattern);
|
|
if (!reg.test(value)) {
|
|
pushValidationError(errors, fieldPath, "string pattern mismatch", resolved.pattern, "string");
|
|
}
|
|
} catch {
|
|
// Ignore invalid server-side regex pattern definitions.
|
|
}
|
|
}
|
|
validateStringFormat(value, resolved, fieldPath, errors);
|
|
} else if (inferredType === "number" || inferredType === "integer") {
|
|
if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) {
|
|
pushValidationError(errors, fieldPath, "type mismatch", inferredType, Array.isArray(value) ? "array" : typeof value);
|
|
return;
|
|
}
|
|
if (inferredType === "integer" && !Number.isInteger(value)) {
|
|
pushValidationError(errors, fieldPath, "type mismatch", "integer", "number");
|
|
}
|
|
if (typeof resolved.minimum === "number" && value < resolved.minimum) {
|
|
pushValidationError(errors, fieldPath, "number below minimum", `>=${resolved.minimum}`, "number");
|
|
}
|
|
if (typeof resolved.maximum === "number" && value > resolved.maximum) {
|
|
pushValidationError(errors, fieldPath, "number above maximum", `<=${resolved.maximum}`, "number");
|
|
}
|
|
if (typeof resolved.exclusiveMinimum === "number" && value <= resolved.exclusiveMinimum) {
|
|
pushValidationError(errors, fieldPath, "number below exclusiveMinimum", `>${resolved.exclusiveMinimum}`, "number");
|
|
}
|
|
if (typeof resolved.exclusiveMaximum === "number" && value >= resolved.exclusiveMaximum) {
|
|
pushValidationError(errors, fieldPath, "number above exclusiveMaximum", `<${resolved.exclusiveMaximum}`, "number");
|
|
}
|
|
} else if (inferredType === "boolean") {
|
|
if (typeof value !== "boolean") {
|
|
pushValidationError(errors, fieldPath, "type mismatch", "boolean", Array.isArray(value) ? "array" : typeof value);
|
|
return;
|
|
}
|
|
} else if (inferredType === "array") {
|
|
if (!Array.isArray(value)) {
|
|
pushValidationError(errors, fieldPath, "type mismatch", "array", typeof value);
|
|
return;
|
|
}
|
|
if (typeof resolved.minItems === "number" && value.length < resolved.minItems) {
|
|
pushValidationError(errors, fieldPath, "array too short", `minItems=${resolved.minItems}`, "array");
|
|
}
|
|
if (typeof resolved.maxItems === "number" && value.length > resolved.maxItems) {
|
|
pushValidationError(errors, fieldPath, "array too long", `maxItems=${resolved.maxItems}`, "array");
|
|
}
|
|
if (resolved.items) {
|
|
value.forEach((item, idx) => {
|
|
validateBySchema(item, resolved.items, `${fieldPath}[${idx}]`, errors, options);
|
|
});
|
|
}
|
|
} else if (inferredType === "object") {
|
|
if (!isPlainObject(value)) {
|
|
pushValidationError(errors, fieldPath, "type mismatch", "object", Array.isArray(value) ? "array" : typeof value);
|
|
return;
|
|
}
|
|
const properties = resolved.properties || {};
|
|
const required = Array.isArray(resolved.required) ? resolved.required : [];
|
|
|
|
for (const reqKey of required) {
|
|
const reqPath = fieldPath ? `${fieldPath}.${reqKey}` : reqKey;
|
|
if (!(reqKey in value) || isEmptyValue(value[reqKey])) {
|
|
pushValidationError(errors, reqPath, "required field missing or empty", "required", "missing");
|
|
}
|
|
}
|
|
|
|
for (const key of Object.keys(value)) {
|
|
const childPath = fieldPath ? `${fieldPath}.${key}` : key;
|
|
if (!(key in properties)) {
|
|
if (options.allowUnknownFields) {
|
|
continue;
|
|
}
|
|
pushValidationError(errors, childPath, "unknown field", "defined property", "unknown");
|
|
continue;
|
|
}
|
|
validateBySchema(value[key], properties[key], childPath, errors, options);
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
|
|
const enumPass = resolved.enum.some((x) => x === value);
|
|
if (!enumPass) {
|
|
pushValidationError(errors, fieldPath, "enum mismatch", JSON.stringify(resolved.enum), typeof value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateCommandInputs(commandName, generatedCmd, body, query, strictValidation = true) {
|
|
const errors = [];
|
|
const { querySchema, bodySchema, requestBodyRequired } = getOperationSchema(generatedCmd.method, generatedCmd.path);
|
|
const options = { allowUnknownFields: !strictValidation };
|
|
|
|
if (requestBodyRequired && isEmptyValue(body)) {
|
|
pushValidationError(errors, "body", "required request body missing or empty", "non-empty object", "missing");
|
|
}
|
|
|
|
if (querySchema) {
|
|
validateBySchema(query || {}, querySchema, "query", errors, options);
|
|
} else if (query && isPlainObject(query) && Object.keys(query).length > 0) {
|
|
pushValidationError(errors, "query", "unexpected query parameters", "no query params", "object");
|
|
}
|
|
|
|
if (bodySchema) {
|
|
validateBySchema(body || {}, bodySchema, "body", errors, options);
|
|
} else if (body && isPlainObject(body) && Object.keys(body).length > 0) {
|
|
pushValidationError(errors, "body", "unexpected request body", "no body", "object");
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
throw new InputValidationError("Request validation failed", errors, commandName);
|
|
}
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`HubStudio CLI
|
|
|
|
Usage:
|
|
node hubstudio.js <command> [args] [--body '{"k":"v"}'] [--query '{"k":"v"}'] [--strict true|false]
|
|
|
|
Commands:
|
|
browserCreate [name] Create browser environment
|
|
browserStart <containerCode> Start browser environment
|
|
browserStop <containerCode> Stop browser environment
|
|
browserStatus [containerCode] Query running browser status
|
|
browserForeground <containerCode> Bring browser window to front
|
|
browserStopAll [clearOpening] Close all environments
|
|
displayAll Get all physical screens
|
|
browserArrange [screenId] Arrange browser windows
|
|
list List all generated commands
|
|
testAll Test all generated endpoints
|
|
help Show this help
|
|
|
|
Generated commands:
|
|
${COMMANDS.length} commands are available, e.g.:
|
|
- postV1BrowserStart
|
|
- postV1BrowserStop
|
|
- postV1EnvCreate
|
|
|
|
Env:
|
|
HUBSTUDIO_BASE_URL Default: http://127.0.0.1:6873
|
|
HUBSTUDIO_STRICT_VALIDATION Default: true (set false to allow unknown fields)
|
|
`);
|
|
}
|
|
|
|
function parseJsonArg(flag, value) {
|
|
if (!value) {
|
|
throw new Error(`Missing value for ${flag}`);
|
|
}
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch (err) {
|
|
throw new Error(`Invalid JSON for ${flag}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function parseCommandArgs(rawArgs) {
|
|
const positional = [];
|
|
let bodyOverride = null;
|
|
let queryOverride = null;
|
|
let strictValidation = process.env.HUBSTUDIO_STRICT_VALIDATION !== "false";
|
|
|
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
const token = rawArgs[i];
|
|
if (token === "--body") {
|
|
bodyOverride = parseJsonArg("--body", rawArgs[i + 1]);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (token === "--query") {
|
|
queryOverride = parseJsonArg("--query", rawArgs[i + 1]);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (token === "--strict") {
|
|
const next = rawArgs[i + 1];
|
|
if (!next || next.startsWith("--")) {
|
|
strictValidation = true;
|
|
} else {
|
|
const normalized = String(next).toLowerCase();
|
|
if (normalized === "true") {
|
|
strictValidation = true;
|
|
} else if (normalized === "false") {
|
|
strictValidation = false;
|
|
} else {
|
|
throw new InputValidationError("Request validation failed", [
|
|
{
|
|
field: "--strict",
|
|
reason: "invalid boolean flag value",
|
|
expected: "true|false",
|
|
actualType: typeof next,
|
|
},
|
|
]);
|
|
}
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
positional.push(token);
|
|
}
|
|
|
|
return { positional, bodyOverride, queryOverride, strictValidation };
|
|
}
|
|
|
|
async function post(endpointPath, body = {}, query = null) {
|
|
const url = new URL(endpointPath, BASE_URL);
|
|
if (query && typeof query === "object") {
|
|
for (let [k, v] of Object.entries(query)) {
|
|
if (v !== undefined && v !== null) {
|
|
url.searchParams.set(k, String(v));
|
|
}
|
|
}
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
let payload = null;
|
|
try {
|
|
payload = await response.json();
|
|
} catch {
|
|
payload = { raw: await response.text() };
|
|
}
|
|
return { status: response.status, payload };
|
|
}
|
|
|
|
function printResult(result) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
}
|
|
|
|
function requiredArg(name, value) {
|
|
if (!value) {
|
|
throw new InputValidationError("Request validation failed", [
|
|
{
|
|
field: name,
|
|
reason: "required argument missing or empty",
|
|
expected: "non-empty",
|
|
actualType: "missing",
|
|
},
|
|
]);
|
|
}
|
|
}
|
|
|
|
function getGeneratedCommand(commandName) {
|
|
const resolved = ALIASES[commandName] || commandName;
|
|
const cmd = COMMAND_MAP.get(resolved);
|
|
if (!cmd) {
|
|
throw new Error(`Unknown command: ${commandName}`);
|
|
}
|
|
return cmd;
|
|
}
|
|
|
|
function resolveCallInputs(commandName, positional, bodyOverride, queryOverride) {
|
|
const generatedCmd = getGeneratedCommand(commandName);
|
|
const body = { ...(generatedCmd.defaultBody || {}) };
|
|
const query = { ...(generatedCmd.defaultQuery || {}) };
|
|
|
|
if (commandName === "browserCreate") {
|
|
const name = positional[0] || `NodeAuto-${Date.now()}`;
|
|
body.containerName = name;
|
|
body.asDynamicType = 1;
|
|
body.proxyTypeName = "不使用代理";
|
|
} else if (commandName === "browserStart" || commandName === "browserForeground") {
|
|
requiredArg("containerCode", positional[0]);
|
|
body.containerCode = positional[0];
|
|
} else if (commandName === "browserStop") {
|
|
requiredArg("containerCode", positional[0]);
|
|
body.containerCode = positional[0];
|
|
query.containerCode = positional[0];
|
|
} else if (commandName === "browserStatus") {
|
|
if (positional[0]) {
|
|
body.containerCode = [positional[0]];
|
|
}
|
|
} else if (commandName === "browserStopAll") {
|
|
body.clearOpening = positional[0] ? positional[0] === "true" : false;
|
|
} else if (commandName === "browserArrange" && positional[0]) {
|
|
body.screenId = Number(positional[0]);
|
|
}
|
|
|
|
const finalBody = bodyOverride && typeof bodyOverride === "object" ? bodyOverride : body;
|
|
const finalQuery = queryOverride && typeof queryOverride === "object" ? queryOverride : query;
|
|
return { generatedCmd, finalBody, finalQuery };
|
|
}
|
|
|
|
function normalizeResult(command, generatedCmd, result, body, query) {
|
|
return {
|
|
command,
|
|
method: generatedCmd.method,
|
|
path: generatedCmd.path,
|
|
status: result.status,
|
|
code: result.payload && Object.prototype.hasOwnProperty.call(result.payload, "code") ? result.payload.code : null,
|
|
msg: result.payload && Object.prototype.hasOwnProperty.call(result.payload, "msg") ? result.payload.msg : null,
|
|
success:
|
|
result.status < 400 &&
|
|
(!result.payload || !Object.prototype.hasOwnProperty.call(result.payload, "code") || result.payload.code === 0),
|
|
request: { body, query },
|
|
payload: result.payload,
|
|
};
|
|
}
|
|
|
|
async function runTestAll() {
|
|
const results = [];
|
|
for (const cmd of COMMANDS) {
|
|
try {
|
|
const result = await post(cmd.path, cmd.defaultBody || {}, cmd.defaultQuery || {});
|
|
results.push(normalizeResult(cmd.command, cmd, result, cmd.defaultBody || {}, cmd.defaultQuery || {}));
|
|
} catch (err) {
|
|
results.push({
|
|
command: cmd.command,
|
|
method: cmd.method,
|
|
path: cmd.path,
|
|
status: null,
|
|
code: null,
|
|
msg: err.message,
|
|
success: false,
|
|
request: { body: cmd.defaultBody || {}, query: cmd.defaultQuery || {} },
|
|
payload: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
const summary = {
|
|
total: results.length,
|
|
success: results.filter((x) => x.success).length,
|
|
failed: results.filter((x) => !x.success).length,
|
|
};
|
|
const report = {
|
|
baseUrl: BASE_URL,
|
|
testedAt: new Date().toISOString(),
|
|
summary,
|
|
results,
|
|
};
|
|
|
|
const reportPath = path.join(__dirname, "scripts", "node_test_all_report.json");
|
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf8");
|
|
return { summary, reportPath, report };
|
|
}
|
|
|
|
async function main() {
|
|
const [, , command, ...rawArgs] = process.argv;
|
|
|
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
if (command === "list") {
|
|
const items = COMMANDS.map((c) => ({
|
|
command: c.command,
|
|
method: c.method,
|
|
path: c.path,
|
|
summary: c.summary,
|
|
}));
|
|
printResult({ total: items.length, commands: items });
|
|
return;
|
|
}
|
|
|
|
if (command === "testAll") {
|
|
const out = await runTestAll();
|
|
printResult({
|
|
summary: out.summary,
|
|
reportPath: out.reportPath,
|
|
});
|
|
if (out.summary.failed > 0) {
|
|
process.exitCode = 2;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { positional, bodyOverride, queryOverride } = parseCommandArgs(rawArgs);
|
|
const { generatedCmd, finalBody, finalQuery } = resolveCallInputs(
|
|
command,
|
|
positional,
|
|
bodyOverride,
|
|
queryOverride
|
|
);
|
|
validateCommandInputs(command, generatedCmd, finalBody, finalQuery);
|
|
const result = await post(generatedCmd.path, finalBody, finalQuery);
|
|
const normalized = normalizeResult(command, generatedCmd, result, finalBody, finalQuery);
|
|
printResult(normalized);
|
|
|
|
if (normalized.status >= 400) {
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (normalized.code !== null && normalized.code !== 0) {
|
|
process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
if (err instanceof InputValidationError) {
|
|
const payload = {
|
|
success: false,
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
command: err.command || null,
|
|
fieldErrors: err.fieldErrors || [],
|
|
},
|
|
};
|
|
console.error(JSON.stringify(payload, null, 2));
|
|
process.exit(3);
|
|
return;
|
|
}
|
|
console.error(err.message || String(err));
|
|
process.exit(1);
|
|
});
|