#!/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 [args] [--body '{"k":"v"}'] [--query '{"k":"v"}'] [--strict true|false] Commands: browserCreate [name] Create browser environment browserStart Start browser environment browserStop Stop browser environment browserStatus [containerCode] Query running browser status browserForeground 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); });