cli: add OpenWrt CLI (work in progress)
authorFelix Fietkau <nbd@nbd.name>
Mon, 13 Jan 2025 21:46:05 +0000 (22:46 +0100)
committerFelix Fietkau <nbd@nbd.name>
Mon, 13 Jan 2025 22:11:23 +0000 (23:11 +0100)
Signed-off-by: Felix Fietkau <nbd@nbd.name>
package/utils/cli/Makefile [new file with mode: 0644]
package/utils/cli/files/usr/sbin/cli [new file with mode: 0755]
package/utils/cli/files/usr/share/ucode/cli/cache.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/color.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/context-call.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/context.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/datamodel.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/modules/service.uc [new file with mode: 0644]

diff --git a/package/utils/cli/Makefile b/package/utils/cli/Makefile
new file mode 100644 (file)
index 0000000..63f9714
--- /dev/null
@@ -0,0 +1,36 @@
+#
+# 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
+include $(INCLUDE_DIR)/kernel.mk
+
+PKG_NAME:=cli
+PKG_VERSION:=1
+
+PKG_LICENSE:=GPL-2.0
+PKG_MAINTAINER:=Felix Fietkau <nbd@nbd.name>
+
+PKG_BUILD_DEPENDS:=bpf-headers
+
+include $(INCLUDE_DIR)/package.mk
+
+define Package/cli
+  SECTION:=utils
+  CATEGORY:=Utilities
+  TITLE:=OpenWrt CLI
+  DEPENDS:=+ucode +ucode-mod-ubus +ucode-mod-uloop
+endef
+
+define Build/Compile
+       :
+endef
+
+define Package/cli/install
+       $(CP) ./files/* $(1)/
+endef
+
+$(eval $(call BuildPackage,cli))
diff --git a/package/utils/cli/files/usr/sbin/cli b/package/utils/cli/files/usr/sbin/cli
new file mode 100755 (executable)
index 0000000..e21da62
--- /dev/null
@@ -0,0 +1,440 @@
+#!/usr/bin/env ucode
+'use strict';
+import * as datamodel from "cli.datamodel";
+import { bold, color_fg } from "cli.color";
+import * as uline from "uline";
+
+let history = [];
+let history_edit;
+let history_idx = -1;
+let cur_line;
+
+let el;
+let model = datamodel.new();
+let uloop = model.uloop;
+model.add_modules();
+let ctx = model.context();
+let parser = uline.arg_parser({
+       line_separator: ";"
+});
+
+model.add_types({
+       Root: {
+               exit: {
+                       help: "Exit the CLI",
+                       call: function(ctx) {
+                               el.close();
+                               uloop.end();
+                       }
+               }
+       }
+});
+
+
+function update_prompt() {
+       el.set_state({
+               prompt: bold(join(" ", [ "cli", ...ctx.prompt ]) + "> "),
+       });
+}
+
+let cur_completion, 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 helptext(cur) {
+       if (!cur) {
+               el.set_hint(`\n  No help information available\n`);
+               return true;
+       }
+
+       let data = cur.data;
+       switch (cur.type) {
+       case "field":
+               let str = `\n  Input field: ${data[1]}`;
+               if (type(data[2]) != "array") {
+                       str += "\n";
+               } else if (length(data[2]) > 0) {
+                       str += ", values:\n";
+                       let len = max_len(map(data[2], (v) => v[0]), 10);
+                       for (let val in data[2]) {
+                               str += sprintf("  - %-" + len + "s", val[0]);
+                               if (val[1])
+                                       str += ": " + val[1];
+                               str += "\n";
+                       }
+               } else {
+                       str += " (no match)\n";
+               }
+               el.set_hint(str);
+               return true;
+       default:
+               break;
+       }
+
+       let str = `\n  Available ${cur.type}:\n\n`;
+       let len = max_len(map(data, (e) => e[0]), 20);
+       for (let entry in data)
+               str += sprintf("    %-" + len + "s %s\n", entry[0], entry[1]);
+
+       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);
+               if (!length(args))
+                       cur_ctx = sel;
+               if (!sel)
+                       return;
+       }
+
+       return cur_ctx;
+}
+
+function completion_check_prefix(data)
+{
+       let prefix = data[0][0];
+       let prefix_len = length(prefix);
+
+       for (let entry in data) {
+               entry = entry[0];
+               if (prefix_len > length(entry))
+                       prefix_len = length(entry);
+       }
+       prefix = substr(prefix, 0, prefix_len);
+
+       for (let entry in data) {
+               entry = substr(entry[0], 0, prefix_len);
+               while (entry != prefix) {
+                       prefix_len--;
+                       prefix = substr(prefix, 0, prefix_len);
+                       entry = substr(entry, 0, prefix_len);
+               }
+       }
+
+       tab_prefix += substr(prefix, tab_prefix_len);
+       tab_prefix_len = prefix_len;
+
+       let line = tab_prefix + tab_quote;
+       let pos = length(line);
+       line += tab_suffix;
+       el.set_state({ line, pos });
+}
+
+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);
+
+               if (!is_open && substr(tab_prefix, -1) == " ")
+                       push(args, "");
+               let last = args[length(args) - 1];
+               tab_prefix_len = length(last);
+               tab_ctx = completion_ctx(arg_info);
+               if (!tab_ctx)
+                       return;
+
+               cur_completion = tab_ctx.complete([...args]);
+       }
+
+       if (!tab_ctx)
+               return;
+
+       if (count < 0)
+               return helptext(cur_completion);
+
+       let cur = cur_completion;
+       if (cur && cur.type == "field" && !cur.data[2])
+               cur = null;
+       if (!cur) {
+               el.set_hint("");
+               return;
+       }
+
+       let data = cur.data;
+       if (cur.type == "field")
+               data = data[2];
+
+       if (length(data) == 0) {
+               el.set_hint(`  (no match)`);
+               return;
+       }
+
+       if (length(data) == 1) {
+               let line = tab_prefix + substr(data[0][0], tab_prefix_len) + tab_quote + " ";
+               let pos = length(line);
+               line += tab_suffix;
+               el.set_state({ line, pos });
+               el.set_hint("");
+               el.reset_key_input();
+               return;
+       }
+
+       if (count == 1)
+               completion_check_prefix(data);
+
+       if (count > 1) {
+               let idx = (count - 2) % length(data);
+               let line = tab_prefix + substr(data[idx][0], tab_prefix_len) + tab_quote;
+               let pos = length(line);
+               line += tab_suffix;
+               el.set_state({ line, pos });
+       }
+
+       let win = el.get_window();
+       let str = "";
+       let x = 0;
+
+       let len = max_len(map(data, (v) => v[0]));
+       for (let entry in data) {
+               let add = sprintf("    %-"+len+"s", entry[0]);
+
+               str += add;
+               x += length(add);
+
+               if (x + length(add) < win.x)
+                       continue;
+
+               str += "\n";
+               x = 0;
+       }
+       el.set_hint(str);
+}
+
+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 (!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 + ": ");
+
+       switch (res.type) {
+       case "table":
+               let len = max_len(res.data, 8);
+               warn("\n");
+               for (let name, val in res.data) {
+                       val = replace(val, /\n/g, "\n    ");
+                       warn(sprintf("    %-" + len + "s %s\n", name + ":", val));
+               }
+               break;
+       case "list":
+               warn("\n");
+               for (let entry in res.data)
+                       warn("  - " + entry + "\n");
+               break;
+       case "string":
+               warn(res.data + "\n");
+               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);
+       for (let cmd in arg_info.args) {
+               let orig_cmd = [ ...cmd ];
+               let cur_ctx = ctx.select(cmd);
+               if (!cur_ctx) {
+                       warn(`Invalid command: ${orig_cmd[0]}\n`);
+                       break;
+               }
+
+               if (!length(cmd)) {
+                       ctx = cur_ctx;
+                       update_prompt();
+                       continue;
+               }
+
+               let res = cur_ctx.call(cmd);
+               format_result(res);
+       }
+}
+
+const cb = {
+       eof: () => { uloop.end(); },
+       interrupt: () => { 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 "\x12":
+                               reverse_search();
+                               return true;
+                       }
+               } catch (e) {
+                       el.set_hint(`${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", "\x12" ]
+});
+
+warn("Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments\n");
+update_prompt();
+el.set_uloop(line_cb);
+uloop.run();
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 (file)
index 0000000..e155415
--- /dev/null
@@ -0,0 +1,59 @@
+'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;
+       timeout += now;
+       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 (file)
index 0000000..1c795c9
--- /dev/null
@@ -0,0 +1,63 @@
+'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(22);
+};
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 (file)
index 0000000..46edeac
--- /dev/null
@@ -0,0 +1,59 @@
+'use strict';
+
+function call_ok(msg)
+{
+       this.result.ok = true;
+       if (msg)
+               this.result.status_msg = msg;
+       return true;
+}
+
+function call_error(code, ...args)
+{
+       let msg = this.model.errors[code] ?? "Unknown error";
+       msg = sprintf(msg, ...args);
+       let error = {
+               code, msg
+       };
+       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_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);
+}
+
+const callctx_proto = {
+       ok: call_ok,
+       error: call_error,
+       list: call_list,
+       table: call_table,
+       string: call_string,
+};
+
+export function new(model, data) {
+       model.callctx_proto ??= { model, ...callctx_proto };
+       let result = {
+               errors: [],
+               ok: false
+       };
+       return proto({ 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 (file)
index 0000000..d5f26de
--- /dev/null
@@ -0,0 +1,265 @@
+'use strict';
+
+import * as callctx from "cli.context-call";
+
+function prefix_match(prefix, str)
+{
+       return substr(str, 0, length(prefix)) == prefix;
+}
+
+function context_clone()
+{
+       let ret = { ...this };
+       ret.prompt = [ ...ret.prompt ];
+       ret.data = { ...ret.data };
+       return ret;
+}
+
+function context_entries()
+{
+       return keys(this.type)
+}
+
+function context_help(entry)
+{
+       if (entry)
+               return this.type[entry].help;
+
+       let ret = {};
+       for (let name, val in this.type)
+               ret[name] = val.help ?? "";
+
+       return ret;
+}
+
+function context_set(type, prompt, data)
+{
+       let new_type = this.model.types[type];
+       if (!new_type)
+               return;
+
+       this.type = new_type;
+       if (prompt)
+               this.cur_prompt = prompt;
+       if (data)
+               this.data = { ...this.data, ...data };
+       return true;
+}
+
+const context_select_proto = {
+       set: context_set
+};
+
+function __context_select(ctx, name, args)
+{
+       let entry = ctx.type[name];
+       if (!entry || !entry.select)
+               return;
+
+       let ret = proto(ctx.clone(), ctx.model.context_select_proto);
+       ret.cur_prompt = name;
+       try {
+               if (!entry.select(ret, args))
+                       return;
+       } catch (e) {
+               ctx.model.exception(e);
+               return;
+       }
+
+       push(ret.prompt, ret.cur_prompt);
+       ret.prev = ctx;
+       proto(ret, proto(ctx));
+
+       return ret;
+}
+
+function context_select(args)
+{
+       let ctx = this;
+
+       if (args[0] == "exit" && !this.type.exit) {
+               while (length(args) > 0)
+                       pop(args);
+               return this.prev;
+       }
+
+       while (length(args) > 0) {
+               let name = args[0];
+               let entry = ctx.type[name];
+
+               if (!entry || !entry.select)
+                       return ctx;
+
+               shift(args);
+               ctx = __context_select(ctx, name, args);
+               if (!ctx)
+                       return;
+       }
+
+       return ctx;
+}
+
+function complete_object(obj, name)
+{
+       let data = [];
+       for (let cur_name in sort(keys(obj))) {
+               let val = obj[cur_name];
+
+               if (!prefix_match(name, cur_name))
+                       continue;
+
+               push(data, [cur_name, val]);
+       }
+       return { type: "keywords", data };
+}
+
+function default_complete(ctx, args)
+{
+       if (!this.args)
+               return;
+
+       let cur_args = [ ...this.args ];
+       let orig_args = [ ...args ];
+       let cur;
+
+       while (length(args) > 0) {
+               let val = shift(args);
+
+               cur = cur_args[0];
+               if (type(cur) != "object")
+                       shift(cur_args);
+
+               if (type(cur) == "object") {
+                       if (length(args)) {
+                               cur = cur[val];
+                               if (!cur)
+                                       return;
+                       }
+
+                       return complete_object(cur, val);
+               }
+
+               if (length(args))
+                       continue;
+
+               if (type(cur) != "array")
+                       return;
+
+               cur = [ ...cur ];
+               if (type(cur[2]) == "function")
+                       cur[2] = call(cur[2], this, {}, ctx, orig_args);
+
+               if (cur[2] == "object") {
+                       let ret = [];
+                       for (let key in sort(keys(cur[2])))
+                               if (prefix_match(val, k))
+                                       push(ret, [ key, cur[2][key] ]);
+                       cur[2] = ret;
+               } else if (type(cur[2]) == "array") {
+                       cur[2] = map(sort(filter(cur[2], (v) => prefix_match(val, v))), (v) => [ v ]);
+               }
+
+               return { type: "field", data: cur };
+       }
+}
+
+function context_complete(args)
+{
+       let last = pop(args);
+       let ctx = this.select(args);
+       if (last != null)
+               push(args, last);
+
+       if (!ctx)
+               return;
+
+       if (length(args) > 1) {
+               let name = shift(args);
+               let entry = ctx.type[name];
+               if (!entry)
+                       return;
+
+               try {
+                       return call(entry.complete ?? default_complete, entry, {}, ctx, args);
+               } catch (e) {
+                       this.model.exception(e);
+                       return;
+               }
+       }
+
+       let name = shift(args) ?? "";
+       let prefix_len = length(name);
+       let data = [];
+       for (let cur_name in sort(keys(ctx.type))) {
+               let val = ctx.type[cur_name];
+
+               if (substr(cur_name, 0, prefix_len) != name)
+                       continue;
+
+               push(data, [cur_name, val.help]);
+       }
+
+       if (ctx == this && this.prev && !this.type.exit &&
+               prefix_match(name, "exit"))
+               push(data, [ "exit", "Return to previous menu" ]);
+
+       return { type: "commands", data };
+}
+
+function context_call(args)
+{
+       let ctx = this;
+
+       while (length(args) > 0) {
+               let name = shift(args);
+               let entry = ctx.type[name];
+               if (!entry)
+                       return;
+
+               if (entry.select) {
+                       ctx = ctx.select(name, args);
+                       if (!ctx)
+                               return;
+                       continue;
+               }
+
+               if (!entry.call)
+                       return;
+
+               ctx = callctx.new(this.model, ctx.data);
+               if (!entry.validate || call(entry.validate, entry, {}, ctx, args)) {
+                       try {
+                               call(entry.call, entry, {}, ctx, 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,
+};
+
+export function new(model) {
+       model.context_proto ??= {
+               model,
+               ...context_proto
+       };
+       model.context_select_proto ??= {
+               model,
+               ...context_select_proto
+       };
+       return proto({
+               prompt: [],
+               type: model.types.Root,
+               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 (file)
index 0000000..a4931af
--- /dev/null
@@ -0,0 +1,96 @@
+'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";
+
+uloop.init();
+let ubus = libubus.connect();
+
+function merge_object(obj, add)
+{
+       for (let name, entry in add)
+               obj[name] = entry;
+}
+
+function merge_types(obj, add)
+{
+       for (let name, val in add) {
+               if (obj[name])
+                       merge_object(obj[name], val);
+               else
+                       obj[name] = { ...val };
+       }
+}
+
+function add_types(add)
+{
+       merge_types(this.types, add);
+}
+
+function merge_hooks(obj, add)
+{
+       for (let name, val in add) {
+               obj[name] ??= [];
+               push(obj[name], ...val);
+       }
+}
+
+function add_module(path)
+{
+       let mod;
+       try {
+               mod = loadfile(path)();
+       } catch (e) {
+               this.warn(`Failed to open module ${path}: ${e}\n${e.stacktrace[0].context}`);
+               return;
+       }
+
+       merge_hooks(this.hooks, mod.hooks);
+       this.add_types(mod.types);
+       merge_object(this.errors, mod.errors);
+       merge_object(this.warnings, mod.warnings);
+}
+
+function add_modules(path)
+{
+       path ??= dirname(sourcepath()) + "/modules/*.uc";
+       for (let mod in glob(path))
+               this.add_module(mod);
+}
+
+function context_new()
+{
+       return context.new(this);
+}
+
+function exception(e)
+{
+       this.warn(`${e}\n${e.stacktrace[0].context}`);
+}
+
+const data_proto = {
+       warn, exception,
+       add_modules,
+       add_module,
+       add_types,
+       context: context_new,
+};
+
+export function new() {
+       let model = proto({
+               libubus, ubus, uloop,
+               hooks: {},
+               types: {
+                       Root: {}
+               },
+               errors: {
+                       MISSING_ARGUMENT: "Missing argument: %s"
+               },
+               warnings: {},
+       }, data_proto);
+       model.cache = cache.new(model);
+       return model;
+};
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 (file)
index 0000000..099c415
--- /dev/null
@@ -0,0 +1,114 @@
+import { glob, access, basename } from "fs";
+
+function get_services(model)
+{
+       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(model, name)
+{
+       return model.ubus.call("service", "list", (name ? { name } : null));
+}
+
+function service_running(model, name)
+{
+       let status = get_service_status(model, name);
+       return status && status[name];
+}
+
+function service_validate(ctx, argv)
+{
+       let name = argv[0];
+       if (!name)
+               return ctx.error("MISSING_ARGUMENT", "name");
+
+       if (index(get_services(ctx.model), name) < 0)
+               return ctx.error("SERVICE_NOT_FOUND", name);
+
+       return true;
+}
+
+const service_args = [
+       [ "name", "Service name", (ctx) => get_services(ctx.model) ]
+];
+
+const Service = {
+       list: {
+               help: "List services",
+               call: function(ctx, argv) {
+                       return ctx.list("Services", get_services(ctx.model));
+               }
+       },
+       start: {
+               help: "Start service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+
+                       if (service_running(ctx.model, name))
+                               return ctx.error("SERVICE_ALREADY_RUNNING");
+
+                       system("/etc/init.d/" + name + " start");
+                       return ctx.ok("Service started");
+               }
+       },
+       stop: {
+               help: "Stop service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+
+                       if (!service_running(ctx.model, name))
+                               return ctx.error("SERVICE_NOT_RUNNING");
+
+                       system("/etc/init.d/" + name + " stop");
+                       return ctx.ok("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(ctx.model)) {
+                                       let running = service_running(ctx.model, service);
+                                       data[service] = running ? "running" : "not running";
+                               }
+                               return ctx.table("Status", data);
+                       }
+
+                       if (index(get_services(ctx.model), name) < 0)
+                               return ctx.error("SERVICE_NOT_FOUND", name);
+
+                       let running = service_running(ctx.model, name);
+                       return ctx.string("Status", running ? "running" : "not running");
+               }
+       }
+};
+
+const Root = {
+       service: {
+               help: "System service configuration",
+               select: function(ctx, argv) {
+                       return ctx.set("Service");
+               },
+       }
+};
+
+return {
+       types: { Root, Service },
+       errors: {
+               SERVICE_NOT_FOUND: "Service not found: %s",
+               SERVICE_ALREADY_RUNNING: "Service already running",
+               SERVICE_NOT_RUNNING: "Service not running",
+       },
+};