--- /dev/null
+#
+# 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 <nbd@nbd.name>
+
+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))
--- /dev/null
+ 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 <type> <name> <...>`: Create a new instance. Also takes parameter values to be set on the object.
+- `destroy <type> <name>`: Delete an instance.
+- `list <type>` 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`.
+
--- /dev/null
+#!/usr/bin/env ucode
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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);
+}
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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;
+};
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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);
+};
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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);
+};
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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);
+};
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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;
+};
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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 });
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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 });
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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;
+};
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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;
--- /dev/null
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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}`;
+};