Gateway/CLI: add paired-device remove and clear flows (#20057)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 26523f8a38148073412cf24590176be9a6ab1237
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-18 13:27:31 +00:00
committed by GitHub
parent fc65f70a9b
commit 1437ed76a0
13 changed files with 239 additions and 2 deletions

View File

@@ -28,6 +28,13 @@ async function runDevicesApprove(argv: string[]) {
await program.parseAsync(["devices", "approve", ...argv], { from: "user" });
}
async function runDevicesCommand(argv: string[]) {
const { registerDevicesCli } = await import("./devices-cli.js");
const program = new Command();
registerDevicesCli(program);
await program.parseAsync(["devices", ...argv], { from: "user" });
}
describe("devices cli approve", () => {
afterEach(() => {
callGateway.mockReset();
@@ -113,3 +120,75 @@ describe("devices cli approve", () => {
);
});
});
describe("devices cli remove", () => {
afterEach(() => {
callGateway.mockReset();
withProgress.mockClear();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
});
it("removes a paired device by id", async () => {
callGateway.mockResolvedValueOnce({ deviceId: "device-1" });
await runDevicesCommand(["remove", "device-1"]);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "device.pair.remove",
params: { deviceId: "device-1" },
}),
);
});
});
describe("devices cli clear", () => {
afterEach(() => {
callGateway.mockReset();
withProgress.mockClear();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
});
it("requires --yes before clearing", async () => {
await runDevicesCommand(["clear"]);
expect(callGateway).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith("Refusing to clear pairing table without --yes");
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("clears paired devices and optionally pending requests", async () => {
callGateway
.mockResolvedValueOnce({
paired: [{ deviceId: "device-1" }, { deviceId: "device-2" }],
pending: [{ requestId: "req-1" }],
})
.mockResolvedValueOnce({ deviceId: "device-1" })
.mockResolvedValueOnce({ deviceId: "device-2" })
.mockResolvedValueOnce({ requestId: "req-1", deviceId: "device-1" });
await runDevicesCommand(["clear", "--yes", "--pending"]);
expect(callGateway).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ method: "device.pair.list" }),
);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-1" } }),
);
expect(callGateway).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-2" } }),
);
expect(callGateway).toHaveBeenNthCalledWith(
4,
expect.objectContaining({ method: "device.pair.reject", params: { requestId: "req-1" } }),
);
});
});

View File

@@ -14,6 +14,8 @@ type DevicesRpcOpts = {
timeout?: string;
json?: boolean;
latest?: boolean;
yes?: boolean;
pending?: boolean;
device?: string;
role?: string;
scope?: string[];
@@ -180,6 +182,86 @@ export function registerDevicesCli(program: Command) {
}),
);
devicesCallOpts(
devices
.command("remove")
.description("Remove a paired device entry")
.argument("<deviceId>", "Paired device id")
.action(async (deviceId: string, opts: DevicesRpcOpts) => {
const trimmed = deviceId.trim();
if (!trimmed) {
defaultRuntime.error("deviceId is required");
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("device.pair.remove", opts, { deviceId: trimmed });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`${theme.warn("Removed")} ${theme.command(trimmed)}`);
}),
);
devicesCallOpts(
devices
.command("clear")
.description("Clear paired devices from the gateway table")
.option("--pending", "Also reject all pending pairing requests", false)
.option("--yes", "Confirm destructive clear", false)
.action(async (opts: DevicesRpcOpts) => {
if (!opts.yes) {
defaultRuntime.error("Refusing to clear pairing table without --yes");
defaultRuntime.exit(1);
return;
}
const list = parseDevicePairingList(await callGatewayCli("device.pair.list", opts, {}));
const removedDeviceIds: string[] = [];
const rejectedRequestIds: string[] = [];
const paired = Array.isArray(list.paired) ? list.paired : [];
for (const device of paired) {
const deviceId = typeof device.deviceId === "string" ? device.deviceId.trim() : "";
if (!deviceId) {
continue;
}
await callGatewayCli("device.pair.remove", opts, { deviceId });
removedDeviceIds.push(deviceId);
}
if (opts.pending) {
const pending = Array.isArray(list.pending) ? list.pending : [];
for (const req of pending) {
const requestId = typeof req.requestId === "string" ? req.requestId.trim() : "";
if (!requestId) {
continue;
}
await callGatewayCli("device.pair.reject", opts, { requestId });
rejectedRequestIds.push(requestId);
}
}
if (opts.json) {
defaultRuntime.log(
JSON.stringify(
{
removedDevices: removedDeviceIds,
rejectedPending: rejectedRequestIds,
},
null,
2,
),
);
return;
}
defaultRuntime.log(
`${theme.warn("Cleared")} ${removedDeviceIds.length} paired device${removedDeviceIds.length === 1 ? "" : "s"}`,
);
if (opts.pending) {
defaultRuntime.log(
`${theme.warn("Rejected")} ${rejectedRequestIds.length} pending request${rejectedRequestIds.length === 1 ? "" : "s"}`,
);
}
}),
);
devicesCallOpts(
devices
.command("approve")