From 4b4534a49641137c8820ac051485224cc06360d5 Mon Sep 17 00:00:00 2001 From: Felix Fietkau Date: Mon, 13 Jan 2025 22:46:05 +0100 Subject: [PATCH] cli: add OpenWrt CLI (work in progress) Signed-off-by: Felix Fietkau --- package/utils/cli/Makefile | 33 ++ package/utils/cli/files/usr/sbin/cli | 442 ++++++++++++++++++ .../cli/files/usr/share/ucode/cli/cache.uc | 58 +++ .../cli/files/usr/share/ucode/cli/color.uc | 63 +++ .../files/usr/share/ucode/cli/context-call.uc | 59 +++ .../cli/files/usr/share/ucode/cli/context.uc | 287 ++++++++++++ .../files/usr/share/ucode/cli/datamodel.uc | 97 ++++ .../usr/share/ucode/cli/modules/service.uc | 164 +++++++ 8 files changed, 1203 insertions(+) create mode 100644 package/utils/cli/Makefile create mode 100755 package/utils/cli/files/usr/sbin/cli create mode 100644 package/utils/cli/files/usr/share/ucode/cli/cache.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/color.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/context-call.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/context.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/datamodel.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/modules/service.uc diff --git a/package/utils/cli/Makefile b/package/utils/cli/Makefile new file mode 100644 index 0000000000..c4762446da --- /dev/null +++ b/package/utils/cli/Makefile @@ -0,0 +1,33 @@ +# +# 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_VERSION:=1 + +PKG_LICENSE:=GPL-2.0 +PKG_MAINTAINER:=Felix Fietkau + +include $(INCLUDE_DIR)/package.mk + +define Package/cli + SECTION:=utils + CATEGORY:=Utilities + TITLE:=OpenWrt CLI + DEPENDS:=+ucode +ucode-mod-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 index 0000000000..9dd9546bd2 --- /dev/null +++ b/package/utils/cli/files/usr/sbin/cli @@ -0,0 +1,442 @@ +#!/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) { + if (type(val) == "bool") + val = val ? "yes" : "no"; + 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 index 0000000000..57f482e3f0 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/cache.uc @@ -0,0 +1,58 @@ +'use strict'; + +const CACHE_DEFAULT_TIMEOUT = 5; + +function cache_get(key, fn, timeout) +{ + let now = time(); + let entry = this.entries[key]; + if (entry) { + if (now < entry.timeout) + return entry.data; + + if (!fn) + delete this.entries[key]; + } + + if (!fn) + return; + + let data = fn(); + if (!entry) + this.entries[key] = entry = {}; + timeout ??= CACHE_DEFAULT_TIMEOUT; + entry.timeout = now + timeout; + entry.data = data; + + return data; +} + +function cache_remove(key) +{ + delete this.entries[key]; +} + +function cache_gc() { + let now = time(); + for (let key, entry in this.entries) + if (now > entry.timeout) + delete this.entries[key]; +} + +const cache_proto = { + get: cache_get, + remove: cache_remove, + gc: cache_gc, +}; + +export function new(model) { + model.cache_proto ??= { model, ...cache_proto }; + let cache = proto({ + entries: {}, + }, model.cache_proto); + cache.gc_interval = model.uloop.interval(10000, () => { + cache.gc(); + }); + + return cache; +}; diff --git a/package/utils/cli/files/usr/share/ucode/cli/color.uc b/package/utils/cli/files/usr/share/ucode/cli/color.uc new file mode 100644 index 0000000000..2a240856d3 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/color.uc @@ -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(0); +}; diff --git a/package/utils/cli/files/usr/share/ucode/cli/context-call.uc b/package/utils/cli/files/usr/share/ucode/cli/context-call.uc new file mode 100644 index 0000000000..46edeacd87 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/context-call.uc @@ -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 index 0000000000..4191f10990 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/context.uc @@ -0,0 +1,287 @@ +'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_named_params(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.help ]); + } + return { type: "keywords", data }; +} + +function complete_param(e, ctx, cur, val, args) +{ + cur = { ...cur }; + if (type(cur.value) == "function") + cur.value = call(cur.value, e, {}, ctx, args); + + if (cur.value == "object") { + let ret = []; + for (let key in sort(keys(cur.value))) + if (prefix_match(val, k)) + push(ret, [ key, cur.value[key] ]); + + cur.value = ret; + } else if (type(cur.value) == "array") { + cur.value = map(sort(filter(cur.value, (v) => prefix_match(val, v))), (v) => [ v ]); + } + + return { type: "field", data: [ cur.name, cur.help, cur.value ] }; +} + +function default_complete(ctx, args) +{ + if (!this.args) + return; + + let cur_args = [ ...this.args ]; + let orig_args = [ ...args ]; + let name_arg; + let cur; + + while (length(args) > 0) { + let val = shift(args); + + cur = cur_args[0]; + if (cur.named_param) { + if (length(args) > 0) { + if (name_arg != null) { + name_arg = null; + continue; + } + + if (!cur.named_param[val] || !cur.named_param[val].flag) + name_arg = val; + continue; + } + + if (!name_arg) + return complete_named_params(cur, val); + + cur = cur_args[0] = cur.named_param[name_arg]; + if (!cur) + return; + } + + if (!cur.repeat) + shift(cur_args); + + if (length(args)) + continue; + + return complete_param(this, ctx, cur, val, orig_args); + } +} + +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 = []; + let exit_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; + + let cur = [cur_name, val.help]; + if (cur_name == "exit") + exit_data = cur; + else + push(data, cur); + } + + if (ctx == this && this.prev && !this.type.exit && + prefix_match(name, "exit")) + exit_data ??= [ "exit", "Return to previous menu" ]; + + if (exit_data) + push(data, exit_data); + + 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 index 0000000000..650618cbac --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc @@ -0,0 +1,97 @@ +'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(`${e}\n${e.stacktrace[0].context}\nFailed to open module ${path}.\n`); + 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", + COMMAND_FAILED: "Command failed", + }, + 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 index 0000000000..429c57c4b7 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc @@ -0,0 +1,164 @@ +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_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.error("COMMAND_FAILED"); +} + +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: "name", + help: "Service name", + value: (ctx) => get_services(ctx.model) + } +]; + +const Service = { + list: { + help: "List services", + call: function(ctx, argv) { + return ctx.list("Services", get_services(ctx.model)); + } + }, + 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"); + } + }, + enable: { + help: "Enable service autostart", + validate: service_validate, + args: service_args, + call: function(ctx, argv) { + return service_cmd(ctx, shift(argv), "enable", "Service enabled"); + } + }, + disable: { + help: "Disable service autostart", + validate: service_validate, + args: service_args, + call: function(ctx, argv) { + return service_cmd(ctx, shift(argv), "disable", "Service disabled"); + } + }, + 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"); + + 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(ctx.model, name)) + return ctx.error("SERVICE_NOT_RUNNING"); + + 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(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 data = { + "Running": service_running(ctx.model, name), + "Enabled": __service_cmd(name, "enabled"), + }; + return ctx.table("Status", data); + } + } +}; + +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", + }, +}; -- 2.30.2