diff --git a/packages/tools/src/cli.ts b/packages/tools/src/cli.ts index 53fc440..265e7e7 100644 --- a/packages/tools/src/cli.ts +++ b/packages/tools/src/cli.ts @@ -7,16 +7,11 @@ import { logger } from "./logger.js"; const jac = new Program("jac", "Tools for controlling devices running Jaculus", { globalOptions: { "log-level": new Opt("Set log level", { defaultValue: "info" }), - help: new Opt("Print this help message", { isFlag: true }), port: new Opt("Serial port to use (default: first available)"), baudrate: new Opt("Baudrate to use", { defaultValue: "921600" }), socket: new Opt("host:port to use"), }, action: async (options: Record) => { - if (options["help"]) { - stdout.write(jac.help() + "\n"); - throw 0; - } logger.level = options["log-level"] as string; }, }); @@ -24,13 +19,23 @@ const jac = new Program("jac", "Tools for controlling devices running Jaculus", // Help command jac.addCommand( "help", - new Command("Print help for given command", { + new Command("Print help for given command or subcommand", { action: async (options: Record, args: Record) => { const command = args["command"]; + const subcommand = args["subcommand"]; if (command) { const cmd = jac.getCommand(command); if (cmd) { - stdout.write(cmd.help(command) + "\n"); + if (subcommand) { + const subcmd = cmd.getSubcommand(subcommand); + if (subcmd) { + stdout.write(subcmd.help(`${command} ${subcommand}`) + "\n"); + } else { + stdout.write(`Unknown subcommand: ${subcommand}` + "\n"); + } + } else { + stdout.write(cmd.help(command) + "\n"); + } } else { stdout.write(`Unknown command: ${command}` + "\n"); } @@ -38,7 +43,10 @@ jac.addCommand( stdout.write(jac.help() + "\n"); } }, - args: [new Arg("command", "The command to get help for", { required: false })], + args: [ + new Arg("command", "The command to get help for", { required: false }), + new Arg("subcommand", "The subcommand to get help for", { required: false }), + ], }) ); @@ -50,8 +58,19 @@ if (args.length === 0) { } jac.run(args) - .then(() => { + .then((result) => { jac.end(); + + if (result.type === "help") { + stdout.write(result.text + "\n"); + process.exit(0); + } + + if (result.type === "exit") { + process.exit(result.code); + } + + // type === "continue" stderr.write("\nDone\n"); process.exit(0); }) diff --git a/packages/tools/src/commands/index.ts b/packages/tools/src/commands/index.ts index 03f42f4..2ba12c0 100644 --- a/packages/tools/src/commands/index.ts +++ b/packages/tools/src/commands/index.ts @@ -1,4 +1,4 @@ -import { Program } from "./lib/command.js"; +import { Program, Command } from "./lib/command.js"; import listPorts from "./list-ports.js"; import serialSocket from "./serial-socket.js"; @@ -42,8 +42,16 @@ export function registerJaculusCommands(jac: Program) { jac.addCommand("upload", upload); jac.addCommand("format", formatCmd); - jac.addCommand("project-create", projectCreate); - jac.addCommand("project-update", projectUpdate); + const projectCommand = new Command("Project management commands", { + description: "Manage projects, create and update projects.", + chainable: true, + subcommands: { + create: projectCreate, + update: projectUpdate, + }, + }); + jac.addCommand("project", projectCommand); + jac.addCommand("resources-ls", resourcesLs); jac.addCommand("resources-read", resourcesRead); @@ -52,12 +60,20 @@ export function registerJaculusCommands(jac: Program) { jac.addCommand("status", status); jac.addCommand("monitor", monitor); - jac.addCommand("wifi-get", wifiGet); - jac.addCommand("wifi-ap", wifiSetAp); - jac.addCommand("wifi-add", wifiAdd); - jac.addCommand("wifi-rm", wifiRemove); - jac.addCommand("wifi-sta", wifiSetSta); - jac.addCommand("wifi-disable", wifiDisable); + const wifiCommand = new Command("WiFi configuration commands", { + description: + "Manage WiFi settings, configure networks, and switch between AP and Station modes.", + chainable: true, + subcommands: { + get: wifiGet, + ap: wifiSetAp, + add: wifiAdd, + rm: wifiRemove, + sta: wifiSetSta, + disable: wifiDisable, + }, + }); + jac.addCommand("wifi", wifiCommand); jac.addCommand("serial-socket", serialSocket); } diff --git a/packages/tools/src/commands/lib/command.ts b/packages/tools/src/commands/lib/command.ts index d783289..fd424bf 100644 --- a/packages/tools/src/commands/lib/command.ts +++ b/packages/tools/src/commands/lib/command.ts @@ -59,6 +59,11 @@ function parseArgs( const options: Record = { ...base }; const argsList: string[] = []; + const allOpts: Record = { + ...expOpts, + help: new Opt("Show help for this command", { isFlag: true }), + }; + for (let i = 0; i < argv.length; i++) { let optName = getOptName(argv[i]); @@ -79,12 +84,12 @@ function parseArgs( value = v; } - if (optName in expOpts) { + if (optName in allOpts) { if (optName in options) { throw new Error(`Option --${optName} was specified multiple times`); } - if (expOpts[optName].isFlag) { + if (allOpts[optName].isFlag) { if (value !== undefined) { throw new Error(`Option --${optName} is a flag and does not accept a value`); } @@ -162,6 +167,11 @@ function parseArgs( export type Env = Record void }>; +export type CommandResult = + | { type: "continue"; remaining: string[] } + | { type: "help"; text: string } + | { type: "exit"; code: number }; + export class Opt { public description: string; public required: boolean; @@ -216,6 +226,7 @@ export class Command { public description?: string; readonly chainable: boolean = false; private args: Arg[] = []; + private subcommands: Record = {}; private action?: ( options: Record, args: Record, @@ -227,6 +238,7 @@ export class Command { options: { options?: Record; args?: Arg[]; + subcommands?: Record; action?: ( options: Record, args: Record, @@ -238,6 +250,7 @@ export class Command { ) { this.options = options.options ?? {}; this.args = options.args ?? []; + this.subcommands = options.subcommands ?? {}; this.action = options.action; this.description = options.description; this.chainable = options.chainable ?? false; @@ -245,8 +258,25 @@ export class Command { this.brief = brief; } + public addSubcommand(name: string, command: Command): void { + this.subcommands[name] = command; + } + + public getSubcommand(name: string): Command | undefined { + return this.subcommands[name]; + } + + public getSubcommands(): Record { + return this.subcommands; + } + public help(command: string): string { + const hasSubcommands = Object.keys(this.subcommands).length > 0; + let args = ""; + if (hasSubcommands) { + args = " "; + } for (const arg of this.args) { if (arg.required) { args += ` <${arg.name}>`; @@ -254,13 +284,23 @@ export class Command { args += ` [${arg.name}]`; } } - let help = `Usage: ${command} [OPTIONS]${args}\n\n`; + let help = `Usage: ${command}${args} [OPTIONS]\n\n`; help += `${this.brief}\n\n`; if (this.description) { help += `${this.description}\n\n`; } + if (hasSubcommands) { + help += "Subcommands:\n"; + const table = []; + for (const [name, subcommand] of Object.entries(this.subcommands)) { + table.push([name, subcommand.brief]); + } + help += tableToString(table, { padding: 2, indent: 2, minWidths: [12] }); + help += "\n"; + } + let table = []; for (const [name, opt] of Object.entries(this.options)) { const desc = @@ -289,18 +329,74 @@ export class Command { public async run( argv: string[], globals: Record, - env: Env - ): Promise { + env: Env, + commandName?: string + ): Promise { + const hasSubcommands = Object.keys(this.subcommands).length > 0; + + if (hasSubcommands) { + const { options, unknown } = parseArgs(argv, globals, this.options, []); + + if (unknown.length === 0) { + if (commandName) { + return { type: "help", text: this.help(commandName) }; + } + return { type: "exit", code: 0 }; + } + + const subcommandName = unknown[0]; + const subcommand = this.subcommands[subcommandName]; + + if (subcommand === undefined) { + throw new Error(`Unknown subcommand ${subcommandName}`); + } + + const mergedGlobals = { ...globals, ...options }; + return subcommand.run( + unknown.slice(1), + mergedGlobals, + env, + `${commandName} ${subcommandName}` + ); + } + const { options, args, unknown } = parseArgs(argv, globals, this.options, this.args); + if (options["help"]) { + if (commandName) { + return { type: "help", text: this.help(commandName) }; + } + return { type: "exit", code: 0 }; + } + if (this.action) { await this.action(options, args, env); } - return unknown; + return { type: "continue", remaining: unknown }; } public validate(argv: string[], globals: Record): string[] { + const hasSubcommands = Object.keys(this.subcommands).length > 0; + + if (hasSubcommands) { + const { options, unknown } = parseArgs(argv, globals, this.options, []); + + if (unknown.length === 0) { + return []; + } + + const subcommandName = unknown[0]; + const subcommand = this.subcommands[subcommandName]; + + if (subcommand === undefined) { + throw new Error(`Unknown subcommand ${subcommandName}`); + } + + const mergedGlobals = { ...globals, ...options }; + return subcommand.validate(unknown.slice(1), mergedGlobals); + } + const { unknown } = parseArgs(argv, globals, this.options, this.args); return unknown; @@ -338,13 +434,16 @@ export class Program { } public help(): string { - let out = `Usage: ${this.name} \n\n`; + let out = `Usage: ${this.name} [OPTIONS]\n\n`; out += `${this.description}\n\n`; out += "Commands:\n"; let table: string[][] = []; for (const [name, command] of Object.entries(this.commands)) { table.push([name, command.brief]); + for (const [subname, subcommand] of Object.entries(command.getSubcommands())) { + table.push([` ${name} ${subname}`, subcommand.brief]); + } } out += tableToString(table, { padding: 2, indent: 2, minWidths: [12] }); @@ -357,13 +456,15 @@ export class Program { } out += tableToString(table, { padding: 2, indent: 2, minWidths: [12] }); + out += `\nRun '${this.name} --help' for more information on a command.\n`; + return out; } private async runInternal( argv: string[], globals: Record = {} - ): Promise { + ): Promise { if (argv.length === 0) { throw new Error("No command specified"); } @@ -375,13 +476,13 @@ export class Program { throw new Error(`Unknown command ${commandName}`); } - return command.run(argv.slice(1), globals, this.env); + return command.run(argv.slice(1), globals, this.env, commandName); } private async runSingle( argv: string[], globals: Record = {} - ): Promise { + ): Promise { this.validateSingle(argv, globals); if (this.action) { @@ -415,9 +516,9 @@ export class Program { private async runChain( argv: string[], globals: Record = {} - ): Promise { + ): Promise { const res = parseArgs(argv, globals, this.globalOptions, []); - let unknown = res.unknown; + const unknown = res.unknown; const options = res.options; this.validateChain(unknown, options); @@ -426,11 +527,20 @@ export class Program { await this.action(globals); } - unknown = await this.runInternal(unknown, options); + let result = await this.runInternal(unknown, options); - while (unknown.length > 0) { - unknown = await this.runInternal(unknown, options); + if (result.type !== "continue") { + return result; + } + + while (result.remaining.length > 0) { + result = await this.runInternal(result.remaining, options); + if (result.type !== "continue") { + return result; + } } + + return { type: "continue", remaining: [] }; } private validateChain(argv: string[], globals: Record = {}): void { @@ -463,9 +573,13 @@ export class Program { public async run( argv: string[], globals: Record = {} - ): Promise { + ): Promise { const { options, unknown } = parseArgs(argv, globals, this.globalOptions, []); + if (options["help"] && unknown.length === 0) { + return { type: "help", text: this.help() }; + } + if (unknown.length === 0) { throw new Error("Command not specified"); } @@ -478,11 +592,10 @@ export class Program { } if (command.chainable) { - await this.runChain(unknown, options); - return; + return this.runChain(unknown, options); } - await this.runSingle(unknown, options); + return this.runSingle(unknown, options); } public end(): void { diff --git a/packages/tools/src/commands/wifi.ts b/packages/tools/src/commands/wifi.ts index 49ed0e0..9eed4ba 100644 --- a/packages/tools/src/commands/wifi.ts +++ b/packages/tools/src/commands/wifi.ts @@ -31,6 +31,7 @@ enum StaMode { } export const wifiAdd = new Command("Add a WiFi network", { + description: "Add a network to the device's known networks list. Password will be prompted.", action: async ( options: Record, args: Record, @@ -64,6 +65,7 @@ export const wifiAdd = new Command("Add a WiFi network", { }); export const wifiRemove = new Command("Remove a WiFi network", { + description: "Remove a network from the device's known networks list.", action: async ( options: Record, args: Record, @@ -95,6 +97,7 @@ export const wifiRemove = new Command("Remove a WiFi network", { }); export const wifiGet = new Command("Display current WiFi config", { + description: "Show WiFi mode, IP address, Station settings, and AP settings.", action: async ( options: Record, args: Record, @@ -156,6 +159,7 @@ AP SSID: ${apSsid} }); export const wifiDisable = new Command("Disable WiFi", { + description: "Turn off WiFi on the device.", action: async ( options: Record, args: Record, @@ -184,6 +188,8 @@ export const wifiDisable = new Command("Disable WiFi", { }); export const wifiSetAp = new Command("Set WiFi to AP mode (create a hotspot)", { + description: + "Create a WiFi hotspot. You can optionally set a custom SSID and password (else will be used previously set configuration).", action: async ( options: Record, args: Record, @@ -233,6 +239,7 @@ export const wifiSetAp = new Command("Set WiFi to AP mode (create a hotspot)", { }); export const wifiSetSta = new Command("Set WiFi to Station mode (connect to a wifi)", { + description: "Connect to WiFi networks. Automatically picks best signal or specify a network.", action: async ( options: Record, args: Record,