diff --git a/README.md b/README.md index 6145a31..bbf2149 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ $ npm install -g gassi-cli $ gassi COMMAND running command... $ gassi (-v|--version|version) -gassi-cli/0.1.4 linux-x64 node-v14.12.0 +gassi-cli/0.2.0 linux-x64 node-v14.4.0 $ gassi --help [COMMAND] USAGE $ gassi COMMAND @@ -34,7 +34,7 @@ USAGE * [`gassi disconnect`](#gassi-disconnect) -* [`gassi execute PARAMNAME PARAMVALUE`](#gassi-execute-paramname-paramvalue) +* [`gassi execute [PARAMNAME] [PARAMVALUE]`](#gassi-execute-paramname-paramvalue) * [`gassi help [COMMAND]`](#gassi-help-command) * [`gassi query`](#gassi-query) * [`gassi sync`](#gassi-sync) @@ -53,25 +53,25 @@ OPTIONS -u, --uri=uri (required) uri of the service ``` -_See code: [src/commands/disconnect.ts](https://github.com/Spissable/gassi-cli/blob/v0.1.4/src/commands/disconnect.ts)_ +_See code: [src/commands/disconnect.ts](https://github.com/Spissable/gassi-cli/blob/v0.2.0/src/commands/disconnect.ts)_ -## `gassi execute PARAMNAME PARAMVALUE` +## `gassi execute [PARAMNAME] [PARAMVALUE]` Sends an EXECUTE request intent ``` USAGE - $ gassi execute PARAMNAME PARAMVALUE + $ gassi execute [PARAMNAME] [PARAMVALUE] OPTIONS - -c, --command=command (required) command to execute + -c, --command=command command to execute -h, --help show CLI help - -i, --id=id (required) id to query + -i, --id=id id to query -t, --token=token (required) oauth access token -u, --uri=uri (required) uri of the service ``` -_See code: [src/commands/execute.ts](https://github.com/Spissable/gassi-cli/blob/v0.1.4/src/commands/execute.ts)_ +_See code: [src/commands/execute.ts](https://github.com/Spissable/gassi-cli/blob/v0.2.0/src/commands/execute.ts)_ ## `gassi help [COMMAND]` @@ -88,7 +88,7 @@ OPTIONS --all see all commands in CLI ``` -_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_ +_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.2/src/commands/help.ts)_ ## `gassi query` @@ -100,12 +100,12 @@ USAGE OPTIONS -h, --help show CLI help - -i, --id=id (required) id to query + -i, --id=id id to query -t, --token=token (required) oauth access token -u, --uri=uri (required) uri of the service ``` -_See code: [src/commands/query.ts](https://github.com/Spissable/gassi-cli/blob/v0.1.4/src/commands/query.ts)_ +_See code: [src/commands/query.ts](https://github.com/Spissable/gassi-cli/blob/v0.2.0/src/commands/query.ts)_ ## `gassi sync` @@ -121,5 +121,5 @@ OPTIONS -u, --uri=uri (required) uri of the service ``` -_See code: [src/commands/sync.ts](https://github.com/Spissable/gassi-cli/blob/v0.1.4/src/commands/sync.ts)_ +_See code: [src/commands/sync.ts](https://github.com/Spissable/gassi-cli/blob/v0.2.0/src/commands/sync.ts)_ diff --git a/package.json b/package.json index 738ce9f..20bcdfc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gassi-cli", "description": "Run SYNC, QUERY, EXECUTE and DISCONNECT requests on Google SmartHome easily", - "version": "0.1.4", + "version": "0.2.0", "author": "Spiss, Lukas @Spissable", "bin": { "gassi": "./bin/run" @@ -12,12 +12,16 @@ "@oclif/config": "^1.17.0", "@oclif/plugin-help": "^3.2.2", "axios": "^0.21.1", + "fs-extra": "^9.1.0", + "inquirer": "^7.3.3", "tslib": "^2.1.0", "uuid": "^8.3.2" }, "devDependencies": { "@oclif/dev-cli": "^1.26.0", "@types/axios": "^0.14.0", + "@types/fs-extra": "^9.0.7", + "@types/inquirer": "^7.3.1", "@types/jest": "^26.0.20", "@types/node": "^14.14.25", "@types/uuid": "^8.3.0", diff --git a/src/commands/disconnect.ts b/src/commands/disconnect.ts index 9a1a6ad..4b31454 100644 --- a/src/commands/disconnect.ts +++ b/src/commands/disconnect.ts @@ -1,6 +1,7 @@ import { Command, flags } from "@oclif/command"; import axios from "axios"; import { v4 as uuid } from "uuid"; +import { DisconnectRequest } from "../entities/DisconnectRequest"; export default class Disconnect extends Command { static description = "Sends a DISCONNECT request intent"; @@ -24,7 +25,7 @@ export default class Disconnect extends Command { async run() { const { flags } = this.parse(Disconnect); const requestId = uuid(); - const disconnectBody: DisconnectBody = { + const disconnectBody: DisconnectRequest = { requestId, inputs: [ { @@ -52,10 +53,3 @@ export default class Disconnect extends Command { ); } } - -interface DisconnectBody { - requestId: string; - inputs: { - intent: "action.devices.DISCONNECT"; - }[]; -} diff --git a/src/commands/execute.test.ts b/src/commands/execute.test.ts index 3fdc887..69a1bdb 100644 --- a/src/commands/execute.test.ts +++ b/src/commands/execute.test.ts @@ -1,5 +1,11 @@ import * as nock from "nock"; import { stdout } from "stdout-stderr"; +import * as inquirer from "inquirer"; + +const configStub = jest.fn(); +jest.mock("../util/configUtil.ts", () => ({ + readConfig: configStub.mockResolvedValue({ ids: [] }), +})); import Execute from "./execute"; describe("EXECUTE intent", () => { @@ -217,4 +223,35 @@ describe("EXECUTE intent", () => { `Request some.uuid failed with:\n${JSON.stringify(errorReply, null, 2)}\n` ); }); + + test("Prompts work", async () => { + const mock = nock(testHost, { + reqheaders: { Authorization: "Bearer some.token" }, + }) + .post("/", onOffRequest) + .reply(200, executeReply); + + jest + .spyOn(inquirer, "prompt") + .mockResolvedValueOnce({ id: "some.id" }) + .mockResolvedValueOnce({ command: "OnOff" }) + .mockResolvedValueOnce({ param: "on" }) + .mockResolvedValueOnce({ value: "true" }); + + stdout.start(); + await Execute.run(["-t", "some.token", "-u", testHost]); + stdout.stop(); + + expect(mock.isDone()).toBeTruthy(); + expect(stdout.output).toEqual(JSON.stringify(executeReply, null, 2) + "\n"); + }); + + test("Config error is caught", async () => { + configStub.mockRejectedValueOnce(new Error()); + + await expect( + Execute.run(["-t", "some.token", "-u", testHost]) + ).rejects.toThrowError("Please run sync first or provide arguments."); + stdout.stop(); + }); }); diff --git a/src/commands/execute.ts b/src/commands/execute.ts index 6c2336f..ce7af77 100644 --- a/src/commands/execute.ts +++ b/src/commands/execute.ts @@ -1,7 +1,11 @@ import { Command, flags } from "@oclif/command"; import axios from "axios"; import { v4 as uuid } from "uuid"; -import { parseInput } from "../util/util"; +import { parseInput } from "../util/parseUtil"; +import { readConfig } from "../util/configUtil"; +import { ExecuteRequest } from "../entities/ExecuteRequest"; +import * as inquirer from "inquirer"; +import { executeCommands } from "../constants/executeCommands"; export default class Execute extends Command { static description = "Sends an EXECUTE request intent"; @@ -23,32 +27,41 @@ export default class Execute extends Command { char: "i", description: "id to query", env: "id", - required: true, + required: false, }), command: flags.string({ char: "c", description: "command to execute", env: "command", - required: true, + required: false, }), help: flags.help({ char: "h" }), }; static args = [ - { name: "paramName", required: true }, + { name: "paramName", required: false }, { name: "paramValue", - required: true, + required: false, }, ]; async run() { const { args, flags } = this.parse(Execute); - const paramValue = parseInput(args.paramValue); - const command = flags.command; + const id = + flags.id || + (await this.promptId().catch(() => { + this.error("Please run sync first or provide arguments."); + })); + const command = flags.command || (await this.promptCommand()); + const paramName = args.paramName || (await this.promptParamName(command)); + const paramValueStr = + args.paramValue || (await this.promptParamValue(command, paramName)); + const paramValue = parseInput(paramValueStr); const requestId = uuid(); - let commandParams: any = { [args.paramName]: paramValue }; + + let commandParams: any = { [paramName]: paramValue }; if (command === "SetModes") { commandParams = { updateModeSettings: { @@ -62,7 +75,7 @@ export default class Execute extends Command { }, }; } - const executeBody: ExecuteBody = { + const executeBody: ExecuteRequest = { requestId, inputs: [ { @@ -70,7 +83,7 @@ export default class Execute extends Command { payload: { commands: [ { - devices: [{ id: flags.id }], + devices: [{ id }], execution: [ { command: `action.devices.commands.${command}`, @@ -102,20 +115,56 @@ export default class Execute extends Command { } ); } -} -interface ExecuteBody { - requestId: string; - inputs: { - intent: "action.devices.EXECUTE"; - payload: { - commands: { - devices: { id: string }[]; - execution: { - command: string; - params: any; - }[]; - }[]; - }; - }[]; + async promptId(): Promise { + const syncedDevices = await readConfig(this.config.configDir); + const responses = await inquirer.prompt([ + { + name: "id", + message: "select a device id", + type: "list", + choices: syncedDevices.ids, + }, + ]); + return responses.id; + } + + async promptCommand(): Promise { + const commands = Object.keys(executeCommands); + const responses = await inquirer.prompt([ + { + name: "command", + message: "select a command", + type: "list", + choices: commands, + }, + ]); + return responses.command; + } + + async promptParamName(command: string): Promise { + const params = Object.keys(executeCommands[command]); + const responses = await inquirer.prompt([ + { + name: "param", + message: "select a param", + type: "list", + choices: params, + }, + ]); + return responses.param; + } + + async promptParamValue(command: string, param: string): Promise { + const values = executeCommands[command][param]; + const responses = await inquirer.prompt([ + { + name: "value", + message: "select a value", + type: "list", + choices: values, + }, + ]); + return responses.value; + } } diff --git a/src/commands/query.test.ts b/src/commands/query.test.ts index 28f1381..f4399ec 100644 --- a/src/commands/query.test.ts +++ b/src/commands/query.test.ts @@ -1,5 +1,11 @@ import * as nock from "nock"; import { stdout } from "stdout-stderr"; +import * as inquirer from "inquirer"; + +const configStub = jest.fn(); +jest.mock("../util/configUtil.ts", () => ({ + readConfig: configStub.mockResolvedValue({ ids: [] }), +})); import Query from "./query"; describe("QUERY intent", () => { @@ -17,27 +23,28 @@ describe("QUERY intent", () => { const errorReply = { message: "some.error", }; + const nockBody = { + requestId: "some.uuid", + inputs: [ + { + intent: "action.devices.QUERY", + payload: { + devices: [ + { + id: "some.id", + }, + ], + }, + }, + ], + }; const testHost = "http://some.google-action.com"; test("Sends a QUERY request to the specified host", async () => { const mock = nock(testHost, { reqheaders: { Authorization: "Bearer some.token" }, }) - .post("/", { - requestId: "some.uuid", - inputs: [ - { - intent: "action.devices.QUERY", - payload: { - devices: [ - { - id: "some.id", - }, - ], - }, - }, - ], - }) + .post("/", nockBody) .reply(200, queryReply); stdout.start(); @@ -52,21 +59,7 @@ describe("QUERY intent", () => { const mock = nock(testHost, { reqheaders: { Authorization: "Bearer some.token" }, }) - .post("/", { - requestId: "some.uuid", - inputs: [ - { - intent: "action.devices.QUERY", - payload: { - devices: [ - { - id: "some.id", - }, - ], - }, - }, - ], - }) + .post("/", nockBody) .reply(409, errorReply); stdout.start(); @@ -78,4 +71,30 @@ describe("QUERY intent", () => { `Request some.uuid failed with:\n${JSON.stringify(errorReply, null, 2)}\n` ); }); + + test("Id is not provided initially", async () => { + const mock = nock(testHost, { + reqheaders: { Authorization: "Bearer some.token" }, + }) + .post("/", nockBody) + .reply(200, queryReply); + + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ id: "some.id" }); + + stdout.start(); + await Query.run(["-t", "some.token", "-u", testHost]); + stdout.stop(); + + expect(mock.isDone()).toBeTruthy(); + expect(stdout.output).toEqual(JSON.stringify(queryReply, null, 2) + "\n"); + }); + + test("Config error is caught", async () => { + configStub.mockRejectedValueOnce(new Error()); + + await expect( + Query.run(["-t", "some.token", "-u", testHost]) + ).rejects.toThrowError("Please run sync first or provide arguments."); + stdout.stop(); + }); }); diff --git a/src/commands/query.ts b/src/commands/query.ts index f885b71..9bc3144 100644 --- a/src/commands/query.ts +++ b/src/commands/query.ts @@ -1,6 +1,9 @@ import { Command, flags } from "@oclif/command"; import axios from "axios"; import { v4 as uuid } from "uuid"; +import * as inquirer from "inquirer"; +import { QueryRequest } from "../entities/QueryRequest"; +import { readConfig } from "../util/configUtil"; export default class Query extends Command { static description = "Sends a QUERY request intent"; @@ -22,7 +25,7 @@ export default class Query extends Command { char: "i", description: "id to query", env: "id", - required: true, + required: false, }), help: flags.help({ char: "h" }), }; @@ -30,13 +33,20 @@ export default class Query extends Command { async run() { const { flags } = this.parse(Query); const requestId = uuid(); - const queryBody: QueryBody = { + + const deviceId = + flags.id || + (await this.promptId().catch(() => { + this.error("Please run sync first or provide arguments."); + })); + + const queryBody: QueryRequest = { requestId, inputs: [ { intent: "action.devices.QUERY", payload: { - devices: [{ id: flags.id }], + devices: [{ id: deviceId }], }, }, ], @@ -60,14 +70,17 @@ export default class Query extends Command { } ); } -} -interface QueryBody { - requestId: string; - inputs: { - intent: "action.devices.QUERY"; - payload: { - devices: { id: string }[]; - }; - }[]; + async promptId(): Promise { + const syncedDevices = await readConfig(this.config.configDir); + const responses = await inquirer.prompt([ + { + name: "id", + message: "select a device id", + type: "list", + choices: syncedDevices.ids, + }, + ]); + return responses.id; + } } diff --git a/src/commands/sync.test.ts b/src/commands/sync.test.ts index 24428bf..83c61c0 100644 --- a/src/commands/sync.test.ts +++ b/src/commands/sync.test.ts @@ -1,5 +1,9 @@ import * as nock from "nock"; import { stdout } from "stdout-stderr"; + +jest.mock("../util/configUtil.ts", () => ({ + writeConfig: jest.fn(), +})); import Sync from "./sync"; describe("SYNC intent", () => { diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 567923d..f210483 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,6 +1,10 @@ import { Command, flags } from "@oclif/command"; -import axios from "axios"; +import axios, { AxiosResponse } from "axios"; import { v4 as uuid } from "uuid"; +import { SyncRequest } from "../entities/SyncRequest"; +import { SyncResponse } from "../entities/SyncResponse"; +import { Config } from "../entities/Config"; +import { writeConfig } from "../util/configUtil"; export default class Sync extends Command { static description = "Sends a SYNC request intent"; @@ -26,7 +30,7 @@ export default class Sync extends Command { const token = flags.token; const uri = flags.uri; const requestId = uuid(); - const syncBody: SyncBody = { + const syncBody: SyncRequest = { requestId, inputs: [ { @@ -44,8 +48,13 @@ export default class Sync extends Command { responseType: "json", }) .then( - (response) => { - this.log(JSON.stringify(response.data, null, 2)); + (response: AxiosResponse) => { + const jsonResponse = JSON.stringify(response.data, null, 2); + const config: Config = { + ids: response.data.payload.devices.map((device) => device.id), + }; + writeConfig(this.config.configDir, config); + this.log(jsonResponse); }, (error) => { this.log(`Request ${requestId} failed with:`); @@ -54,10 +63,3 @@ export default class Sync extends Command { ); } } - -interface SyncBody { - requestId: string; - inputs: { - intent: "action.devices.SYNC"; - }[]; -} diff --git a/src/constants/executeCommands.ts b/src/constants/executeCommands.ts new file mode 100644 index 0000000..6b5dbe7 --- /dev/null +++ b/src/constants/executeCommands.ts @@ -0,0 +1,10 @@ +export const executeCommands: { + [command: string]: { [param: string]: string[] }; +} = { + StartStop: { + start: ["true", "false"], + }, + OnOff: { + on: ["true", "false"], + }, +}; diff --git a/src/entities/Config.ts b/src/entities/Config.ts new file mode 100644 index 0000000..d326e29 --- /dev/null +++ b/src/entities/Config.ts @@ -0,0 +1,3 @@ +export interface Config { + ids: string[]; +} diff --git a/src/entities/DisconnectRequest.ts b/src/entities/DisconnectRequest.ts new file mode 100644 index 0000000..4055f81 --- /dev/null +++ b/src/entities/DisconnectRequest.ts @@ -0,0 +1,6 @@ +export interface DisconnectRequest { + requestId: string; + inputs: { + intent: "action.devices.DISCONNECT"; + }[]; +} diff --git a/src/entities/ExecuteRequest.ts b/src/entities/ExecuteRequest.ts new file mode 100644 index 0000000..34748ec --- /dev/null +++ b/src/entities/ExecuteRequest.ts @@ -0,0 +1,15 @@ +export interface ExecuteRequest { + requestId: string; + inputs: { + intent: "action.devices.EXECUTE"; + payload: { + commands: { + devices: { id: string }[]; + execution: { + command: string; + params: any; + }[]; + }[]; + }; + }[]; +} diff --git a/src/entities/QueryRequest.ts b/src/entities/QueryRequest.ts new file mode 100644 index 0000000..952b381 --- /dev/null +++ b/src/entities/QueryRequest.ts @@ -0,0 +1,9 @@ +export interface QueryRequest { + requestId: string; + inputs: { + intent: "action.devices.QUERY"; + payload: { + devices: { id: string }[]; + }; + }[]; +} diff --git a/src/entities/SyncRequest.ts b/src/entities/SyncRequest.ts new file mode 100644 index 0000000..42bca3e --- /dev/null +++ b/src/entities/SyncRequest.ts @@ -0,0 +1,6 @@ +export interface SyncRequest { + requestId: string; + inputs: { + intent: "action.devices.SYNC"; + }[]; +} diff --git a/src/entities/SyncResponse.ts b/src/entities/SyncResponse.ts new file mode 100644 index 0000000..b245c0f --- /dev/null +++ b/src/entities/SyncResponse.ts @@ -0,0 +1,9 @@ +export interface SyncResponse { + requestId: string; + payload: { + agentUserId: string; + devices: { + id: string; + }[]; + }; +} diff --git a/src/util/configUtil.test.ts b/src/util/configUtil.test.ts new file mode 100644 index 0000000..a7affaf --- /dev/null +++ b/src/util/configUtil.test.ts @@ -0,0 +1,41 @@ +jest.mock("fs-extra"); +import * as fs from "fs-extra"; +import { readConfig, writeConfig } from "./configUtil"; +import { Config } from "../entities/Config"; + +describe("configUtil", () => { + describe("writeConfig write user config file", () => { + test("gassi-cli config folder exists", async () => { + jest + .spyOn(fs, "pathExists") + .mockImplementationOnce(async () => Promise.resolve(true)); + const config: Config = { ids: ["1"] }; + const dir = "/home/user/.config/gassi-cli/"; + + await writeConfig(dir, config); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.writeJSON).toHaveBeenCalledWith(`${dir}config.json`, config); + }); + + test("gassi-cli config doesn't exist", async () => { + jest + .spyOn(fs, "pathExists") + .mockImplementationOnce(async () => Promise.resolve(false)); + const config: Config = { ids: ["1"] }; + const dir = "/home/user/.config/gassi-cli/"; + + await writeConfig(dir, config); + expect(fs.mkdir).toHaveBeenCalled(); + expect(fs.writeJSON).toHaveBeenCalledWith(`${dir}config.json`, config); + }); + }); + + describe("readConfig reads config file", () => { + test("reads file", async () => { + const dir = "/home/user/.config/gassi-cli/"; + + await readConfig(dir); + expect(fs.readJSON).toHaveBeenCalledWith(`${dir}config.json`); + }); + }); +}); diff --git a/src/util/configUtil.ts b/src/util/configUtil.ts new file mode 100644 index 0000000..2e7c21c --- /dev/null +++ b/src/util/configUtil.ts @@ -0,0 +1,14 @@ +import * as fs from "fs-extra"; +import * as path from "path"; +import { Config } from "../entities/Config"; + +export async function writeConfig(dir: string, config: Config) { + if (!(await fs.pathExists(dir))) { + await fs.mkdir(dir); + } + await fs.writeJSON(path.join(dir, "config.json"), config); +} + +export async function readConfig(dir: string): Promise { + return fs.readJSON(path.join(dir, "config.json")); +} diff --git a/src/util/util.test.ts b/src/util/parseUtil.test.ts similarity index 91% rename from src/util/util.test.ts rename to src/util/parseUtil.test.ts index 2178092..d8a4908 100644 --- a/src/util/util.test.ts +++ b/src/util/parseUtil.test.ts @@ -1,6 +1,6 @@ -import { parseInput } from "./util"; +import { parseInput } from "./parseUtil"; -describe("Util", () => { +describe("parseUtil", () => { describe("parseInput returns parsed numeric, boolean or string", () => { test("numeric values are parsed", () => { expect(parseInput("19")).toEqual(19); diff --git a/src/util/util.ts b/src/util/parseUtil.ts similarity index 100% rename from src/util/util.ts rename to src/util/parseUtil.ts