diff --git a/package/utils/cli/Makefile b/package/utils/cli/Makefile new file mode 100644 index 0000000000..65eae06309 --- /dev/null +++ b/package/utils/cli/Makefile @@ -0,0 +1,35 @@ +# +# Copyright (C) 2025 OpenWrt.org +# +# This is free software, licensed under the GNU General Public License v2. +# See /LICENSE for more information. +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=cli +PKG_RELEASE:=$(AUTORELEASE) + +PKG_LICENSE:=GPL-2.0 +PKG_MAINTAINER:=Felix Fietkau + +include $(INCLUDE_DIR)/package.mk + +define Package/cli + SECTION:=utils + CATEGORY:=Utilities + TITLE:=OpenWrt CLI + DEPENDS:=+ucode +ucode-mod-uline \ + +ucode-mod-ubus +ucode-mod-uloop \ + +ucode-mod-fs +ucode-mod-rtnl +endef + +define Build/Compile + : +endef + +define Package/cli/install + $(CP) ./files/* $(1)/ +endef + +$(eval $(call BuildPackage,cli)) diff --git a/package/utils/cli/docs/MODULE-API.md b/package/utils/cli/docs/MODULE-API.md new file mode 100644 index 0000000000..ac6e2ce760 --- /dev/null +++ b/package/utils/cli/docs/MODULE-API.md @@ -0,0 +1,364 @@ + Design of the `cli` module API + +## Structure: +The cli is organized as a set of *nodes*, which are ucode objects objects describing *entries*. +Each *entry* can either implement a *command*, or select another *node*, optionally with *parameters*. +Additionally, it contains helptext and full *parameter* descriptions, including everything needed for tab completion. +The initial *node* on startup is `Root`, representing the main menu. + +## Simple example: + +### Code: +``` +const Example = { + hello: { + help: "Example command", + args: [ + { + name: "name", + type: "string", + min: 3, + max: 16, + required: true, + } + ], + call: function(ctx, argv, named) { + return ctx.ok("Hello, " + argv[0]); + }, + }, + hello2: { + help: "Example command (named_args version)", + named_args: { + name: { + required: true, + args: { + type: "string", + min: 3, + max: 16, + } + } + }, + call: function(ctx, argv, named) { + return ctx.ok("Hello, " + named.name); + }, + } +}; + +const Root = { + example: { + help: "Example node", + select_node: "Example", + } +}; + +model.add_nodes({ Root, Example }); +``` +### Example interaction: +``` +root@OpenWrt:~# cli +Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments +cli> example +cli example> hello +Error: Missing argument 1: name +cli example> hello foo +Hello, foo +cli example> hello2 +Error: Missing argument: name +cli example> hello2 name foo2 +Hello, foo2 +cli example> +``` + +## API documentation: + +Each module is placed in `/usr/share/ucode/cli/modules` on the root filesystem. +When included by the cli code, the scope contains the `model` variable, which is the main cli API object. This variable is also present in the scope of the other callback functions described below. + +### `model` methods: +- `model.warn(msg)`: Pass a warning to the user (similar to the ucode `warn` function). +- `model.exception(e)`: Print an exception with stack trace. +- `model.add_module(path)`: Load a single module from `path` +- `model.add_modules(path)`: Load multiple modules from the `path` wildcard pattern +- `model.add_node(name, obj)`: Add a single node under the given name +- `model.add_nodes(nodes)`: Add multiple nodes with taking `name` and `obj` from the `nodes` object. +- `model.add_type(name, info)`: Add a data type with validation information, +- `model.add_types(types)`: Add multiple data types, taking `name` and `info` from the `types` object. +- `model.status_msg(msg)`: Print an asynchronous status message (should not be used from within a node `call` or `select` function). + +### Properties of an `entry` inside a `node`: +Each entry must have at least `help` and either `call` or `select_node` set. +- `help`: Helptext describing the command +- `call: function(ctx, argv, named)`: main command handler function of the entry. + - `this`: pointer to the `entry` + - `ctx`: call context object (see below) + - `argv`: array of positional arguments after the command name + - `named`: object of named parameters passed to the command + - Return value: either `ctx.ok(msg)` for successfull calls, or the result of an error function (see below). +- `select_node`: (string) name of the *node* that this entry points to. Mutually exclusive with implementing `call`. +- `select: function(ctx, argv, named)`: function for selecting another node. + - `this`: pointer to the *entry* + - `ctx`: node context object (see below) + - `argv`, `named`: see `call` + - Return value: either `ctx.set(prompt, data)`, `true`, or the result of an error function (see below). +- `args`: array of positional *arguments* (see *argument* property description) +- `named_args`: object of named *parameters* (see *parameter* property description) +- `available: function(ctx)`: function indicating if the entry can be used (affects tab completion and running commands) + - `this`: pointer to the *entry* + - `ctx`: node context object (see below) + - Return value: `true` if available, `false` otherwise. +- `validate: function (ctx, argv, named)`: validate command arguments + - Function parameters: see `call` + +### Named *parameter* properties: +- `help`: Description of the named parameter's purpose +- `args`: Either an array of *argument* objects, or an object with a single *argument* (see below). If not set, paramter will not take any arguments, and its value will be `true` if the parameter was specified on the command line. +- `available: function(ctx, argv, named)`: function indicating if the named parameter can be used (affects tab completion and argument validation). May depend on *arguments*/*parameters* specified before this one. +- `multiple` (bool): indicates if an argument may be specified multiple times. Turns the value in `named` into an array. +- `required` (bool): Parameter must be specified for the command +- `default`: default value for the parameter. +- `allow_empty`: empty values are allowed and can be specified on the command line using `-param_name` instead of `param_name`. The value in the `named` object will be `null` in that case. + +### Positional *argument* properties: +- `name`: Short name of the *argument* +- `help`: Longer description of the *argument* (used in helptext/completion) +- `type`: data type name (see below) +- `required` (bool): Value must not be empty +- `value`: possible values for tab completion, one of: + - array of objects with the following contents: + - `name`: value string + - `help`: help text for this value + - `function(ctx, argv, named)` returning the above. +- extra properties specific to the data type (see below) + +### Default data types: +- `int`: Integer value. The valid range can be specified using the `min` and `max` properties. +- `string`: String value. The valid string length can be specified using the `min` and `max` properties. +- `bool`: Boolean value. Converts `"1"` and `"0"` to `true` and `false` +- `enum`: String value that must match one entry of the list provided via the `value` property. Case-insensitive match can be enabled using the `ignore_case` property. +- `path`: Local filesystem path. When the `new_path` property is set, only match directories for a file to be created. +- `host`: Host name or IP address +- `macaddr`: MAC address +- `ipv4`: IPv4 address +- `ipv6`: IPv6 address +- `cidr4`: IPv4 address with netmask size, e.g. 192.168.1.1/24. Allows `auto` as value if the `allow_auto` property is set. + +### `call` context: +Passed as `ctx` argument to entry `call` functions. +- `ctx.data`: Object containing any data passed via `ctx.set()` from a `select` context. +- `ctx.ok(msg)`: Indicates successful call, passes the message `msg` to the user. +- `ctx.select(...args)`: After completion, switch to a different *node* by running the command chain provided as function argument (only entries with `.select_node` are supported). +- `ctx.string(name, val)`: Passes a string to the caller as return value. +- `ctx.list(name, val)`: Passes a list of values to the caller as return value. `val` must be an array. +- `ctx.table(name, val)`: Passes a table as value to the caller. `val` can be an array `[ column_1, column_2 ]`, where each member of the outer array describes a row in the table. It can also be an object, where the property name is the first column value, and the value the second column value. +- `ctx.multi_table(name, val)`: Passes multiple tables to the caller. Can be an array of `[ title, table ]`, or an object. +- Error functions (see below) + +### `select` context: +- `ctx.data`: Object containing any data passed via parent `ctx.set` calls. +- `ctx.set(prompt, data)`: Modify the prompt and `ctx.data` for the child context. The string given in `prompt` is appended to the existing prompt. The data given in the `data` object is merged with the previous `ctx.data` value. +- Error functions (see below) + +### Error functions: +All error messages accept a format string in `msg`, with arguments added after it. +- `ctx.invalid_argument(msg, ...args)`: Indicates that invalid arguments were provided. +- `ctx.missing_argument(msg, ...args)`: Indicates that an expected argument was missing. +- `ctx.command_failed(msg, ...args)`: Indicates that the command failed. +- `ctx.not_found(msg, ...args)`: Indicates that a given entry was not found. +- `ctx.unknown_error(msg, ...args)`: Indicates that the command failed for unknown or unspecified reasons. +- `ctx.error(id, msg, ...args)`: Generic error message with `id` specifying a machine readable error type string. + +## Editor API documentation +The editor API provides a layer of abstraction above node entries/calls in order to make it easy to edit properties of an object based on an attribute list, as well as create/destroy/show object instances using a consistent user interface. + +### Simple example: +``` +import * as editor from "cli.object-editor"; + +let changed = false; +let data = { + things: { + foo: { + label_str: [ "bar" ], + id: 31337, + } + }, +}; + +const thing_editor = { + change_cb: function(ctx) { + changed = true; + }, + named_args: { + label: { + help: "Thing label", + attribute: "label_str", + multiple: true, + args: { + type: "string", + min: 2, + max: 16 + }, + }, + id: { + help: "Thing id", + required: true, + args: { + type: "int", + min: 1, + }, + }, + }, +}; +const ExampleThing = editor.new(thing_editor); + +let Example = { + dump: { + help: "Dump current data", + call: function(ctx, argv, named) { + return ctx.json("Data", { + changed, + data + }); + }, + } +}; +const example_editor = { + change_cb: function(ctx) { + changed = true; + }, + types: { + thing: { + node_name: "ExampleThing", + node: ExampleThing, + object: "things", + }, + }, +}; +editor.edit_create_destroy(example_editor, Example); + +const Root = { + example: { + help: "Example node", + select_node: "Example", + select: function(ctx, argv, named) { + return ctx.set(null, { + object_edit: data, + }); + } + } +}; + +model.add_nodes({ Root, Example, ExampleThing }); +``` +### Example interaction: +``` +root@OpenWrt:~# cli +Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments +cli> example +cli example> dump +Data: { + "changed": false, + "data": { + "things": { + "foo": { + "label_str": [ + "bar" + ], + "id": 31337 + } + } + } +} +cli example> thing foo set id 1337 +cli example> create thing bar id 168 label l1 label l2 +Added thing 'bar' +cli example> thing bar show +Values: + id: 168 + label: l1, l2 +cli example> thing bar remove label 1 +cli example> thing bar show +Values: + id: 168 + label: l2 +cli example> dump +Data: { + "changed": true, + "data": { + "things": { + "foo": { + "label_str": [ + "bar" + ], + "id": 1337 + }, + "bar": { + "id": 168, + "label_str": [ + "l2" + ] + } + } + } +} +cli example> destroy thing foo +Deleted thing 'foo' +cli example> +``` +### API documentation +Prelude: `import * as editor from "cli.object-editor";` + +#### Object editor: +For editing an object, the following user commands are defined: +- `set`: Changes property values +- `show` Show all values + +If properties with `multiple: true` are defined, the following commands are also defined: +- `add`: Add values to properties +- `remove` Remove specific values from properties + +##### Variant 1 (editor-only node): +`const Node = editor.new(editor_data)` + +##### Variant 2 (merge with existing entries): +`let Node = {};` +`editor.new(editor_data, Node);` + +The editor code assumes that the *node* that selects the editor node uses `ctx.set()` to set the `edit` field in `ctx.data` to the object being edited. + +#### `editor_data` properties: +- `change_cb: function(ctx)`: Called whenever a property is changed by the user +- `named_args`: Parameters for editing properties (based on *entry* `named_args`, see below) +- `add`, `set`, `show`, `remove`: Object for overriding fields of the commands defined by the editor. Primarily used to override the helptext. + +#### Instance editor `named_args` entry properties: +All *entry* `named_args` properties are supported, but the meaning is extended slightly: +- `multiple`: Property array values can be added/removed +- `default`: Default value when creating the object +- `allow_empty`: Property can be deleted +- `required`: Property is mandatory in the object. + +#### Object instance editor: +For managing object instances, the following user commands are defined: +- `create <...>`: Create a new instance. Also takes parameter values to be set on the object. +- `destroy `: Delete an instance. +- `list ` List all instances of a given type. + +The instance editor code assumes that the *node* that selects the editor node uses `ctx.set()` to set the `object_edit` field in `ctx.data` to the object being edited. + +##### Variant 1 (editor-only node): +`const Node = editor.edit_create_destroy(instance_data);` + +##### Variant 2 (merge with existing entries): +`let Node = {};` +`editor.edit_create_destroy(instance_data, Node);` + +#### `instance_data` properties: +- `change_cb: function(ctx)`: Called whenever an instance is added or deleted +- `types`: Metadata about instances types (see below) + +#### `instance_data.types` object properties: +- `node_name`: name of the *editor node* belonging to the object instance. +- `node`: The *editor node* itself. +- `object`: Name of the type specific container object inside the object pointed to by `ctx.data.object_edit`. + diff --git a/package/utils/cli/files/usr/sbin/cli b/package/utils/cli/files/usr/sbin/cli new file mode 100755 index 0000000000..0a763f2fdb --- /dev/null +++ b/package/utils/cli/files/usr/sbin/cli @@ -0,0 +1,741 @@ +#!/usr/bin/env ucode +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; +import * as datamodel from "cli.datamodel"; +import { bold, color_fg } from "cli.color"; +import * as uline from "uline"; +import { basename, stdin } from "fs"; + +let history = []; +let history_edit; +let history_idx = -1; +let cur_line; +let interactive; +let script_mode; + +let el; +let model = datamodel.new({ + getpass: uline.getpass, + poll_key: (timeout) => el.poll_key(timeout), + status_msg: (msg) => { + el.hide_prompt(); + warn(msg + "\n"); + el.refresh_prompt(); + }, +}); +let uloop = model.uloop; +model.add_modules(); +let ctx = model.context(); +let parser = uline.arg_parser({ + line_separator: ";" +}); +let base_prompt = [ "cli" ]; + +model.add_nodes({ + Root: { + exit: { + help: "Exit the CLI", + call: function(ctx) { + el.close(); + uloop.end(); + interactive = false; + } + } + } +}); + +model.init(); + +function update_prompt() { + el.set_state({ + prompt: bold(join(" ", [ ...base_prompt, ...ctx.prompt ]) + "> "), + }); +} + +let cur_completion, tab_arg, tab_arg_len, tab_prefix, tab_suffix, tab_prefix_len, tab_quote, tab_ctx; + +function max_len(list, len) +{ + for (let entry in list) + if (length(entry) > len) + len = length(entry); + return len + 3; +} + +function sort_completion(data) +{ + let categories = {}; + for (let entry in data) { + let cat = entry.category ?? " "; + categories[cat] ??= []; + push(categories[cat], entry); + } + return categories; +} + +function val_str(val) +{ + if (type(val) == "array") + return join(", ", val); + return val; +} + +function helptext_list_str(cur, str) +{ + let data = cur.value; + let categories = sort_completion(data); + let cat_len = max_len(keys(categories)); + let has_categories = length(categories) > 1 || !categories[" "]; + let len = max_len(map(data, (v) => v.name), 10); + + if (has_categories || str == null) + str = ""; + + for (let cat, cdata in categories) { + if (has_categories && cat != " ") { + if (length(str) > 0) + str += "\n"; + str += `${cat}:\n`; + } + + for (let val in cdata) { + let name = val.name; + let help = val.help ?? ""; + let extra = []; + if (val.multiple) + push(extra, "multiple"); + if (val.required) + push(extra, "required"); + if (val.default) + push(extra, "default: " + val_str(val.default)); + if (length(extra) > 0) + help += " (" + join(", ", extra) + ")"; + if (length(help) > 0) + name += ":"; + str += sprintf(" %-" + len + "s %s\n", name, help); + } + } + + return str; +} + +function helptext(cur) { + if (!cur) { + el.set_hint(`\n No help information available\n`); + return true; + } + + let str = `${cur.help}: `; + let data = cur.value; + if (type(data) != "array") { + str += `<${cur.type}>\n`; + } else if (length(data) > 0) { + str += "\n"; + str = helptext_list_str(cur, str); + } else { + str += " (no match)\n"; + } + el.set_hint(str); + return true; +} + +function completion_ctx(arg_info) +{ + let cur_ctx = ctx; + for (let args in arg_info.args) { + let sel = cur_ctx.select(args, true); + if (!length(args)) + cur_ctx = sel; + if (type(sel) != "object" || sel.errors) + return; + } + + return cur_ctx; +} + +function completion_replace_arg(val, incomplete, skip_space) +{ + let ref = substr(tab_prefix, -tab_prefix_len); + val = parser.escape(val, ref); + + if (incomplete) { + let last = substr(val, -1); + if (last == '"' || last == "'") + val = substr(val, 0, -1); + } else if (!skip_space) { + val += " "; + } + + let line = tab_prefix; + if (tab_prefix_len) + line = substr(tab_prefix, 0, -tab_prefix_len); + line += val; + let pos = length(line); + line += tab_suffix; + el.set_state({ line, pos }); +} + +function completion_check_prefix(data) +{ + let prefix = data[0].name; + let prefix_len = length(prefix); + + for (let entry in data) { + entry = entry.name; + if (prefix_len > length(entry)) + prefix_len = length(entry); + } + prefix = substr(prefix, 0, prefix_len); + + for (let entry in data) { + entry = substr(entry.name, 0, prefix_len); + while (entry != prefix) { + prefix_len--; + prefix = substr(prefix, 0, prefix_len); + entry = substr(entry, 0, prefix_len); + } + } + + completion_replace_arg(prefix, true); +} + +function completion(count) { + if (count < 2) { + let line_data = el.get_line(); + let line = line_data.line; + let pos = line_data.pos; + tab_suffix = substr(line, pos); + if (length(tab_suffix) > 0 && + substr(tab_suffix, 0, 1) != " ") { + let idx = index(tab_suffix, " "); + if (idx < 0 || !idx) + pos += length(tab_suffix); + else + pos += idx; + + tab_suffix = substr(line, pos); + } + tab_prefix = substr(line, 0, pos); + + let arg_info = parser.parse(tab_prefix); + let is_open = arg_info.missing != null; + if (arg_info.missing == "\\\"") + tab_quote = "\""; + else + tab_quote = arg_info.missing ?? ""; + let args = pop(arg_info.args); + let arg_pos = pop(arg_info.pos); + + if (!is_open && substr(tab_prefix, -1) == " ") + push(args, ""); + let tab_arg_pos = arg_pos[length(args) - 1]; + tab_arg = args[length(args) - 1]; + if (tab_arg_pos) + tab_prefix_len = tab_arg_pos[1] - tab_arg_pos[0]; + else + tab_prefix_len = 0; + + tab_ctx = completion_ctx(arg_info); + if (!tab_ctx) + return; + + cur_completion = tab_ctx.complete([...args]); + } + + if (!tab_ctx) + return; + + if (count < 0 || (cur_completion && cur_completion.force_helptext)) + return helptext(cur_completion); + + let cur = cur_completion; + if (!cur || !cur.value) { + if (!tab_prefix_len) { + el.set_hint(""); + return; + } + + cur = { + value: [{ + name: tab_arg, + }] + }; + } + + let data = cur.value; + if (length(data) == 0) { + el.set_hint(` (no match)`); + return; + } + + if (length(data) == 1) { + completion_replace_arg(data[0].name, data[0].incomplete); + el.set_hint(""); + el.reset_key_input(); + return; + } + + if (count == 1) + completion_check_prefix(data); + + if (count > 1) { + let idx = (count - 2) % length(data); + completion_replace_arg(data[idx].name, false, true); + } + + let win = el.get_window(); + let str = ""; + let x = 0; + + let categories = sort_completion(data); + let cat_len = max_len(keys(categories)); + let len = max_len(map(data, (v) => v.name)); + let has_categories = length(categories) > 1 || !categories[" "]; + + for (let cat, cdata in categories) { + let cat_start = cat != " "; + if (cat_start) + cat += ": "; + + if (x) { + str += "\n"; + x = 0; + } + for (let entry in cdata) { + let add; + + if (!x && has_categories) + add = sprintf(" %-"+cat_len+"s", cat); + else + add = " "; + cat = ""; + + add += sprintf("%-"+len+"s", entry.name); + str += add; + x += length(add); + + if (x + length(add) < win.x) + continue; + + str += "\n"; + x = 0; + } + } + el.set_hint(str); +} + +function format_entry(val) +{ + if (type(val) == "bool") + val = val ? "yes" : "no"; + return val; +} + +function format_multiline(prefix, val) +{ + let prefix2 = replace(prefix, /./g, " "); + let prefix_len = length(prefix); + let win = el.get_window(); + let x = 0; + + if (type(val) != "array") + val = [ val ]; + + for (let cur in val) { + cur = format_entry(cur); + let cur_lines = split(cur, "\n"); + if (length(cur_lines) > 1) { + if (x) { + warn(',\n'); + x = 0; + } + + cur = join("\n" + prefix2, cur_lines); + warn(cur); + x = win.x; + prefix = null; + continue; + } + + if (x && (x + length(cur) > win.x - 3)) { + warn(',\n'); + x = 0; + } + + if (!x) { + warn(prefix ?? prefix2); + prefix = null; + x = prefix_len; + } else { + warn(', '); + x += 2; + } + + warn(cur); + x += length(cur); + } + warn('\n'); +} + +function format_table(table) +{ + let data = table; + + let len = max_len(map(data, (v) => v[0]), 8); + for (let line in data) { + let name = line[0]; + let val = line[1]; + let prefix = sprintf(" %-" + len + "s ", name + ":"); + format_multiline(prefix, val); + } +} + +function convert_table(val) +{ + if (type(val) == "array") + return val; + + let data = []; + for (let name in sort(keys(val))) + push(data, [ name, val[name] ]); + + return data; +} + +function convert_multi_table(val) +{ + if (type(val) != "array") { + let data = []; + for (let name in sort(keys(val))) + push(data, [ val[name], name ]); + val = data; + } + + for (let line in val) + line[0] = convert_table(line[0]); + + return val; +} + +function format_result(res) +{ + if (!res) { + warn(color_fg("red", "Unknown command") + "\n"); + return; + } + if (!res.ok) { + for (let err in res.errors) { + warn(color_fg("red", "Error: "+ err.msg) + "\n"); + } + if (!length(res.errors)) + warn(color_fg("red", "Failed") + "\n"); + return; + } + + if (res.status_msg) + warn(color_fg("green", res.status_msg) + "\n"); + + if (res.name) + warn(res.name + ": "); + + let data = res.data; + switch (res.type) { + case "multi_table": + data = convert_multi_table(data); + warn("\n"); + for (let table in data) { + if (table[1]) + warn("\n" + table[1] + ":\n"); + format_table(table[0]); + warn("\n"); + } + break; + case "table": + data = convert_table(data); + warn("\n"); + format_table(data); + break; + case "list": + warn("\n"); + for (let entry in data) + warn(" - " + entry + "\n"); + break; + case "string": + warn(res.data + "\n"); + break; + case "json": + warn(sprintf("%.J\n", res.data)); + break; + } +} + +function line_history_reset() +{ + history_idx = -1; + history_edit = null; + cur_line = null; +} + +function line_history(dir) +{ + let min_idx = cur_line == null ? 0 : -1; + let new_idx = history_idx + dir; + + if (new_idx < min_idx || new_idx >= length(history)) + return; + + let line = el.get_line().line; + let cur_history = history_edit ?? history; + if (history_idx == -1) + cur_line = line; + else if (cur_history[history_idx] != line) { + history_edit ??= [ ...history ]; + history_edit[history_idx] = line; + cur_history = history_edit; + } + + history_idx = new_idx; + if (history_idx < 0) + line = cur_line; + else + line = cur_history[history_idx]; + let pos = length(line); + el.set_state({ line, pos }); + +} +let rev_search, rev_search_results, rev_search_index; + +function reverse_search_update(line) +{ + if (line) { + rev_search = line; + rev_search_results = filter(history, (l) => index(l, line) >= 0); + rev_search_index = 0; + } + + let prompt = "reverse-search: "; + if (line && !length(rev_search_results)) + prompt = "failing " + prompt; + + el.set_state({ + line2_prompt: prompt, + }); + + if (line && length(rev_search_results)) { + line = rev_search_results[0]; + let pos = length(line); + el.set_state({ line, pos }); + } +} + +function reverse_search_reset() { + if (rev_search == null) + return; + rev_search = null; + rev_search_results = null; + rev_search_index = 0; + el.set_state({ + line2_prompt: null + }); +} + +function reverse_search() +{ + if (rev_search == null) { + reverse_search_update(""); + return; + } + + if (!length(rev_search_results)) + return; + + rev_search_index = (rev_search_index + 1) % length(rev_search_results); + let line = rev_search_results[rev_search_index]; + let pos = length(line); + el.set_state({ line, pos }); +} + +function line_cb(line) +{ + reverse_search_reset(); + line_history_reset(); + unshift(history, line); + + let arg_info = parser.parse(line); + if (!arg_info) + return; + for (let cmd in arg_info.args) { + let orig_cmd = [ ...cmd ]; + + // convenience hack + if (cmd[0] == "cd" && cmd[1] == "..") { + shift(cmd); + cmd[0] = "up"; + } else if (cmd[0] == "ls") { + let compl = ctx.complete([""]); + if (!compl) + continue; + + warn(helptext_list_str(compl)); + continue; + } + + let cur_ctx = ctx.select(cmd); + if (type(cur_ctx) != "object" || cur_ctx.errors) { + format_result(cur_ctx); + break; + } + + if (!length(cmd)) { + ctx = cur_ctx; + update_prompt(); + continue; + } + + try { + let res = cur_ctx.call(cmd); + format_result(res); + if (res && res.ctx) { + ctx = res.ctx; + update_prompt(); + } + } catch (e) { + model.exception(e); + } + } +} + +const cb = { + eof: () => { warn(`\n`); uloop.end(); }, + line_check: (line) => parser.check(line) == null, + line2_cursor: () => { + reverse_search_reset(); + return false; + }, + line2_update: reverse_search_update, + key_input: (c, count) => { + try { + switch(c) { + case "?": + if (parser.check(el.get_line().line) != null) + return false; + completion(-1); + return true; + case "\t": + reverse_search_reset(); + completion(count); + return true; + case '\x03': + if (count < 2) { + el.set_state({ line: "", pos: 0 }); + } else if (ctx.prev) { + warn(`\n`); + let cur_ctx = ctx.select([ "main" ]); + if (cur_ctx && !cur_ctx.errors) + ctx = cur_ctx; + update_prompt(); + } else { + warn(`\n`); + el.poll_stop(); + uloop.end(); + } + return true; + case "\x12": + reverse_search(); + return true; + } + } catch (e) { + warn(`${e}\n${e.stacktrace[0].context}`); + } + }, + cursor_up: () => { + try { + line_history(1); + } catch (e) { + el.set_hint(`${e}\n${e.stacktrace[0].context}`); + } + }, + cursor_down: () => { + try { + line_history(-1); + } catch (e) { + el.set_hint(`${e}\n${e.stacktrace[0].context}`); + } + }, +}; +el = uline.new({ + utf8: true, + cb, + key_input_list: [ "?", "\t", "\x03", "\x12" ] +}); + +while (length(ARGV) > 0) { + let cmd = ARGV[0]; + if (substr(cmd, 0, 1) != "-") + break; + + shift(ARGV); + switch (cmd) { + case '-i': + interactive = true; + break; + case '-s': + script_mode = true; + break; + } +} + +if (SCRIPT_NAME != "cli") { + let cur_ctx = ctx.select([ basename(SCRIPT_NAME) ]); + if (cur_ctx && cur_ctx != ctx && !cur_ctx.errors) { + ctx = cur_ctx; + delete ctx.prev; + ctx.node.exit = model.node.Root.exit; + base_prompt = []; + } +} + +while (length(ARGV) > 0) { + let cmd = ARGV; + let idx = index(ARGV, ":"); + if (idx >= 0) { + cmd = slice(ARGV, 0, idx); + ARGV = slice(ARGV, idx + 1); + } else { + ARGV = []; + } + interactive ??= false; + + let orig_cmd = [ ...cmd ]; + let cur_ctx = ctx.select(cmd); + if (type(cur_ctx) != "object" || cur_ctx.errors) { + format_result(cur_ctx); + break; + } + + if (!length(cmd)) { + ctx = cur_ctx; + continue; + } + + let res = cur_ctx.call(cmd); + format_result(res); +} + +if (script_mode) { + el.close(); + while (!stdin.error()) { + let line = stdin.read("line"); + line_cb(line); + } + exit(0); +} + +if (interactive != false) { + warn("Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments\n"); + update_prompt(); + el.set_uloop(line_cb); + uloop.run(); + exit(0); +} diff --git a/package/utils/cli/files/usr/share/ucode/cli/cache.uc b/package/utils/cli/files/usr/share/ucode/cli/cache.uc new file mode 100644 index 0000000000..27cc049f73 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/cache.uc @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +const CACHE_DEFAULT_TIMEOUT = 5; + +function cache_get(key, fn, timeout) +{ + let now = time(); + let entry = this.entries[key]; + if (entry) { + if (now < entry.timeout) + return entry.data; + + if (!fn) + delete this.entries[key]; + } + + if (!fn) + return; + + let data = fn(); + if (!entry) + this.entries[key] = entry = {}; + timeout ??= CACHE_DEFAULT_TIMEOUT; + entry.timeout = now + timeout; + entry.data = data; + + return data; +} + +function cache_remove(key) +{ + delete this.entries[key]; +} + +function cache_gc() { + let now = time(); + for (let key, entry in this.entries) + if (now > entry.timeout) + delete this.entries[key]; +} + +const cache_proto = { + get: cache_get, + remove: cache_remove, + gc: cache_gc, +}; + +export function new(model) { + model.cache_proto ??= { model, ...cache_proto }; + let cache = proto({ + entries: {}, + }, model.cache_proto); + cache.gc_interval = model.uloop.interval(10000, () => { + cache.gc(); + }); + + return cache; +}; diff --git a/package/utils/cli/files/usr/share/ucode/cli/color.uc b/package/utils/cli/files/usr/share/ucode/cli/color.uc new file mode 100644 index 0000000000..39e5863533 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/color.uc @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +const color_codes = { + black: 30, + red: 31, + green: 32, + yellow: 33, + blue: 34, + magenta: 35, + cyan: 36, + white: 37, + default: 39 +}; + +function color_str(n) +{ + return "\e["+n+"m"; +} + +function color_code(str) +{ + let n = 0; + if (substr(str, 0, 7) == "bright_") { + str = substr(str, 7); + n += 60; + } + if (!color_codes[str]) + return; + + n += color_codes[str]; + return n; +} + +export function color_fg(name, str) +{ + let n = color_code(name); + if (!n) + return str; + + let ret = color_str(n); + if (str != null) + ret += str + color_str(39); + + return ret; +}; + +export function color_bg(name, str) +{ + let n = color_code(name); + if (!n) + return str; + + let ret = color_str(n + 10); + if (str != null) + ret += str + color_str(49); + + return ret; +}; + +export function bold(str) +{ + return color_str(1) + str + color_str(0); +}; diff --git a/package/utils/cli/files/usr/share/ucode/cli/context-call.uc b/package/utils/cli/files/usr/share/ucode/cli/context-call.uc new file mode 100644 index 0000000000..0977cda741 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/context-call.uc @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +function default_result() +{ + return { + errors: [], + ok: false + }; +} + +function context_clone() +{ + let ret = { ...this }; + ret.result = default_result(); + ret.data = { ...ret.data }; + return proto(ret, proto(this)); +} + +function call_select(...args) +{ + this.result.ctx = this.node_ctx.select(args); +} + +function call_ok(msg) +{ + this.result.ok = true; + if (msg) + this.result.status_msg = msg; + return true; +} + +function call_error(code, msg, ...args) +{ + msg ??= "Unknown error"; + msg = sprintf(msg, ...args); + let error = { + code, msg, args + }; + push(this.result.errors, error); +} + +function call_generic(ctx, name, type, val) +{ + ctx.result.type = type; + ctx.result.name = name; + ctx.result.data = val; + return ctx.ok(); +} + +function call_multi_table(name, val) +{ + return call_generic(this, name, "multi_table", val); +} + +function call_table(name, val) +{ + return call_generic(this, name, "table", val); +} + +function call_list(name, val) +{ + return call_generic(this, name, "list", val); +} + +function call_string(name, val) +{ + return call_generic(this, name, "string", val); +} + +function call_json(name, val) +{ + return call_generic(this, name, "json", val); +} + +function call_apply_defaults(named_args, args) +{ + let entry = this.entry; + named_args ??= entry.named_args; + args ??= this.named_args; + for (let name, arg in named_args) + if (arg.default != null && !(name in args)) + args[name] ??= arg.default; +} + +export const callctx_error_proto = { + missing_argument: function(msg, ...args) { + return this.error("MISSING_ARGUMENT", msg ?? "Missing argument", ...args); + }, + invalid_argument: function(msg, ...args) { + return this.error("INVALID_ARGUMENT", msg ?? "Invalid argument", ...args); + }, + unknown_error: function(msg, ...args) { + return this.error("UNKNOWN_ERROR", msg ?? "Unknown error", ...args); + }, + not_found: function(msg, ...args) { + return this.error("NOT_FOUND", msg ?? "Not found", ...args); + }, + command_failed: function(msg, ...args) { + return this.error("COMMAND_FAILEDu", msg ?? "Command failed", ...args); + }, +}; + +const callctx_proto = { + clone: context_clone, + select: call_select, + apply_defaults: call_apply_defaults, + ok: call_ok, + list: call_list, + table: call_table, + multi_table: call_multi_table, + string: call_string, + json: call_json, + + error: call_error, + ...callctx_error_proto, +}; + +export function new(model, ctx) { + let node_ctx = ctx; + let data = ctx.data; + model.callctx_proto ??= { model, ...callctx_proto }; + let result = default_result(); + return proto({ node_ctx, data, result }, model.callctx_proto); +}; diff --git a/package/utils/cli/files/usr/share/ucode/cli/context.uc b/package/utils/cli/files/usr/share/ucode/cli/context.uc new file mode 100644 index 0000000000..b3f24f77a8 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/context.uc @@ -0,0 +1,679 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +import * as callctx from "cli.context-call"; + +function prefix_match(prefix, str, icase) +{ + if (icase) { + str = lc(str); + prefix = lc(prefix); + } + return substr(str, 0, length(prefix)) == prefix; +} + +function context_clone() +{ + let ret = { ...this }; + ret.prompt = [ ...ret.prompt ]; + ret.data = { ...ret.data }; + ret.hooks = {}; + return proto(ret, proto(this)); +} + +function context_entries() +{ + return keys(this.node) +} + +function context_help(entry) +{ + if (entry) + return this.node[entry].help; + + let ret = {}; + for (let name, val in this.node) + ret[name] = val.help ?? ""; + + return ret; +} + +function context_add_hook(type, cb) +{ + this.hooks[type] ??= []; + push(this.hooks[type], cb); +} + +function context_select_error(code, msg, ...args) +{ + msg ??= "Unknown error"; + msg = sprintf(msg, ...args); + let error = { + code, msg, args + }; + this.errors ??= []; + push(this.errors, error); +} + +function context_set(prompt, data) +{ + if (prompt) + this.cur_prompt = prompt; + if (data) + this.data = { ...this.data, ...data }; + return true; +} + +const context_select_proto = { + add_hook: context_add_hook, + error: context_select_error, + set: context_set, + ...callctx.callctx_error_proto, +}; + +function __context_select(ctx, name, args) +{ + let entry = ctx.node[name]; + if (!entry || !entry.select_node) + return; + + let node = ctx.model.node[entry.select_node]; + if (!node) + return; + + let ret = proto(ctx.clone(), ctx.model.context_select_proto); + ret.cur_prompt = name; + ret.node = node; + try { + if (entry.select && + !call(entry.select, entry, ctx.model.scope, ret, args)) + ret.errors ??= []; + } catch (e) { + ctx.model.exception(e); + return; + } + + push(ret.prompt, ret.cur_prompt); + ret.prev = ctx; + proto(ret, proto(ctx)); + + return ret; +} + +function context_run_hooks(ctx, name) +{ + try { + while (length(ctx.hooks[name]) > 0) { + let hook = ctx.hooks[name][0]; + + let ret = call(hook, ctx, ctx.model.scope); + if (!ret) + return false; + + shift(ctx.hooks.exit); + } + } catch (e) { + ctx.model.exception(e); + return false; + } + + return true; +} + +function context_prev(ctx, skip_hooks) +{ + if (!skip_hooks && !context_run_hooks(ctx, "exit")) + return; + return ctx.prev; +} + +function context_top(ctx, skip_hooks) +{ + while (ctx && ctx.prev) + ctx = context_prev(ctx, skip_hooks); + return ctx; +} + +function prepare_spec(e, ctx, spec, argv) +{ + if (type(spec) != "function") + return spec; + + return call(spec, e, ctx.model.scope, ctx, argv); +} + +function prepare_default(e, ctx, spec, argv, named_args) +{ + if (type(spec) != "object" || type(spec.default) != "function") + return; + + try { + spec.default = call(spec.default, e, ctx.model.scope, ctx, argv, named_args, spec); + } catch (e) { + model.exception(e); + } +} + +function prepare_attr_spec(e, ctx, spec, argv, named_args) +{ + if (type(spec) != "object") + return spec; + + let t = ctx.model.types[spec.type]; + if (t) + spec = { ...t, ...spec }; + else + spec = { ...spec }; + + prepare_default(e, ctx, spec, argv, named_args, spec); + if (type(spec.value) == "function") + try { + spec.value = call(spec.value, e, ctx.model.scope, ctx, argv, named_args, spec); + } catch (e) { + ctx.model.exception(e); + spec.value = []; + } + + return spec; +} + +function parse_arg(ctx, name, spec, val) +{ + let t; + + if (val == null) { + ctx.invalid_argument("Missing argument %s", name); + return; + } + + if (type(spec) == "object" && spec.type) + t = ctx.model.types[spec.type]; + if (!t) { + ctx.invalid_argument("Invalid type in argument: %s", name); + return; + } + + if (!t.parse) + return val; + + return call(t.parse, spec, ctx.model.scope, ctx, name, val); +} + +const context_defaults = { + up: [ "Return to previous node", context_prev ], + exit: [ "Return to previous node", context_prev ], + main: [ "Return to main node", context_top ], +}; + +const context_default_order = [ "up", "exit", "main" ]; + +function context_select(args, completion) +{ + let ctx = this; + + while (length(args) > completion ? 1 : 0) { + let name = args[0]; + let entry = ctx.node[name]; + + if (!entry) { + let e = context_defaults[name]; + if (!e) + return ctx; + + shift(args); + ctx = e[1](ctx, completion); + if (!ctx) + return; + + continue; + } + + if (!entry.select_node) + return ctx; + + let num_args = length(entry.args); + if (completion && num_args + 1 >= length(args)) + return ctx; + + shift(args); + let argv = []; + let parse_ctx = callctx.new(this.model, ctx); + if (num_args > 0) { + let cur_argv = slice(args, 0, num_args); + for (let i = 0; i < num_args; i++) { + let arg = shift(args); + let spec = entry.args[i]; + + spec = prepare_attr_spec(entry, ctx, spec, cur_argv, {}); + if (arg != null) + arg = parse_arg(parse_ctx, spec.name, spec, arg); + + if (arg != null) + push(argv, arg); + } + + } + + if (entry.no_subcommands && length(args) > 0) + parse_ctx.invalid_argument("command %s does not support subcommands", name); + + if (length(parse_ctx.result.errors) > 0) { + ctx = ctx.clone(); + ctx.errors = parse_ctx.result.errors; + return ctx; + } + + ctx = __context_select(ctx, name, argv); + if (type(ctx) != "object" || ctx.errors) + break; + } + + return ctx; +} + +function complete_named_params(ctx, entry, obj, name, argv, named_params) +{ + let data = []; + let empty = ""; + + if (substr(name, 0, 1) == "-") { + empty = "-"; + name = substr(name, 1); + } + + let defaults = {}; + callctx.new(ctx.model, ctx).apply_defaults(obj, defaults); + for (let cur_name in sort(keys(obj))) { + let val = obj[cur_name]; + + if (!prefix_match(name, cur_name) || val.no_complete) + continue; + + if (empty && !(val.allow_empty ?? entry.allow_empty)) + continue; + + if (!val.multiple && named_params[cur_name] != null) + continue; + + if (type(val.available) == "function" && + !call(val.available, val, ctx.model.scope, ctx, argv, named_params)) + continue; + + val = { + name: empty + cur_name, + ...val, + }; + push(data, val); + } + + return { + type: "keywords", + name: "parameter", + help: "Parameter name", + value: data + }; +} + +function complete_param(e, ctx, cur, val, args, named_args) +{ + cur = prepare_attr_spec(e, ctx, cur, args, named_args); + + if (type(cur.value) == "object") { + let ret = []; + for (let key in sort(keys(cur.value))) + if (prefix_match(val, key, cur.ignore_case)) + push(ret, { + name: key, + help: cur.value[key] + }); + + cur.value = ret; + return cur; + } + + if (type(cur.value) == "array") { + cur.value = map(sort(filter(cur.value, (v) => prefix_match(val, v, cur.ignore_case))), (v) => ({ name: v })); + return cur; + } + + let type_info = ctx.model.types[cur.type]; + if (!type_info || !type_info.complete) + return cur; + + cur.value = call(type_info.complete, cur, ctx.model.scope, ctx, val); + + return cur; +} + +function complete_arg_list(e, ctx, arg_info, args, base_args, named_args) +{ + let cur_idx = length(args) - 1; + let cur = arg_info[cur_idx]; + let val; + + for (let i = 0; i <= cur_idx; i++) + val = shift(args); + + return complete_param(e, ctx, cur, val, base_args, named_args); +} + +function handle_empty_param(entry, spec, name, argv, named_args) +{ + if (substr(name, 0, 1) != "-") + return; + + name = substr(name, 1); + let cur = spec[name]; + if (!cur) + return; + + if (cur.default == null && + !(cur.allow_empty ?? entry.allow_empty)) + return; + + if (cur.required) { + cur = { ...cur }; + prepare_default(e, ctx, cur, argv, named_args, cur); + named_args[name] = cur.default; + } else { + named_args[name] = null; + } + return true; +} + + +function default_complete(ctx, args) +{ + let num_args = length(this.args); + let named_args = {}; + let cur_args; + + if (length(args) <= num_args) + return complete_arg_list(this, ctx, this.args, args, [ ...args ], named_args); + + let spec = prepare_spec(this, ctx, this.named_args, args); + if (!spec) + return; + + let base_args = slice(args, 0, num_args); + for (let i = 0; i < num_args; i++) + shift(args); + + while (length(args) > 0) { + let name = args[0]; + + if (length(args) == 1) + return complete_named_params(ctx, this, spec, name, base_args, named_args); + + shift(args); + let cur = spec[name]; + if (!cur) { + if (handle_empty_param(this, spec, name, base_args, named_args)) + continue; + return; + } + + if (!cur.args) { + named_args[name] = true; + continue; + } + + let val; + let cur_spec = cur.args; + if (type(cur_spec) != "array") { + cur_spec = [{ + name, + help: cur.help, + ...cur_spec + }]; + named_args[name] = shift(args); + val = [ named_args[name] ]; + } else { + let num_args = length(cur_spec); + let val = []; + for (let i = 0; i < num_args; i++) + push(val, shift(args)); + named_args[name] = val; + } + + if (!length(args)) + return complete_arg_list(this, ctx, cur_spec, val, base_args, named_args); + } +} + +function context_complete(args) +{ + let ctx = this.select(args, true); + if (!ctx || ctx.errors) + return; + + if (ctx != this) { + ctx = ctx.clone(); + ctx.skip_default_complete = true; + } + + if (length(args) > 1) { + let name = shift(args); + let entry = ctx.node[name]; + if (!entry) + return; + + try { + if (!entry.available || call(entry.available, entry, ctx.model.scope, ctx, args)) + return call(entry.complete ?? default_complete, entry, ctx.model.scope, ctx, args); + } catch (e) { + this.model.exception(e); + } + return; + } + + let name = shift(args) ?? ""; + let prefix_len = length(name); + let data = []; + let default_data = {}; + for (let cur_name in sort(keys(ctx.node))) { + let val = ctx.node[cur_name]; + + if (substr(cur_name, 0, prefix_len) != name) + continue; + + if (val.available && !call(val.available, val, ctx.model.scope, ctx, args)) + continue; + + let cur = { + name: cur_name, + help: val.help, + category: val.select_node ? "Object" : "Action", + }; + if (context_defaults[cur_name]) + default_data[cur_name] = cur; + else + push(data, cur); + } + + for (let cur_name in context_default_order) { + if (substr(cur_name, 0, prefix_len) != name) + continue; + + let val = default_data[cur_name]; + if (!val) { + if (!ctx.prev || ctx.skip_default_complete) + continue; + val = { + name: cur_name, + help: context_defaults[cur_name][0], + category: "Navigation", + }; + } + + push(data, val); + } + + return { + type: "enum", + name: "command", + help: "Command", + value: data + }; +} + +function context_call(args) +{ + let ctx = this.select(args); + if (!ctx || !length(args)) + return; + + let name = shift(args); + let entry = ctx.node[name]; + if (!entry) + return; + + if (!entry.call) + return; + + let named_args = {}; + let num_args = length(entry.args); + let cur_argv = slice(args, 0, num_args); + let argv = []; + let skip = {}; + + ctx = callctx.new(this.model, ctx); + ctx.entry = entry; + ctx.named_args = named_args; + + for (let i = 0; i < num_args; i++) { + let arg = shift(args); + let spec = entry.args[i]; + + spec = prepare_attr_spec(entry, ctx, spec, cur_argv, named_args); + if (arg != null) + arg = parse_arg(ctx, spec.name, spec, arg); + + if (spec.required && !length(arg)) { + if (spec.default) + arg = spec.default; + else + ctx.missing_argument("Missing argument %d: %s", i + 1, spec.name); + } + + if (arg != null) + push(argv, arg); + } + + let spec = prepare_spec(entry, ctx, entry.named_args, argv) ?? {}; + let defaults = {}; + ctx.apply_defaults(spec, defaults); + while (length(args) > 0) { + let name = shift(args); + let cur = spec[name]; + try { + if (cur && type(cur.available) == "function" && + !call(cur.available, cur, ctx.model.scope, ctx, argv, { ...defaults, ...named_args })) + cur = null; + } catch (e) { + ctx.model.exception(e); + continue; + } + + if (!cur) { + if (handle_empty_param(entry, spec, name, argv, named_args)) + continue; + ctx.invalid_argument("Invalid argument: %s", name); + return ctx.result; + } + + if (!cur.args) { + named_args[name] = true; + continue; + } + + let val; + let cur_spec = cur.args; + if (type(cur.args) == "array") { + val = []; + for (let spec in cur.args) { + spec = prepare_attr_spec(entry, ctx, spec, argv, named_args); + let cur = parse_arg(ctx, name, spec, shift(args)); + if (cur == null) + return ctx.result; + + push(val, cur); + } + } else { + let spec = prepare_attr_spec(entry, ctx, cur.args, argv, named_args); + val = parse_arg(ctx, name, spec, shift(args)); + if (val == null) + return ctx.result; + } + if (cur.multiple) { + named_args[name] ??= []; + push(named_args[name], val); + } else { + named_args[name] = val; + } + } + + for (let name, arg in spec) { + if (!arg.required || named_args[name] != null) + continue; + + try { + if (type(arg.available) == "function" && + !call(arg.available, arg, ctx.model.scope, ctx, argv, named_args)) + continue; + } catch (e) { + ctx.model.exception(e); + continue; + } + + let spec = { ...arg }; + prepare_default(entry, ctx, spec, argv, named_args); + if (spec.default != null) + named_args[name] = spec.default; + else + ctx.missing_argument("Missing argument: %s", name); + } + + if (length(ctx.result.errors) > 0) + return ctx.result; + + if (entry.available && !call(entry.available, entry, ctx.model.scope, ctx)) + return ctx.result; + + try { + if (!entry.validate || call(entry.validate, entry, ctx.model.scope, ctx, argv, named_args)) + call(entry.call, entry, ctx.model.scope, ctx, argv, named_args); + } catch (e) { + this.model.exception(e); + return; + } + return ctx.result; +} + +const context_proto = { + clone: context_clone, + entries: context_entries, + help: context_help, + select: context_select, + call: context_call, + complete: context_complete, + add_hook: context_add_hook, +}; + +export function new(model) { + model.context_proto ??= { + model, + ...context_proto + }; + model.context_select_proto ??= { + model, + ...context_select_proto + }; + return proto({ + prompt: [], + node: model.node.Root, + hooks: {}, + data: {} + }, model.context_proto); +}; diff --git a/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc b/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc new file mode 100644 index 0000000000..bc78a7b12b --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +import * as context from "cli.context"; +import * as cache from "cli.cache"; +import * as libubus from "ubus"; +import * as uloop from "uloop"; +import { glob, dirname } from "fs"; +let types = require("cli.types"); + +uloop.init(); +let ubus = libubus.connect(); + +function status_msg(msg) +{ + if (this.cb.status_msg) + call(this.cb.status_msg, this, this.scope, msg); +} + +function poll_key(keys, prompt) +{ + if (!model.cb.poll_key) + return; + + if (prompt) + warn(prompt); + + while (1) { + let key = lc(model.cb.poll_key()); + if (!key || key == "\x03") + return; + + if (index(keys, key) >= 0) + return key; + } +} + +function merge_object(obj, add) +{ + for (let name, entry in add) + obj[name] = entry; +} + +function add_node(name, node) +{ + let obj = this.node; + + if (obj[name]) + merge_object(obj[name], node); + else + obj[name] = { ...node }; + + return obj[name]; +} + +function add_nodes(add) +{ + for (let name, val in add) + this.add_node(name, val); +} + +function add_hook(name, val) +{ + let obj = this.hooks; + + if (type(val) == "function") + val = [ val ]; + obj[name] ??= []; + push(obj[name], ...val); +} + +function add_hooks(add) +{ + for (let name, val in add) + this.add_hook(name, val); +} + +function add_type(name, val) +{ + this.type[name] = val; +} + +function add_types(add) +{ + for (let name, val in add) + this.add_type(name, val); +} + +function add_module(path) +{ + if (substr(path, 0, 1) != "/") + path = dirname(sourcepath()) + "/modules/" + path; + + let mod; + try { + let fn = loadfile(path, { + raw_mode: true, + strict_declarations: true, + }); + mod = call(fn, this, this.scope); + } catch (e) { + this.warn(`${e}\n${e.stacktrace[0].context}\nFailed to open module ${path}.\n`); + return; + } +} + +function add_modules(path) +{ + path ??= "*.uc"; + if (substr(path, 0, 1) != "/") + path = dirname(sourcepath()) + "/modules/" + path; + + for (let mod in glob(path)) + this.add_module(mod); +} + +function run_hook(name, ...args) +{ + let hooks = this.hooks[name]; + if (!hooks) + return; + + for (let hook in hooks) + call(hook, this, {}, ...args); +} + +function init() +{ + this.run_hook("init"); +} + +function context_new() +{ + return context.new(this); +} + +function exception(e) +{ + this.warn(`${e}\n${e.stacktrace[0].context}`); +} + +const data_proto = { + warn, exception, + poll_key, + add_module, + add_modules, + add_node, + add_nodes, + add_type, + add_types, + add_hook, + add_hooks, + run_hook, + init, + status_msg, + context: context_new, +}; + +export function new(cb) { + cb ??= {}; + let model = proto({ + libubus, ubus, uloop, + cb, + hooks: {}, + node: { + Root: {} + }, + warnings: {}, + types: { ...types }, + }, data_proto); + model.scope = proto({ model }, global); + model.cache = cache.new(model); + return model; +}; diff --git a/package/utils/cli/files/usr/share/ucode/cli/modules/network.uc b/package/utils/cli/files/usr/share/ucode/cli/modules/network.uc new file mode 100644 index 0000000000..3d5ef5b058 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/modules/network.uc @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +import { time_format } from "cli.utils"; + +function get_interfaces() +{ + let data = model.ubus.call("network.interface", "dump"); + if (!data) + return {}; + + let ret = {}; + for (let iface in data.interface) + ret[iface.interface] = iface; + + return ret; +} + +function interface_validate(ctx, argv) +{ + let name = argv[0]; + if (!name) + return ctx.missing_argument("Missing argument: %s", "name"); + + if (index(get_interfaces(), name) < 0) + return ctx.not_found("Interface not found: %s", name); + + return true; +} + +const interface_args = [ + { + name: "interface", + help: "Interface name", + type: "enum", + value: (ctx) => keys(get_interfaces()) + } +]; + +function interface_status(data) +{ + if (data.up) + return "up"; + if (!data.autostart) + return "down"; + if (!data.available) + return "unavailable"; + return "pending"; +} + +const Network = { + list: { + help: "List interfaces", + call: function(ctx, argv) { + return ctx.list("Interfaces", keys(get_interfaces())); + } + }, + reload: { + help: "Reload network config", + call: function(ctx, argv) { + model.ubus.call("network", "reload"); + return ctx.ok("Configuration reloaded"); + } + }, + restart: { + help: "Restart interface", + validate: interface_validate, + args: interface_args, + call: function(ctx, argv) { + let name = shift(argv); + model.ubus.call("network.interface."+name, "down"); + model.ubus.call("network.interface."+name, "up"); + return ctx.ok("Interface restarted"); + } + }, + start: { + help: "Start interface", + validate: interface_validate, + args: interface_args, + call: function(ctx, argv) { + let name = shift(argv); + model.ubus.call("network.interface."+name, "up"); + return ctx.ok("Interface started"); + } + }, + stop: { + help: "Stop interface", + validate: interface_validate, + args: interface_args, + call: function(ctx, argv) { + let name = shift(argv); + model.ubus.call("network.interface."+name, "down"); + return ctx.ok("Interface stopped"); + } + }, + status: { + help: "Interface status", + args: interface_args, + call: function(ctx, argv) { + let name = shift(argv); + let status = get_interfaces(); + if (!name) { + let data = {}; + for (let iface, ifdata in status) + data[iface] = interface_status(ifdata); + + return ctx.table("Status", data); + } + + let ifdata = status[name]; + let data = { + Status: interface_status(ifdata), + }; + if (ifdata.up) + data.Uptime = time_format(ifdata.uptime); + + if (length(ifdata["ipv4-address"]) > 0) + data.IPv4 = join(", ", map(ifdata["ipv4-address"], (v) => v.address + "/" + v.mask)); + if (length(ifdata["ipv6-address"]) > 0) + data.IPv6 = join(", ", map(ifdata["ipv6-address"], (v) => v.address + "/" + v.mask)); + if (length(ifdata["dns-server"]) > 0) + data.DNS = join(", ", ifdata["dns-server"]); + if (length(ifdata["route"]) > 0) + data.Routes = join(", ", map(ifdata["route"], (v) => (v.mask == 0 ? "Default" : `${v.target}/${v.mask}`) + ": " + v.nexthop)); + return ctx.table("Status", data); + } + } +}; + +const Root = { + network: { + help: "Network interface configuration", + select_node: "Network", + } +}; + +model.add_nodes({ Root, Network }); diff --git a/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc b/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc new file mode 100644 index 0000000000..a280f6559e --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +import { glob, access, basename } from "fs"; + +function get_services() +{ + return model.cache.get("init_service_list", () => { + let services = glob("/etc/init.d/*"); + services = filter(services, (v) => !system([ "grep", "-q", "start_service()", v ])); + services = map(services, basename); + return sort(services); + }); +} + +function get_service_status( name) +{ + return model.ubus.call("service", "list", (name ? { name } : null)); +} + +function service_running(name) +{ + let status = get_service_status(name); + return !!(status && status[name]); +} + +function __service_cmd(name, cmd) +{ + return system([ "/etc/init.d/" + name, cmd ]) == 0; +} + +function service_cmd(ctx, name, cmd, msg) +{ + if (__service_cmd(name, cmd)) + return ctx.ok(msg); + else + return ctx.command_failed("Command failed"); +} + +function service_validate(ctx, argv) +{ + let name = argv[0]; + if (!name) + return ctx.missing_argument("Missing argument: %s", "name"); + + if (index(get_services(), name) < 0) + return ctx.not_found("Service not found: %s", name); + + return true; +} + +const service_args = [ + { + name: "name", + help: "Service name", + type: "enum", + value: (ctx) => get_services() + } +]; + +const service_settings = { + enabled: { + help: "Service enabled at system boot", + }, + disabled: { + help: "Service disabled at system boot", + } +}; + +const SystemService = { + list: { + help: "List services", + call: function(ctx, argv) { + return ctx.list("Services", get_services()); + } + }, + reload: { + help: "Reload service", + validate: service_validate, + args: service_args, + call: function(ctx, argv) { + return service_cmd(ctx, shift(argv), "reload", "Service reloaded"); + } + }, + restart: { + help: "Restart service", + validate: service_validate, + args: service_args, + call: function(ctx, argv) { + return service_cmd(ctx, shift(argv), "restart", "Service restarted"); + } + }, + set: { + help: "Change service settings", + validate: service_validate, + args: service_args, + named_args: service_settings, + call: function(ctx, argv, param) { + if (!length(param)) + return ctx.invalid_argument("No settings provided"); + + if (param.enabled && param.disabled) + return ctx.invalid_argument("enabled and disabled cannot be set at the same time"); + + if (param.enabled && !__service_cmd(name, "enable")) + ctx.command_failed("Command failed: %s", "enable"); + + if (param.disabled && !__service_cmd(name, "disable")) + ctx.command_failed("Command failed: %s", "disable"); + + return ctx.ok("Settings changed"); + } + }, + start: { + help: "Start service", + validate: service_validate, + args: service_args, + call: function(ctx, argv) { + let name = shift(argv); + + if (service_running(name)) + return ctx.invalid_argument("Service already running", name); + + return service_cmd(ctx, name, "start", "Service started"); + } + }, + stop: { + help: "Stop service", + validate: service_validate, + args: service_args, + call: function(ctx, argv) { + let name = shift(argv); + + if (!service_running(name)) + return ctx.invalid_argument("Service not running", name); + + return service_cmd(ctx, name, "stop", "Service stopped"); + } + }, + status: { + help: "Service status", + args: service_args, + call: function(ctx, argv) { + let name = shift(argv); + if (!name) { + let data = {}; + for (let service in get_services()) { + let running = service_running(service); + data[service] = running ? "running" : "not running"; + } + return ctx.table("Status", data); + } + + if (index(get_services(), name) < 0) + return ctx.not_found("Service not found: %s", name); + + let data = { + "Running": service_running(name), + "Enabled": __service_cmd(name, "enabled"), + }; + return ctx.table("Status", data); + } + } +}; + +const Root = { + service: { + help: "System service configuration", + select_node: "SystemService", + } +}; + +model.add_nodes({ Root, SystemService }); diff --git a/package/utils/cli/files/usr/share/ucode/cli/object-editor.uc b/package/utils/cli/files/usr/share/ucode/cli/object-editor.uc new file mode 100644 index 0000000000..c55bf40010 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/object-editor.uc @@ -0,0 +1,546 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +function __get_edit_object(ctx, entry, argv, name) +{ + if (type(entry.edit_object) == "function") + return call(entry.edit_object, entry, ctx.model.scope, ctx, argv); + + if (name) + return ctx.data.edit[name]; + + return ctx.data.edit; +} + +function get_edit_object(ctx, entry, argv, name) +{ + let obj = __get_edit_object(ctx, entry, argv, name); + if (!obj) + ctx.invalid_argument(); + + return obj; +} + +function get_param_object(ctx, obj, spec, argv) +{ + if (type(spec.get_object) != "function") + return obj; + + return call(spec.get_object, spec, ctx.model.scope, ctx, spec, obj, argv); +} + +function call_change_cb(ctx, entry, argv, named) +{ + if (!length(named) || type(entry.change_cb) != "function") + return; + + call(entry.change_cb, entry, ctx.model.scope, ctx, argv); +} + +function check_duplicate(ctx, val, new_val) +{ + for (let i = 0; i < length(new_val); i++) { + let v = new_val[i]; + if ((val && index(val, v) >= 0) || + (i && index(slice(new_val, 0, i), v) >= 0)) { + ctx.invalid_argument("Duplicate value: %s", v); + return true; + } + } +} + +export function add_call(ctx, argv, named) +{ + let spec = this.named_args; + let obj = get_edit_object(ctx, this, argv); + if (!obj) + return; + + for (let name, val in named) { + let cur = spec[name]; + if (type(cur.add) == "function") { + call(cur.add, cur, ctx.model.scope, ctx, val); + continue; + } + + if (cur.attribute) + name = cur.attribute; + + let cur_obj = get_param_object(ctx, obj, cur, argv); + cur_obj[name] ??= []; + if (!cur.allow_duplicate && + check_duplicate(ctx, obj[name], val)) + return; + push(cur_obj[name], ...val); + } + call_change_cb(ctx, this, argv, named); + return ctx.ok(); +}; + +export function set_call(ctx, argv, named) +{ + let spec = this.named_args; + let obj = get_edit_object(ctx, this, argv); + if (!obj) + return; + + for (let name, val in named) { + let cur = spec[name]; + if (!cur) + continue; + + if (type(cur.set) == "function") { + call(cur.set, cur, ctx.model.scope, ctx, val); + continue; + } + + if (cur.attribute) + name = cur.attribute; + + let cur_obj = get_param_object(ctx, obj, cur, argv); + if (val == null) { + delete cur_obj[name]; + continue; + } + + if (cur.multiple && !cur.allow_duplicate && + check_duplicate(ctx, obj[name], val)) + return; + cur_obj[name] = val; + } + call_change_cb(ctx, this, argv, named); + return ctx.ok(); +}; + +export function remove_call(ctx, argv, named) +{ + let spec = this.named_args; + let obj = get_edit_object(ctx, this, argv); + if (!obj) + return; + + for (let name, val in named) { + let cur = spec[name]; + if (type(cur.remove) == "function") { + call(cur.remove, cur, ctx.model.scope, ctx, val); + continue; + } + + if (cur.attribute) + name = cur.attribute; + + let cur_obj = get_param_object(ctx, obj, cur, argv); + let data = cur_obj[name]; + if (!data) + continue; + + for (let idx in val) + data[+idx - 1] = null; + + cur_obj[name] = filter(data, (v) => v != null); + if (cur.attribute_allow_empty && !length(cur_obj[name])) + delete cur_obj[name]; + } + call_change_cb(ctx, this, argv, named); + return ctx.ok(); +}; + +export function show_call(ctx, argv, named) +{ + let obj = get_edit_object(ctx, this, argv); + if (!obj) + return; + + let data = {}; + for (let name, spec in this.attribute_info) { + let val; + if (type(spec.get) == "function") { + val = call(spec.get, spec, ctx.model.scope, ctx); + } else { + let cur_obj = get_param_object(ctx, obj, spec, argv); + val = cur_obj[spec.attribute ?? name]; + } + val ??= spec.default; + + if (val != null) + data[name] = val; + } + + return ctx.table("Values", data); +}; + +function param_values(ctx, argv, named_args, spec) +{ + let obj = get_edit_object(ctx, this, argv); + if (!obj) + return; + + let values; + if (type(spec.get) == "function") + values = call(spec.get, spec, ctx.model.scope, ctx); + else { + let cur_obj = get_param_object(ctx, obj, spec, argv); + values = cur_obj[spec.attribute]; + } + + let ret = {}; + let idx = 0; + for (let value in values) + ret["" + (++idx)] = value; + + return ret; +} + +function add_params(orig_params) +{ + let params = {}; + + for (let name, val in orig_params) { + if (!val.multiple) + continue; + + val = { ...val }; + delete val.required; + delete val.allow_empty; + params[name] = val; + } + + return params; +} + +function set_params(orig_params) +{ + let params = {}; + + for (let name, val in orig_params) { + val = { ...val }; + if (!val.required) + val.allow_empty = true; + else + delete val.allow_empty; + + delete val.required; + params[name] = val; + } + + return params; +} + +function remove_params(orig_params) +{ + let params = {}; + + for (let name, val in orig_params) { + if (!val.multiple) + continue; + + val = { ...val }; + val.attribute_allow_empty = val.allow_empty; + delete val.required; + delete val.allow_empty; + val.args = { + type: "enum", + attribute: val.attribute ?? name, + value: param_values, + force_helptext: true, + }; + + params[name] = val; + } + + return params; +} + +export function new(info, node) +{ + let params = info.named_args; + let ret = { + add: { + help: "Add list parameter entries", + args: info.args, + named_args: add_params(params), + call: add_call, + edit_object: info.edit_object, + change_cb: info.change_cb, + ...(info.add ?? {}), + }, + show: { + help: "Show parameter values", + args: info.args, + call: show_call, + attribute_info: params, + ...(info.show ?? {}), + }, + set: { + help: "Set parameter values", + args: info.args, + named_args: set_params(params), + call: set_call, + edit_object: info.edit_object, + change_cb: info.change_cb, + ...(info.set ?? {}), + }, + remove: { + help: "Remove parameter values", + args: info.args, + named_args: remove_params(params), + call: remove_call, + edit_object: info.edit_object, + change_cb: info.change_cb, + ...(info.remove ?? {}), + } + }; + + if (!length(ret.add.named_args)) { + delete ret.add; + delete ret.remove; + } + + if (node) + for (let cmd, val in ret) + node[cmd] = val; + + return ret; +}; + +export function object_destroy_call(ctx, argv, named) +{ + let type_name = argv[0]; + if (!type_name) + return ctx.invalid_argument(); + + let info = this.object_info; + let type_info = info.types[type_name]; + if (!type_info) + return ctx.invalid_argument(); + + let obj_name = type_info.object ?? type_name; + + let name = argv[1]; + if (type_info.delete) + return call(type_info.delete, info, ctx.model.scope, ctx, type, name); + + let obj = ctx.data.object_edit[obj_name]; + if (!obj) + return ctx.unknown_error(); + + if (!obj[name]) + return ctx.not_found(); + + delete obj[name]; + + if (info.change_cb) + call(info.change_cb, info, ctx.model.scope, ctx, argv); + + return ctx.ok(`Deleted ${argv[0]} '${name}'`); +}; + +const create_edit_param = { + help: "Edit object after creating", +}; + +export function object_create_params(node) +{ + if (!node.show) + return {}; + + let orig_params = node.show.attribute_info; + let params = {}; + + for (let name, val in orig_params) { + if (val.change_only) + continue; + + params[name] = val; + } + params.edit ??= create_edit_param; + + return params; +}; + +export function object_create_call(ctx, argv, named) +{ + let type_name = argv[0]; + if (!type_name) + return ctx.invalid_argument(); + + let info = this.object_info; + let type_info = info.types[type_name]; + if (!type_info) + return ctx.invalid_argument(); + + let obj_name = type_info.object ?? type_name; + + let name = argv[1]; + let obj, data; + if (type_info.add) { + data = call(type_info.add, info, ctx.model.scope, ctx, type, name); + if (!data) + return; + } else { + data = {}; + } + + ctx.data.object_edit[obj_name] ??= {}; + obj = ctx.data.object_edit[obj_name]; + + let entry = type_info.node.set; + if (entry) { + ctx.apply_defaults(); + let subctx = ctx.clone(); + subctx.data.edit = data; + + try { + call(entry.call, entry, ctx.model.scope, subctx, argv, named); + } catch (e) { + ctx.model.exception(e); + return ctx.unknown_error(); + } + + if (!subctx.result.ok) { + ctx.result = subctx.result; + return; + } + } + + obj[name] = data; + + if (named.edit) + ctx.select(type_name, name); + + return ctx.ok(`Added ${type_name} '${name}'`); +}; + +function object_lookup(ctx, entry, type_name) +{ + let info = entry.object_info; + let type_info = info.types[type_name]; + if (!type_info) + return []; + + let obj_name = type_info.object ?? type_name; + + return ctx.data.object_edit[obj_name]; +} + +function object_values(ctx, entry, type_name) +{ + let obj = object_lookup(ctx, entry, type_name); + if (!obj) + return []; + + return keys(obj); +} + +export function object_list_call(ctx, argv, named) +{ + return ctx.list(argv[0] + " list", object_values(ctx, this, argv[0])); +}; + +export function edit_create_destroy(info, node) +{ + let type_arg = { + name: "type", + help: "Type", + type: "enum", + required: true, + value: keys(info.types), + }; + let name_arg = { + name: "name", + help: "Name", + type: "string", + required: true, + }; + let delete_name_arg = { + ...name_arg, + type: "enum", + value: function(ctx, argv) { + return object_values(ctx, this, argv[0]); + } + }; + + let create_params = {}; + for (let name, val in info.types) + create_params[name] = object_create_params(val.node); + + let types_info = " (" + join(", ", keys(info.types)) + ")"; + let cmds = { + destroy: { + object_info: info, + help: "Delete object" + types_info, + args: [ type_arg, delete_name_arg ], + call: object_destroy_call, + }, + list: { + object_info: info, + help: "List objects" + types_info, + args: [ type_arg ], + call: object_list_call, + }, + create: { + object_info: info, + help: "Create object" + types_info, + args: [ type_arg, name_arg ], + type_params: create_params, + named_args: function(ctx, argv) { + if (!argv[0]) + return; + return this.type_params[argv[0]]; + }, + call: object_create_call, + }, + }; + + for (let name, val in info.types) { + cmds[name] = { + object_name: name, + object_info: info, + help: "Edit " + name, + args: [ + { + ...name_arg, + type: "enum", + value: function(ctx, argv) { + return object_values(ctx, this, this.object_name); + } + } + ], + select_node: val.node_name, + select: function(ctx, argv) { + let name = argv[0]; + if (!name) { + warn(`Missing argument\n`); + return; + } + + let obj = object_lookup(ctx, this, this.object_name); + if (!obj) { + warn(`Object not found\n`); + return; + } + + let entry = obj[name]; + if (!entry) { + warn(`${name} not found\n`); + return; + } + + let info = this.object_info; + let type_info = info.types[this.object_name]; + return ctx.set(`${this.object_name} "${name}"`, { + name, + edit: entry, + object_edit: entry, + }); + } + }; + } + + if (node) + for (let cmd, val in cmds) + node[cmd] = val; + + return cmds; +}; diff --git a/package/utils/cli/files/usr/share/ucode/cli/types.uc b/package/utils/cli/files/usr/share/ucode/cli/types.uc new file mode 100644 index 0000000000..46c563dfb3 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/types.uc @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +import { access, basename, dirname, opendir, stat } from "fs"; + +function is_directory(path) +{ + let s = stat(path); + return s && s.type == "directory"; +} + +const types = { + bool: { + value: [ "0", "1" ], + parse: function(ctx, name, val) { + if (val == "1") + return true; + if (val == "0") + return false; + ctx.invalid_argument("value for %s must be 0 or 1", name); + return; + }, + }, + int: { + parse: function(ctx, name, strval) { + let val = +strval; + if (substr(strval, 0, 1) == "-") + strval = substr(strval, 1); + if (match(strval, /[^0-9]/)) { + ctx.invalid_argument("value for %s is not a number", name); + return; + } + if ((this.min == null || val >= this.min) && + (this.max == null || val <= this.max)) + return val; + if (this.min != null && this.max != null) + ctx.invalid_argument(`value for %s must be between ${this.min} and ${this.max}`, name); + else if (this.min != null) + ctx.invalid_argument(`value for %s must be at least ${this.min}`, name); + else + ctx.invalid_argument(`value for %s must not be bigger than ${this.max}`, name); + return; + } + }, + string: { + parse: function(ctx, name, val) { + let len = length(val); + if ((this.min == null || len >= this.min) && + (this.max == null || len <= this.max)) + return val; + if (this.min != null && this.max != null) + ctx.invalid_argument(`String value %s must be between ${this.min} and ${this.max} characters`, name); + else if (this.min != null) + ctx.invalid_argument(`String value %s must be at least ${this.min} characters long`, name); + else + ctx.invalid_argument(`String value %s must not be longer than ${this.max} characters`, name); + return; + } + }, + enum: { + parse: function(ctx, name, val) { + if (this.no_validate) + return val; + + let list = this.value; + if (this.ignore_case) { + val = lc(val); + val = filter(list, (v) => val == lc(v))[0]; + } else { + if (index(list, val) < 0) + val = null; + } + + if (val == null) + ctx.invalid_argument("Invalid value for %s", name); + + return val; + } + }, + path: { + complete: function(ctx, val) { + let ret = []; + + let dir = split(val, "/"); + let prefix = pop(dir); + push(dir, ""); + dir = join("/", dir); + let prefix_len = length(prefix); + let d = opendir(length(dir) ? dir : "."); + if (!d) + return ret; + + let cur; + while (cur = d.read()) { + if (cur == "." || cur == "..") + continue; + + if (substr(cur, 0, prefix_len) != prefix) + continue; + + let path = dir + cur; + let incomplete = false; + if (is_directory(path)) { + path += "/"; + incomplete = true; + } + + push(ret, { name: path, incomplete }); + } + + return ret; + }, + parse: function(ctx, name, val) { + if (this.new_path) { + let dir = dirname(val); + let s = stat(dir); + if (!is_directory(dir)) { + ctx.invalid_argument("Path '%s' is not a directory", dir); + return; + } + } else { + if (!access(val, "r")) { + ctx.invalid_argument("Path '%s' does not exist", val); + return; + } + } + return val; + } + }, + host: { + parse: function(ctx, name, val) { + if (length(iptoarr(val)) != 0) + return val; + if (length(val) > 255) + return; + let labels = split(val, "."); + if (length(filter(labels, label => !match(label, /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])$/))) == 0 && length(labels) > 0) + return val; + ctx.invalid_argument("value for %s is not an valid IP or hostname", name); + return; + } + }, + macaddr: { + parse: function(ctx, name, val) { + val = lc(val); + let arr = split(val, ":"); + if (length(arr) != 6 || length(filter(arr, (v) => !match(v, /^[0-9a-f][0-9a-f]$/)))) + return ctx.invalid_argument("value for %s is not an MAC address", name); + return val; + } + }, + ipv4: { + parse: function(ctx, name, val) { + if (length(iptoarr(val)) == 4) + return val; + ctx.invalid_argument("value for %s is not an IPv4", name); + return; + } + }, + ipv6: { + parse: function(ctx, name, val) { + if (length(iptoarr(val)) == 16) + return val; + ctx.invalid_argument("value for %s is not an IPv6", name); + return; + } + }, + cidr4: { + parse: function(ctx, name, val) { + let m = split(val, '/', 2); + if (m && +m[1] <= 32 && + ((m[0] == "auto" && this.allow_auto) || + length(iptoarr(m[0])) == 4)) + return val; + ctx.invalid_argument("value for %s is not cidr4 (e.g. 192.168.1.1/24)", name); + return; + } + }, +}; + +return types; diff --git a/package/utils/cli/files/usr/share/ucode/cli/utils.uc b/package/utils/cli/files/usr/share/ucode/cli/utils.uc new file mode 100644 index 0000000000..f299ad361d --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/utils.uc @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 Felix Fietkau +'use strict'; + +export function time_format(val) +{ + let ret = `${val % 60}s`; + + val /= 60; + if (!val) + return ret; + + ret = `${val % 60}m ${ret}`; + + val /= 60; + if (!val) + return ret; + + ret = `${val % 24 }h ${ret}`; + + val /= 24; + if (!val) + return ret; + + return `${val}d ${ret}`; +};