mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 08:02:12 -06:00
fix(cron): stop advertising inactive JSON defaults (#124903)
* fix(cron): make --json help match behavior * fix(cron): preserve scratch write JSON output * test(cli): classify cron JSON result commands
This commit is contained in:
committed by
GitHub
parent
6405a59c45
commit
6205cf3bb3
+3
-1
@@ -313,7 +313,9 @@ openclaw automations runs --id <job-id> --run-id <run-id>
|
||||
|
||||
`openclaw automations list` shows enabled jobs by default. Pass `--all` to include disabled jobs, or `--agent <id>` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent.
|
||||
|
||||
`openclaw automations get <job-id>` returns the stored job JSON directly. `get` and `runs` accept `--json` as the explicit machine-output spelling. Use `automations show <job-id>` when you want the human-readable view with delivery-route preview.
|
||||
`--json` always requests JSON output. Commands whose product is already a machine-readable result emit JSON results by default: `add`/`create`, `status`, `enable`, `disable`, `rm`/`remove`/`delete`, `run`, `edit`, `get`, and `runs`. They accept `--json` as the explicit machine-output spelling. `openclaw automations get <job-id>` returns the stored job JSON directly; use `automations show <job-id>` when you want the human-readable view with delivery-route preview.
|
||||
|
||||
`list` and `show` use human-readable output by default and switch to JSON with `--json`. `scratch` reads raw scratch content by default and prints the scratch plus revision metadata with `--json`; scratch writes return the revision result as JSON by default and accept `--json` as the explicit machine-output spelling.
|
||||
|
||||
`automations list --json` and `automations show <job-id> --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. JSON status stays canonical and undecorated so external tooling can read job state without re-deriving it; human output may decorate repeated `error` statuses with a failure count.
|
||||
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
import { getMachineOutputCommandPath } from "../machine-output-argv.js";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
getMachineOutputCommandPath,
|
||||
MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION,
|
||||
} from "../machine-output-argv.js";
|
||||
|
||||
const MACHINE_OUTPUT_COMMANDS = new Set([
|
||||
"add",
|
||||
"create",
|
||||
"delete",
|
||||
"disable",
|
||||
"edit",
|
||||
"enable",
|
||||
"get",
|
||||
"remove",
|
||||
"rm",
|
||||
"run",
|
||||
"runs",
|
||||
"status",
|
||||
]);
|
||||
const CRON_SCRATCH_JSON_OPTION_DESCRIPTION =
|
||||
"Output scratch plus revision metadata as JSON; writes return JSON by default";
|
||||
|
||||
type CronOutputCommandDefinition = {
|
||||
aliases: readonly string[];
|
||||
alwaysJson: boolean;
|
||||
};
|
||||
|
||||
const CRON_OUTPUT_COMMANDS = {
|
||||
status: { aliases: [], alwaysJson: true },
|
||||
add: { aliases: ["create"], alwaysJson: true },
|
||||
rm: { aliases: ["remove", "delete"], alwaysJson: true },
|
||||
enable: { aliases: [], alwaysJson: true },
|
||||
disable: { aliases: [], alwaysJson: true },
|
||||
get: { aliases: [], alwaysJson: true },
|
||||
runs: { aliases: [], alwaysJson: true },
|
||||
run: { aliases: [], alwaysJson: true },
|
||||
edit: { aliases: [], alwaysJson: true },
|
||||
scratch: { aliases: [], alwaysJson: false },
|
||||
} as const satisfies Record<string, CronOutputCommandDefinition>;
|
||||
|
||||
type CronOutputCommandName = keyof typeof CRON_OUTPUT_COMMANDS;
|
||||
const MACHINE_OUTPUT_COMMANDS = new Set<string>();
|
||||
for (const [name, definition] of Object.entries(CRON_OUTPUT_COMMANDS)) {
|
||||
MACHINE_OUTPUT_COMMANDS.add(name);
|
||||
for (const alias of definition.aliases) {
|
||||
MACHINE_OUTPUT_COMMANDS.add(alias);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCronOutputCommand(parent: Command, name: CronOutputCommandName): Command {
|
||||
const definition = CRON_OUTPUT_COMMANDS[name];
|
||||
const command = parent.command(name);
|
||||
for (const alias of definition.aliases) {
|
||||
command.alias(alias);
|
||||
}
|
||||
return definition.alwaysJson
|
||||
? command.option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION)
|
||||
: command.option("--json", CRON_SCRATCH_JSON_OPTION_DESCRIPTION);
|
||||
}
|
||||
|
||||
export function isCronMachineOutput(argv: readonly string[]): boolean {
|
||||
const [, command] = getMachineOutputCommandPath(argv, 2);
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
if (MACHINE_OUTPUT_COMMANDS.has(command)) {
|
||||
return true;
|
||||
}
|
||||
return command === "scratch";
|
||||
return MACHINE_OUTPUT_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { listCronJobsFromGateway } from "./list-jobs.js";
|
||||
import { createCronOutputCommand } from "./output-mode.js";
|
||||
import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
@@ -32,10 +33,8 @@ import { readCronPayloadScript, readCronTriggerScript } from "./trigger-options.
|
||||
|
||||
export function registerCronStatusCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("status")
|
||||
createCronOutputCommand(cron, "status")
|
||||
.description("Show automations scheduler status")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.status", opts, {});
|
||||
@@ -84,9 +83,7 @@ export function registerCronListCommand(cron: Command) {
|
||||
|
||||
export function registerCronAddCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("add")
|
||||
.alias("create")
|
||||
createCronOutputCommand(cron, "add")
|
||||
.description("Add an automation")
|
||||
.argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron")
|
||||
.argument("[message]", "Agent message when using a positional schedule")
|
||||
@@ -163,7 +160,6 @@ export function registerCronAddCommand(cron: Command) {
|
||||
.option("--thread-id <id>", "Telegram forum topic thread id")
|
||||
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
|
||||
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(
|
||||
async (
|
||||
nameArg: string | undefined,
|
||||
|
||||
@@ -58,6 +58,17 @@ describe("cron edit command", () => {
|
||||
expect(help).toMatch(/also\s+implies --announce when used alone/);
|
||||
});
|
||||
|
||||
it("accepts --json as the explicit machine-output spelling", async () => {
|
||||
await createCronProgram().parseAsync(["edit", "job-1", "--enable", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), {
|
||||
id: "job-1",
|
||||
patch: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the human-readable display name without changing the job name", async () => {
|
||||
await createCronProgram().parseAsync(["edit", "job-1", "--display-name", "Daily summary"], {
|
||||
from: "user",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "../gateway-rpc.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { isUnknownCronGetMethodError, listCronJobsFromGateway } from "./list-jobs.js";
|
||||
import { createCronOutputCommand } from "./output-mode.js";
|
||||
import { resolveCronEditPayloadDeliveryPatch } from "./register.cron-edit-options.js";
|
||||
import {
|
||||
applyExistingCronSchedulePatch,
|
||||
@@ -55,8 +56,7 @@ async function readCronJobForEdit(opts: GatewayRpcOpts, id: string): Promise<Cro
|
||||
|
||||
export function registerCronEditCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("edit")
|
||||
createCronOutputCommand(cron, "edit")
|
||||
.description("Edit an automation (patch fields)")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--name <name>", "Set name")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Cron scratch register tests cover cron scratch command option validation.
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
const callGatewayFromCli = vi.fn();
|
||||
@@ -38,6 +38,38 @@ describe("cron scratch command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without --json", ["--set", "new note"]],
|
||||
["with --json", ["--unset", "--json"]],
|
||||
])("prints the write result as JSON %s", async (_label, args) => {
|
||||
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const writeJson = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {});
|
||||
|
||||
await createCronProgram().parseAsync(["scratch", "job-1", ...args], { from: "user" });
|
||||
|
||||
expect(writeJson).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
scratch: null,
|
||||
currentRevision: 3,
|
||||
maxBytes: 1024,
|
||||
});
|
||||
expect(stdoutWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("documents the read/write JSON split", () => {
|
||||
const scratch = createCronProgram().commands.find((command) => command.name() === "scratch");
|
||||
const jsonOption = scratch?.options.find((option) => option.long === "--json");
|
||||
|
||||
expect(jsonOption?.description).toBe(
|
||||
"Output scratch plus revision metadata as JSON; writes return JSON by default",
|
||||
);
|
||||
expect(jsonOption?.defaultValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["0x2", "1e2", "2.5", "-1", "2a"])(
|
||||
"rejects non-decimal --expected-revision %j",
|
||||
async (revision) => {
|
||||
@@ -72,6 +104,7 @@ describe("cron scratch command", () => {
|
||||
])(
|
||||
"passes decimal --expected-revision %j through to the CAS write",
|
||||
async (revision, expectedRevision) => {
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
await createCronProgram().parseAsync(
|
||||
["scratch", "job-1", "--set", "x", "--expected-revision", revision],
|
||||
{ from: "user" },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/numb
|
||||
// Cron scratch CLI: private per-job prompt context reads and compare-and-swap writes.
|
||||
import type { Command } from "commander";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { createCronOutputCommand } from "./output-mode.js";
|
||||
import { handleCronCliError, printCronJson } from "./shared.js";
|
||||
import { readCronScratchContent } from "./trigger-options.js";
|
||||
|
||||
@@ -28,15 +29,13 @@ function parseExpectedRevision(value: string | undefined): number | undefined {
|
||||
|
||||
export function registerCronScratchCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("scratch")
|
||||
createCronOutputCommand(cron, "scratch")
|
||||
.description("Read or replace an automation's private scratch")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--set <text>", "Replace scratch with exact text")
|
||||
.option("--file <path>", "Replace scratch from a file, or - for stdin")
|
||||
.option("--unset", "Remove the scratch row", false)
|
||||
.option("--expected-revision <n>", "Require the current scratch revision")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const mutations = [
|
||||
|
||||
@@ -15,6 +15,8 @@ vi.mock("../gateway-rpc.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const { isCronMachineOutput } = await import("./output-mode.js");
|
||||
const { registerCronCli } = await import("./register.js");
|
||||
const { registerCronSimpleCommands } = await import("./register.cron-simple.js");
|
||||
const originalStderrIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
|
||||
|
||||
@@ -65,6 +67,60 @@ function restoreStderrIsTTY(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function createRegisteredCronCommand(): Command {
|
||||
const program = new Command().name("openclaw");
|
||||
registerCronCli(program);
|
||||
const cron = program.commands.find((command) => command.name() === "cron");
|
||||
if (!cron) {
|
||||
throw new Error("cron command was not registered");
|
||||
}
|
||||
return cron;
|
||||
}
|
||||
|
||||
describe("cron machine-output help", () => {
|
||||
it.each([
|
||||
{ name: "status", aliases: [] },
|
||||
{ name: "add", aliases: ["create"] },
|
||||
{ name: "rm", aliases: ["remove", "delete"] },
|
||||
{ name: "enable", aliases: [] },
|
||||
{ name: "disable", aliases: [] },
|
||||
{ name: "get", aliases: [] },
|
||||
{ name: "runs", aliases: [] },
|
||||
{ name: "run", aliases: [] },
|
||||
{ name: "edit", aliases: [] },
|
||||
])("documents $name as always-JSON machine output", ({ name, aliases }) => {
|
||||
const command = createRegisteredCronCommand().commands.find((candidate) =>
|
||||
[candidate.name(), ...candidate.aliases()].includes(name),
|
||||
);
|
||||
const jsonOption = command?.options.find((option) => option.long === "--json");
|
||||
|
||||
expect(command?.aliases()).toEqual(aliases);
|
||||
expect(jsonOption?.description).toBe(
|
||||
"Explicit machine-output spelling (command results are JSON by default)",
|
||||
);
|
||||
expect(jsonOption?.defaultValue).toBeUndefined();
|
||||
for (const commandName of [name, ...aliases]) {
|
||||
expect(isCronMachineOutput(["node", "openclaw", "cron", commandName])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps registered command output declarations aligned with early stdout routing", () => {
|
||||
const cron = createRegisteredCronCommand();
|
||||
for (const command of cron.commands) {
|
||||
const jsonOption = command.options.find((option) => option.long === "--json");
|
||||
const alwaysJson =
|
||||
jsonOption?.description ===
|
||||
"Explicit machine-output spelling (command results are JSON by default)";
|
||||
const reservesMachineOutput = command.name() === "scratch" || alwaysJson;
|
||||
for (const commandName of [command.name(), ...command.aliases()]) {
|
||||
expect(isCronMachineOutput(["node", "openclaw", "cron", commandName]), commandName).toBe(
|
||||
reservesMachineOutput,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron show pagination guard (regression for #83856)", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayFromCli.mockReset();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { parseTimeoutMs } from "../parse-timeout.js";
|
||||
import { findCronJobByIdOrName } from "./list-jobs.js";
|
||||
import { createCronOutputCommand } from "./output-mode.js";
|
||||
import {
|
||||
enrichCronJsonWithStatus,
|
||||
handleCronCliError,
|
||||
@@ -101,8 +102,7 @@ function registerCronToggleCommand(params: {
|
||||
enabled: boolean;
|
||||
}) {
|
||||
addGatewayClientOptions(
|
||||
params.cron
|
||||
.command(params.name)
|
||||
createCronOutputCommand(params.cron, params.name)
|
||||
.description(params.description)
|
||||
.argument("<id>", "Job id")
|
||||
.action(async (id, opts) => {
|
||||
@@ -127,13 +127,9 @@ function registerCronToggleCommand(params: {
|
||||
|
||||
export function registerCronSimpleCommands(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("rm")
|
||||
.alias("remove")
|
||||
.alias("delete")
|
||||
createCronOutputCommand(cron, "rm")
|
||||
.description("Remove an automation")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.remove", opts, { id });
|
||||
@@ -158,11 +154,9 @@ export function registerCronSimpleCommands(cron: Command) {
|
||||
});
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("get")
|
||||
createCronOutputCommand(cron, "get")
|
||||
.description("Get an automation as JSON")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.get", opts, { id: String(id) });
|
||||
@@ -199,11 +193,9 @@ export function registerCronSimpleCommands(cron: Command) {
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("runs")
|
||||
createCronOutputCommand(cron, "runs")
|
||||
.description("Show automation run history")
|
||||
.requiredOption("--id <id>", "Job id")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--run-id <runId>", "Filter by cron run id")
|
||||
.option("--limit <n>", "Max entries (default 50)", "50")
|
||||
.action(async (opts) => {
|
||||
@@ -229,8 +221,7 @@ export function registerCronSimpleCommands(cron: Command) {
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("run")
|
||||
createCronOutputCommand(cron, "run")
|
||||
.description("Run an automation now (debug)")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--due", "Run only when due (default behavior in older versions)", false)
|
||||
|
||||
@@ -42,6 +42,21 @@ describe("gateway restart-handoff commands", () => {
|
||||
expect(gateway.helpInformation()).not.toContain("restart-handoff");
|
||||
});
|
||||
|
||||
it("documents restart-handoff output as unconditionally JSON", () => {
|
||||
const program = new Command();
|
||||
const gateway = program.command("gateway");
|
||||
addGatewayRestartHandoffCommands(gateway);
|
||||
const restartHandoff = gateway.commands.find((command) => command.name() === "restart-handoff");
|
||||
|
||||
for (const command of restartHandoff?.commands ?? []) {
|
||||
const jsonOption = command.options.find((option) => option.long === "--json");
|
||||
expect(jsonOption?.description, command.name()).toBe(
|
||||
"Explicit machine-output spelling (command results are JSON by default)",
|
||||
);
|
||||
expect(jsonOption?.defaultValue, command.name()).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports protocol version 1 capabilities", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerGatewayRestartHandoffCli,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GATEWAY_RESTART_HANDOFF_PROTOCOL_VERSION,
|
||||
} from "../../infra/restart-handoff-contract.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION } from "../machine-output-argv.js";
|
||||
|
||||
function writeRestartHandoffError(reason: "invalid-expected-pid" | "store-unavailable") {
|
||||
defaultRuntime.writeJson({
|
||||
@@ -28,7 +29,7 @@ export function addGatewayRestartHandoffCommands(gateway: Command): void {
|
||||
restartHandoff
|
||||
.command("capabilities")
|
||||
.description("Report the gateway restart-handoff machine contract")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION)
|
||||
.action(() => {
|
||||
defaultRuntime.writeJson({
|
||||
ok: true,
|
||||
@@ -43,7 +44,7 @@ export function addGatewayRestartHandoffCommands(gateway: Command): void {
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.option("--expected-pid [pid]", "PID of the exited gateway process", collectExpectedPid, [])
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION)
|
||||
.action(async (opts, command: Command) => {
|
||||
const expectedPidValues = Array.isArray(opts.expectedPid) ? opts.expectedPid : [];
|
||||
const expectedPid =
|
||||
|
||||
@@ -10,6 +10,9 @@ export type MachineOutputResolverParams = {
|
||||
|
||||
export type MachineOutputResolver = (params: MachineOutputResolverParams) => boolean;
|
||||
|
||||
export const MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION =
|
||||
"Explicit machine-output spelling (command results are JSON by default)";
|
||||
|
||||
/** Normalize Node's absent `isTTY` property to the public resolver's boolean contract. */
|
||||
export function isMachineOutputStdoutTTY(
|
||||
stdout: { readonly isTTY?: boolean } = process.stdout,
|
||||
|
||||
@@ -86,6 +86,12 @@ describe("built-in machine-output resolvers", () => {
|
||||
|
||||
it("reserves raw cron scratch output", () => {
|
||||
expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job"])).toBe(true);
|
||||
expect(
|
||||
isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job", "--set", "note"]),
|
||||
).toBe(true);
|
||||
expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job", "--unset"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["get", "file", "schema"])("reserves config %s machine output", (subcommand) => {
|
||||
|
||||
@@ -187,10 +187,6 @@ const JSON_NOT_APPLICABLE = {
|
||||
"fleet restart",
|
||||
"fleet upgrade",
|
||||
"fleet rm",
|
||||
"cron enable",
|
||||
"cron disable",
|
||||
"cron run",
|
||||
"cron edit",
|
||||
"dns setup",
|
||||
"proxy purge",
|
||||
"pairing approve",
|
||||
|
||||
Reference in New Issue
Block a user