cli 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  1. #!/usr/bin/env ucode
  2. // SPDX-License-Identifier: GPL-2.0-or-later
  3. // Copyright (C) 2025 Felix Fietkau <[email protected]>
  4. 'use strict';
  5. import * as datamodel from "cli.datamodel";
  6. import { bold, color_fg } from "cli.color";
  7. import * as uline from "uline";
  8. import { basename, stdin } from "fs";
  9. let history = [];
  10. let history_edit;
  11. let history_idx = -1;
  12. let cur_line;
  13. let interactive, script_mode, raw_mode;
  14. while (length(ARGV) > 0) {
  15. let cmd = ARGV[0];
  16. if (substr(cmd, 0, 1) != "-")
  17. break;
  18. shift(ARGV);
  19. switch (cmd) {
  20. case '-i':
  21. interactive = true;
  22. break;
  23. case '-s':
  24. script_mode = true;
  25. break;
  26. case '-R':
  27. raw_mode = true;
  28. break;
  29. }
  30. }
  31. let el;
  32. let model = datamodel.new({
  33. getpass: uline.getpass,
  34. poll_key: (timeout) => el.poll_key(timeout),
  35. status_msg: (msg) => {
  36. el.hide_prompt();
  37. warn(msg + "\n");
  38. el.refresh_prompt();
  39. },
  40. opt_pretty_print: !raw_mode
  41. });
  42. let uloop = model.uloop;
  43. model.add_modules();
  44. let ctx = model.context();
  45. let parser = uline.arg_parser({
  46. line_separator: ";"
  47. });
  48. let base_prompt = [ "cli" ];
  49. model.add_nodes({
  50. Root: {
  51. exit: {
  52. help: "Exit the CLI",
  53. call: function(ctx) {
  54. el.close();
  55. uloop.end();
  56. interactive = false;
  57. return ctx.ok();
  58. }
  59. }
  60. }
  61. });
  62. model.init();
  63. function update_prompt() {
  64. el.set_state({
  65. prompt: bold(join(" ", [ ...base_prompt, ...ctx.prompt ]) + "> "),
  66. });
  67. }
  68. let cur_completion, tab_arg, tab_arg_len, tab_prefix, tab_suffix, tab_prefix_len, tab_quote, tab_ctx;
  69. function max_len(list, len)
  70. {
  71. for (let entry in list)
  72. if (length(entry) > len)
  73. len = length(entry);
  74. return len + 3;
  75. }
  76. function sort_completion(data)
  77. {
  78. let categories = {};
  79. for (let entry in data) {
  80. let cat = entry.category ?? " ";
  81. categories[cat] ??= [];
  82. push(categories[cat], entry);
  83. }
  84. return categories;
  85. }
  86. function val_str(val)
  87. {
  88. if (type(val) == "array")
  89. return join(", ", val);
  90. return val;
  91. }
  92. function helptext_list_str(cur, str)
  93. {
  94. let data = cur.value;
  95. let categories = sort_completion(data);
  96. let cat_len = max_len(keys(categories));
  97. let has_categories = length(categories) > 1 || !categories[" "];
  98. let len = max_len(map(data, (v) => v.name), 10);
  99. if (has_categories || str == null)
  100. str = "";
  101. for (let cat, cdata in categories) {
  102. if (has_categories && cat != " ") {
  103. if (length(str) > 0)
  104. str += "\n";
  105. str += `${cat}:\n`;
  106. }
  107. for (let val in cdata) {
  108. let name = val.name;
  109. let help = val.help ?? "";
  110. let extra = [];
  111. if (val.multiple)
  112. push(extra, "multiple");
  113. if (val.required)
  114. push(extra, "required");
  115. if (val.default)
  116. push(extra, "default: " + val_str(val.default));
  117. if (length(extra) > 0)
  118. help += " (" + join(", ", extra) + ")";
  119. if (length(help) > 0)
  120. name += ":";
  121. str += sprintf(" %-" + len + "s %s\n", name, help);
  122. }
  123. }
  124. return str;
  125. }
  126. function helptext(cur) {
  127. if (!cur) {
  128. el.set_hint(`\n No help information available\n`);
  129. return true;
  130. }
  131. let str = `${cur.help}: `;
  132. let data = cur.value;
  133. if (type(data) != "array") {
  134. str += `<${cur.type}>\n`;
  135. } else if (length(data) > 0) {
  136. str += "\n";
  137. str = helptext_list_str(cur, str);
  138. } else {
  139. str += " (no match)\n";
  140. }
  141. el.set_hint(str);
  142. return true;
  143. }
  144. function completion_ctx(arg_info)
  145. {
  146. let cur_ctx = ctx;
  147. for (let args in arg_info.args) {
  148. let sel = cur_ctx.select(args, true);
  149. if (!length(args))
  150. cur_ctx = sel;
  151. if (type(sel) != "object" || sel.errors)
  152. return;
  153. }
  154. return cur_ctx;
  155. }
  156. function completion_replace_arg(val, incomplete, skip_space)
  157. {
  158. let ref = substr(tab_prefix, -tab_prefix_len);
  159. val = parser.escape(val, ref);
  160. if (incomplete) {
  161. let last = substr(val, -1);
  162. if (last == '"' || last == "'")
  163. val = substr(val, 0, -1);
  164. } else if (!skip_space) {
  165. val += " ";
  166. }
  167. let line = tab_prefix;
  168. if (tab_prefix_len)
  169. line = substr(tab_prefix, 0, -tab_prefix_len);
  170. line += val;
  171. let pos = length(line);
  172. line += tab_suffix;
  173. el.set_state({ line, pos });
  174. }
  175. function completion_check_prefix(data)
  176. {
  177. let prefix = data[0].name;
  178. let prefix_len = length(prefix);
  179. for (let entry in data) {
  180. entry = entry.name;
  181. if (prefix_len > length(entry))
  182. prefix_len = length(entry);
  183. }
  184. prefix = substr(prefix, 0, prefix_len);
  185. for (let entry in data) {
  186. entry = substr(entry.name, 0, prefix_len);
  187. while (entry != prefix) {
  188. prefix_len--;
  189. prefix = substr(prefix, 0, prefix_len);
  190. entry = substr(entry, 0, prefix_len);
  191. }
  192. }
  193. completion_replace_arg(prefix, true);
  194. }
  195. function completion(count) {
  196. if (count < 2) {
  197. let line_data = el.get_line();
  198. let line = line_data.line;
  199. let pos = line_data.pos;
  200. tab_suffix = substr(line, pos);
  201. if (length(tab_suffix) > 0 &&
  202. substr(tab_suffix, 0, 1) != " ") {
  203. let idx = index(tab_suffix, " ");
  204. if (idx < 0 || !idx)
  205. pos += length(tab_suffix);
  206. else
  207. pos += idx;
  208. tab_suffix = substr(line, pos);
  209. }
  210. tab_prefix = substr(line, 0, pos);
  211. let arg_info = parser.parse(tab_prefix);
  212. let is_open = arg_info.missing != null;
  213. if (arg_info.missing == "\\\"")
  214. tab_quote = "\"";
  215. else
  216. tab_quote = arg_info.missing ?? "";
  217. let args = pop(arg_info.args);
  218. let arg_pos = pop(arg_info.pos);
  219. if (!is_open && substr(tab_prefix, -1) == " ")
  220. push(args, "");
  221. let tab_arg_pos = arg_pos[length(args) - 1];
  222. tab_arg = args[length(args) - 1];
  223. if (tab_arg_pos)
  224. tab_prefix_len = tab_arg_pos[1] - tab_arg_pos[0];
  225. else
  226. tab_prefix_len = 0;
  227. tab_ctx = completion_ctx(arg_info);
  228. if (!tab_ctx)
  229. return;
  230. cur_completion = tab_ctx.complete([...args]);
  231. }
  232. if (!tab_ctx)
  233. return;
  234. if (count < 0 || (cur_completion && cur_completion.force_helptext))
  235. return helptext(cur_completion);
  236. let cur = cur_completion;
  237. if (!cur || !cur.value) {
  238. if (!tab_prefix_len) {
  239. el.set_hint("");
  240. return;
  241. }
  242. cur = {
  243. value: [{
  244. name: tab_arg,
  245. }]
  246. };
  247. }
  248. let data = cur.value;
  249. if (length(data) == 0) {
  250. el.set_hint(` (no match)`);
  251. return;
  252. }
  253. if (length(data) == 1) {
  254. completion_replace_arg(data[0].name, data[0].incomplete);
  255. el.set_hint("");
  256. el.reset_key_input();
  257. return;
  258. }
  259. if (count == 1)
  260. completion_check_prefix(data);
  261. if (count > 1) {
  262. let idx = (count - 2) % length(data);
  263. completion_replace_arg(data[idx].name, false, true);
  264. }
  265. let win = el.get_window();
  266. let str = "";
  267. let x = 0;
  268. let categories = sort_completion(data);
  269. let cat_len = max_len(keys(categories));
  270. let len = max_len(map(data, (v) => v.name));
  271. let has_categories = length(categories) > 1 || !categories[" "];
  272. for (let cat, cdata in categories) {
  273. let cat_start = cat != " ";
  274. if (cat_start)
  275. cat += ": ";
  276. if (x) {
  277. str += "\n";
  278. x = 0;
  279. }
  280. for (let entry in cdata) {
  281. let add;
  282. if (!x && has_categories)
  283. add = sprintf(" %-"+cat_len+"s", cat);
  284. else
  285. add = " ";
  286. cat = "";
  287. let name = entry.name;
  288. if (entry.incomplete)
  289. name += "...";
  290. add += sprintf("%-"+len+"s", name);
  291. str += add;
  292. x += length(add);
  293. if (x + length(add) < win.x)
  294. continue;
  295. str += "\n";
  296. x = 0;
  297. }
  298. }
  299. el.set_hint(str);
  300. }
  301. function format_entry(val)
  302. {
  303. if (type(val) == "bool")
  304. val = val ? "yes" : "no";
  305. return val;
  306. }
  307. function format_multiline(prefix, val)
  308. {
  309. let prefix2 = replace(prefix, /./g, " ");
  310. let prefix_len = length(prefix);
  311. let win = el.get_window();
  312. let x = 0;
  313. if (type(val) != "array")
  314. val = [ val ];
  315. if (length(val) == 0)
  316. val = [ "<none>" ];
  317. for (let cur in val) {
  318. cur = format_entry(cur);
  319. let cur_lines = split(cur, "\n");
  320. if (length(cur_lines) > 1) {
  321. if (x) {
  322. warn(',\n');
  323. x = 0;
  324. }
  325. cur = join("\n" + prefix2, cur_lines);
  326. warn(cur);
  327. x = win.x;
  328. prefix = null;
  329. continue;
  330. }
  331. if (x && (x + length(cur) > win.x - 3)) {
  332. warn(',\n');
  333. x = 0;
  334. }
  335. if (!x) {
  336. warn(prefix ?? prefix2);
  337. prefix = null;
  338. x = prefix_len;
  339. } else {
  340. warn(', ');
  341. x += 2;
  342. }
  343. warn(cur);
  344. x += length(cur);
  345. }
  346. warn('\n');
  347. }
  348. function format_table(table)
  349. {
  350. let data = table;
  351. let len = max_len(map(data, (v) => v[0]), 8);
  352. for (let line in data) {
  353. let name = line[0];
  354. let val = line[1];
  355. let prefix = sprintf(" %-" + len + "s ", name + ":");
  356. format_multiline(prefix, val);
  357. }
  358. }
  359. function convert_table(val)
  360. {
  361. if (type(val) == "array")
  362. return val;
  363. let data = [];
  364. for (let name in sort(keys(val)))
  365. push(data, [ name, val[name] ]);
  366. return data;
  367. }
  368. function convert_multi_table(val)
  369. {
  370. if (type(val) != "array") {
  371. let data = [];
  372. for (let name in sort(keys(val)))
  373. push(data, [ val[name], name ]);
  374. val = data;
  375. }
  376. for (let line in val)
  377. line[0] = convert_table(line[0]);
  378. return val;
  379. }
  380. function format_result(res)
  381. {
  382. if (!res) {
  383. warn(color_fg("red", "Unknown command") + "\n");
  384. return;
  385. }
  386. if (!res.ok) {
  387. for (let err in res.errors) {
  388. warn(color_fg("red", "Error: "+ err.msg) + "\n");
  389. }
  390. if (!length(res.errors))
  391. warn(color_fg("red", "Failed") + "\n");
  392. return;
  393. }
  394. if (res.status_msg)
  395. warn(color_fg("green", res.status_msg) + "\n");
  396. if (res.name)
  397. warn(res.name + ": ");
  398. let data = res.data;
  399. switch (res.type) {
  400. case "multi_table":
  401. data = convert_multi_table(data);
  402. warn("\n");
  403. for (let table in data) {
  404. if (table[1])
  405. warn("\n" + table[1] + ":\n");
  406. format_table(table[0]);
  407. warn("\n");
  408. }
  409. break;
  410. case "table":
  411. data = convert_table(data);
  412. warn("\n");
  413. format_table(data);
  414. break;
  415. case "list":
  416. warn("\n");
  417. for (let entry in data)
  418. warn(" - " + entry + "\n");
  419. break;
  420. case "string":
  421. warn(res.data + "\n");
  422. break;
  423. case "json":
  424. warn(sprintf("%.J\n", res.data));
  425. break;
  426. }
  427. }
  428. function line_history_reset()
  429. {
  430. history_idx = -1;
  431. history_edit = null;
  432. cur_line = null;
  433. }
  434. function line_history(dir)
  435. {
  436. let min_idx = cur_line == null ? 0 : -1;
  437. let new_idx = history_idx + dir;
  438. if (new_idx < min_idx || new_idx >= length(history))
  439. return;
  440. let line = el.get_line().line;
  441. let cur_history = history_edit ?? history;
  442. if (history_idx == -1)
  443. cur_line = line;
  444. else if (cur_history[history_idx] != line) {
  445. history_edit ??= [ ...history ];
  446. history_edit[history_idx] = line;
  447. cur_history = history_edit;
  448. }
  449. history_idx = new_idx;
  450. if (history_idx < 0)
  451. line = cur_line;
  452. else
  453. line = cur_history[history_idx];
  454. let pos = length(line);
  455. el.set_state({ line, pos });
  456. }
  457. let rev_search, rev_search_results, rev_search_index;
  458. function reverse_search_update(line)
  459. {
  460. if (line) {
  461. rev_search = line;
  462. rev_search_results = filter(history, (l) => index(l, line) >= 0);
  463. rev_search_index = 0;
  464. }
  465. let prompt = "reverse-search: ";
  466. if (line && !length(rev_search_results))
  467. prompt = "failing " + prompt;
  468. el.set_state({
  469. line2_prompt: prompt,
  470. });
  471. if (line && length(rev_search_results)) {
  472. line = rev_search_results[0];
  473. let pos = length(line);
  474. el.set_state({ line, pos });
  475. }
  476. }
  477. function reverse_search_reset() {
  478. if (rev_search == null)
  479. return;
  480. rev_search = null;
  481. rev_search_results = null;
  482. rev_search_index = 0;
  483. el.set_state({
  484. line2_prompt: null
  485. });
  486. }
  487. function reverse_search()
  488. {
  489. if (rev_search == null) {
  490. reverse_search_update("");
  491. return;
  492. }
  493. if (!length(rev_search_results))
  494. return;
  495. rev_search_index = (rev_search_index + 1) % length(rev_search_results);
  496. let line = rev_search_results[rev_search_index];
  497. let pos = length(line);
  498. el.set_state({ line, pos });
  499. }
  500. function line_cb(line)
  501. {
  502. reverse_search_reset();
  503. line_history_reset();
  504. unshift(history, line);
  505. let arg_info = parser.parse(line);
  506. if (!arg_info)
  507. return;
  508. for (let cmd in arg_info.args) {
  509. let orig_cmd = [ ...cmd ];
  510. // convenience hack
  511. if (cmd[0] == "cd" && cmd[1] == "..") {
  512. shift(cmd);
  513. cmd[0] = "up";
  514. } else if (cmd[0] == "ls") {
  515. let compl = ctx.complete([""]);
  516. if (!compl)
  517. continue;
  518. warn(helptext_list_str(compl));
  519. continue;
  520. }
  521. let cur_ctx = ctx.select(cmd);
  522. if (type(cur_ctx) != "object" || cur_ctx.errors) {
  523. format_result(cur_ctx);
  524. break;
  525. }
  526. if (!length(cmd)) {
  527. ctx = cur_ctx;
  528. update_prompt();
  529. continue;
  530. }
  531. try {
  532. let res = cur_ctx.call(cmd);
  533. format_result(res);
  534. if (res && res.ctx) {
  535. ctx = res.ctx;
  536. update_prompt();
  537. }
  538. } catch (e) {
  539. model.exception(e);
  540. }
  541. }
  542. }
  543. const cb = {
  544. eof: () => { warn(`\n`); uloop.end(); },
  545. line_check: (line) => parser.check(line) == null,
  546. line2_cursor: () => {
  547. reverse_search_reset();
  548. return false;
  549. },
  550. line2_update: reverse_search_update,
  551. key_input: (c, count) => {
  552. try {
  553. switch(c) {
  554. case "?":
  555. if (parser.check(el.get_line().line) != null)
  556. return false;
  557. completion(-1);
  558. return true;
  559. case "\t":
  560. reverse_search_reset();
  561. completion(count);
  562. return true;
  563. case '\x03':
  564. if (count < 2) {
  565. el.set_state({ line: "", pos: 0 });
  566. } else if (ctx.prev) {
  567. warn(`\n`);
  568. let cur_ctx = ctx.select([ "main" ]);
  569. if (cur_ctx && !cur_ctx.errors)
  570. ctx = cur_ctx;
  571. update_prompt();
  572. } else {
  573. warn(`\n`);
  574. el.poll_stop();
  575. uloop.end();
  576. }
  577. return true;
  578. case "\x12":
  579. reverse_search();
  580. return true;
  581. }
  582. } catch (e) {
  583. warn(`${e}\n${e.stacktrace[0].context}`);
  584. }
  585. },
  586. cursor_up: () => {
  587. try {
  588. line_history(1);
  589. } catch (e) {
  590. el.set_hint(`${e}\n${e.stacktrace[0].context}`);
  591. }
  592. },
  593. cursor_down: () => {
  594. try {
  595. line_history(-1);
  596. } catch (e) {
  597. el.set_hint(`${e}\n${e.stacktrace[0].context}`);
  598. }
  599. },
  600. };
  601. el = uline.new({
  602. utf8: true,
  603. cb,
  604. key_input_list: [ "?", "\t", "\x03", "\x12" ]
  605. });
  606. if (SCRIPT_NAME != "cli") {
  607. let cur_ctx = ctx.select([ basename(SCRIPT_NAME) ]);
  608. if (cur_ctx && cur_ctx != ctx && !cur_ctx.errors) {
  609. ctx = cur_ctx;
  610. delete ctx.prev;
  611. ctx.node.exit = model.node.Root.exit;
  612. base_prompt = [];
  613. }
  614. }
  615. while (length(ARGV) > 0) {
  616. let cmd = ARGV;
  617. let idx = index(ARGV, ":");
  618. if (idx >= 0) {
  619. cmd = slice(ARGV, 0, idx);
  620. ARGV = slice(ARGV, idx + 1);
  621. } else {
  622. ARGV = [];
  623. }
  624. interactive ??= false;
  625. let orig_cmd = [ ...cmd ];
  626. let cur_ctx = ctx.select(cmd);
  627. if (type(cur_ctx) != "object" || cur_ctx.errors) {
  628. format_result(cur_ctx);
  629. break;
  630. }
  631. if (!length(cmd)) {
  632. ctx = cur_ctx;
  633. continue;
  634. }
  635. let res = cur_ctx.call(cmd);
  636. format_result(res);
  637. }
  638. if (script_mode) {
  639. el.close();
  640. while (!stdin.error()) {
  641. let line = stdin.read("line");
  642. line_cb(line);
  643. }
  644. exit(0);
  645. }
  646. if (interactive != false) {
  647. warn("Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments\n");
  648. update_prompt();
  649. el.set_uloop(line_cb);
  650. uloop.run();
  651. exit(0);
  652. }