@nan0web/ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.datasets/README.dataset.jsonl +12 -0
  2. package/.editorconfig +20 -0
  3. package/CONTRIBUTING.md +42 -0
  4. package/LICENSE +15 -0
  5. package/README.md +238 -0
  6. package/docs/uk/README.md +240 -0
  7. package/package.json +64 -0
  8. package/playground/User.js +52 -0
  9. package/playground/currency.exchange.js +48 -0
  10. package/playground/i18n/index.js +21 -0
  11. package/playground/i18n/uk.js +53 -0
  12. package/playground/language.form.js +25 -0
  13. package/playground/main.js +72 -0
  14. package/playground/registration.form.js +58 -0
  15. package/playground/topup.telephone.js +62 -0
  16. package/src/App/Command/Options.js +78 -0
  17. package/src/App/Command/index.js +9 -0
  18. package/src/App/Core/CoreApp.js +129 -0
  19. package/src/App/Core/UI.js +116 -0
  20. package/src/App/Core/Widget.js +67 -0
  21. package/src/App/Core/index.js +11 -0
  22. package/src/App/Scenario.js +45 -0
  23. package/src/App/User/Command/Message.js +44 -0
  24. package/src/App/User/Command/Options.js +48 -0
  25. package/src/App/User/Command/index.js +11 -0
  26. package/src/App/User/UserApp.js +73 -0
  27. package/src/App/User/UserApp.test.js +56 -0
  28. package/src/App/User/UserUI.js +20 -0
  29. package/src/App/User/UserUI.test.js +51 -0
  30. package/src/App/User/index.js +15 -0
  31. package/src/App/index.js +22 -0
  32. package/src/Component/Process/Input.js +70 -0
  33. package/src/Component/Process/Process.js +26 -0
  34. package/src/Component/Process/index.js +5 -0
  35. package/src/Component/Welcome/Input.js +50 -0
  36. package/src/Component/Welcome/Welcome.js +26 -0
  37. package/src/Component/Welcome/index.js +5 -0
  38. package/src/Component/index.js +9 -0
  39. package/src/Frame/Frame.js +591 -0
  40. package/src/Frame/Frame.test.js +429 -0
  41. package/src/Frame/Props.js +102 -0
  42. package/src/Locale.js +119 -0
  43. package/src/Model/User/User.js +56 -0
  44. package/src/Model/index.js +7 -0
  45. package/src/README.md.js +371 -0
  46. package/src/StdIn.js +111 -0
  47. package/src/StdOut.js +99 -0
  48. package/src/View/RenderOptions.js +48 -0
  49. package/src/View/View.js +289 -0
  50. package/src/View/View.test.js +77 -0
  51. package/src/core/Form/Form.js +289 -0
  52. package/src/core/Form/Form.test.js +116 -0
  53. package/src/core/Form/Input.js +116 -0
  54. package/src/core/Form/Input.test.js +58 -0
  55. package/src/core/Form/Message.js +86 -0
  56. package/src/core/Form/Message.test.js +54 -0
  57. package/src/core/Form/index.js +11 -0
  58. package/src/core/InputAdapter.js +41 -0
  59. package/src/core/InputAdapter.test.js +35 -0
  60. package/src/core/Message/InputMessage.js +119 -0
  61. package/src/core/Message/InputMessage.test.js +45 -0
  62. package/src/core/Message/Message.js +77 -0
  63. package/src/core/Message/Message.test.js +58 -0
  64. package/src/core/Message/OutputMessage.js +143 -0
  65. package/src/core/Message/OutputMessage.test.js +61 -0
  66. package/src/core/Message/index.js +7 -0
  67. package/src/core/OutputAdapter.js +50 -0
  68. package/src/core/OutputAdapter.test.js +35 -0
  69. package/src/core/Stream.js +71 -0
  70. package/src/core/Stream.test.js +78 -0
  71. package/src/core/StreamEntry.js +59 -0
  72. package/src/core/index.js +13 -0
  73. package/src/functions.js +38 -0
  74. package/src/index.js +34 -0
  75. package/src/index.test.js +14 -0
  76. package/src/models/SimpleUser.js +18 -0
  77. package/stories/App/AppView.js +15 -0
  78. package/stories/App/AppView.test.js +22 -0
  79. package/stories/App/RenderOptions.js +14 -0
  80. package/stories/nodejs/interface.test.js +27 -0
  81. package/system.md +187 -0
  82. package/system1.md +137 -0
  83. package/task.md +181 -0
  84. package/tsconfig.json +23 -0
  85. package/types/App/Command/Options.d.ts +46 -0
  86. package/types/App/Command/index.d.ts +8 -0
  87. package/types/App/Core/CoreApp.d.ts +70 -0
  88. package/types/App/Core/UI.d.ts +49 -0
  89. package/types/App/Core/Widget.d.ts +40 -0
  90. package/types/App/Core/index.d.ts +10 -0
  91. package/types/App/Scenario.d.ts +26 -0
  92. package/types/App/User/Command/Message.d.ts +30 -0
  93. package/types/App/User/Command/Options.d.ts +27 -0
  94. package/types/App/User/Command/index.d.ts +8 -0
  95. package/types/App/User/UserApp.d.ts +31 -0
  96. package/types/App/User/UserUI.d.ts +18 -0
  97. package/types/App/User/index.d.ts +12 -0
  98. package/types/App/index.d.ts +14 -0
  99. package/types/Component/Process/Input.d.ts +48 -0
  100. package/types/Component/Process/Process.d.ts +13 -0
  101. package/types/Component/Process/index.d.ts +4 -0
  102. package/types/Component/Welcome/Input.d.ts +34 -0
  103. package/types/Component/Welcome/Welcome.d.ts +13 -0
  104. package/types/Component/Welcome/index.d.ts +4 -0
  105. package/types/Component/index.d.ts +8 -0
  106. package/types/Frame/Frame.d.ts +186 -0
  107. package/types/Frame/Props.d.ts +77 -0
  108. package/types/Locale.d.ts +55 -0
  109. package/types/Model/User/User.d.ts +36 -0
  110. package/types/Model/index.d.ts +6 -0
  111. package/types/StdIn.d.ts +62 -0
  112. package/types/StdOut.d.ts +52 -0
  113. package/types/View/RenderOptions.d.ts +29 -0
  114. package/types/View/View.d.ts +115 -0
  115. package/types/core/Form/Form.d.ts +123 -0
  116. package/types/core/Form/Input.d.ts +69 -0
  117. package/types/core/Form/Message.d.ts +28 -0
  118. package/types/core/Form/index.d.ts +5 -0
  119. package/types/core/InputAdapter.d.ts +28 -0
  120. package/types/core/Message/InputMessage.d.ts +71 -0
  121. package/types/core/Message/Message.d.ts +50 -0
  122. package/types/core/Message/OutputMessage.d.ts +53 -0
  123. package/types/core/Message/index.d.ts +5 -0
  124. package/types/core/OutputAdapter.d.ts +33 -0
  125. package/types/core/Stream.d.ts +27 -0
  126. package/types/core/StreamEntry.d.ts +45 -0
  127. package/types/core/index.d.ts +9 -0
  128. package/types/functions.d.ts +3 -0
  129. package/types/index.d.ts +20 -0
  130. package/types/models/SimpleUser.d.ts +21 -0
  131. package/vitest.config.js +26 -0
@@ -0,0 +1,52 @@
1
+ import FormInput from "../src/core/Form/Input.js"
2
+
3
+ /**
4
+ * Simple domain model representing a user.
5
+ *
6
+ * The static `formFields` property defines the UI fields that should be
7
+ * displayed when generating a form for this model.
8
+ *
9
+ * Each field is described using `FormInput`, which the UI core library
10
+ * understands. The `required` flag, `type`, and other properties are
11
+ * respected by the validation routine inside `UIForm`.
12
+ */
13
+ export default class User {
14
+ /**
15
+ * Constructs a new User instance.
16
+ *
17
+ * @param {Object} data - Initial data.
18
+ * @param {string} data.name
19
+ * @param {string} data.email
20
+ * @param {number} data.age
21
+ */
22
+ constructor({ name = "", email = "", age = null } = {}) {
23
+ this.name = String(name)
24
+ this.email = String(email)
25
+ this.age = age !== null ? Number(age) : null
26
+ }
27
+
28
+ /** @type {FormInput[]} UI fields for the User model */
29
+ static formFields = [
30
+ new FormInput({
31
+ name: "name",
32
+ label: "Name",
33
+ type: FormInput.TYPES.TEXT,
34
+ required: true,
35
+ placeholder: "Enter full name",
36
+ }),
37
+ new FormInput({
38
+ name: "email",
39
+ label: "Email",
40
+ type: FormInput.TYPES.EMAIL,
41
+ required: true,
42
+ placeholder: "example@domain.com",
43
+ }),
44
+ new FormInput({
45
+ name: "age",
46
+ label: "Age",
47
+ type: FormInput.TYPES.NUMBER,
48
+ required: false,
49
+ placeholder: "Optional",
50
+ }),
51
+ ]
52
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Simple currency exchange console tool.
3
+ *
4
+ * Prompts for:
5
+ * - From currency (options)
6
+ * - To currency (options)
7
+ * - Amount (number)
8
+ *
9
+ * Controls:
10
+ * - Input "0" on any prompt to cancel.
11
+ * - Empty input on final confirmation submits.
12
+ */
13
+
14
+ import { CancelError, select } from "@nan0web/ui-cli"
15
+
16
+ const rates = {
17
+ USD: 1,
18
+ EUR: 0.9,
19
+ UAH: 27,
20
+ GBP: 0.8,
21
+ }
22
+
23
+ /** Main exchange flow */
24
+ export async function runExchange(t, ask, console, prompt, invalidPrompt) {
25
+ /** Choose a currency from available list */
26
+ async function chooseCurrency(title, selected = []) {
27
+ const input = await select({
28
+ title,
29
+ prompt, invalidPrompt, console,
30
+ options: Object.keys(rates).filter(c => !selected.includes(c))
31
+ })
32
+ return input.value
33
+ }
34
+
35
+ console.info("\n=== " + t("Currency Exchange") + " ===")
36
+ const from = await chooseCurrency(t("From Currency"))
37
+ if (!from) throw new CancelError()
38
+ const to = await chooseCurrency(t("To Currency"), [from])
39
+ if (!to) throw new CancelError()
40
+ const input = await ask(
41
+ t("Amount") + ": ",
42
+ input => isNaN(input) || Number(input) <= 0,
43
+ t("Invalid amount.") + ": ",
44
+ )
45
+ const amount = Number(input)
46
+ const result = (amount / rates[from]) * rates[to]
47
+ console.success(`\n${amount} ${from} = ${result.toFixed(2)} ${to}\n`)
48
+ }
@@ -0,0 +1,21 @@
1
+ import i18n, { createT } from "@nan0web/i18n"
2
+ import uk from "./uk.js"
3
+
4
+ const getVocab = i18n({ uk })
5
+
6
+ export function detectLocale(argv = []) {
7
+ // Detect language from CLI argument or environment variable
8
+ const langArg = argv.find(a => a.startsWith("--lang="))
9
+ let locale = "en"
10
+ if (langArg) {
11
+ locale = langArg.split("=")[1]
12
+ }
13
+ return locale
14
+ }
15
+
16
+ export const localesMap = new Map([
17
+ ["en", "English"],
18
+ ["uk", "Українська"],
19
+ ])
20
+
21
+ export default (locale) => createT(getVocab(locale))
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Ukrainian translations for the playground.
3
+ *
4
+ * The object keys are English identifiers, the values are Ukrainian strings.
5
+ *
6
+ * Example usage:
7
+ * import vocabMap from "./i18n/index.js"
8
+ * const vocab = vocabMap("uk")
9
+ * const t = createT(vocab)
10
+ * console.log(t("Registration Form")) // → "Форма реєстрації"
11
+ * console.log(vocab["Registration Form"]) // → "Форма реєстрації"
12
+ */
13
+ export default {
14
+ "Select demo:": "Оберіть демонстрацію:",
15
+ "Registration Form": "Форма реєстрації",
16
+ "Currency Exchange": "Обмін валют",
17
+ "Top‑up Telephone": "Поповнення телефону",
18
+ "Enter number (or 0 to quit):": "Введіть номер (або 0 для виходу):",
19
+ "Good‑bye.": "До побачення.",
20
+ "Invalid choice.": "Невірний вибір.",
21
+ "Username": "Ім'я користувача",
22
+ "Password": "Пароль",
23
+ "Confirm Password": "Підтвердження пароля",
24
+ "Email or Telephone": "Електронна пошта або телефон",
25
+ "Press ENTER to submit, type 0 to cancel": "Натисніть ENTER для відправки, введіть 0 для скасування",
26
+ "Cancelled.": "Скасовано.",
27
+ "Form submitted successfully!": "Форма успішно надіслана!",
28
+ "Phone Number": "Номер телефону",
29
+ "Top‑up Amount": "Сума поповнення",
30
+ "Select currency": "Виберіть валюту",
31
+ "Currency options:": "Валютні опції:",
32
+ "Invalid choice.": "Неправильний вибір.",
33
+ "Phone number is required.": "Необхідно вказати номер телефону.",
34
+ "Invalid phone number. Use digits only, 7‑15 characters.": "Неправильний номер телефону. Використовуйте лише цифри, 7-15 символів.",
35
+ "Invalid amount.": "Неправильна сума.",
36
+ "Top‑up of": "Поповнення на",
37
+ "to": "до",
38
+ "scheduled.": "заплановано.",
39
+ "options:": "опції:",
40
+ "Select": "Зробіть вибір",
41
+ "From Currency": "З якої валюти міняємо",
42
+ "To Currency": "У яку валюту",
43
+ "Amount": "Сума",
44
+ "Invalid choice, try again.": "Неправильний вибір, спробуйте знову.",
45
+ "Password (min 4 chars)": "Пароль (мінімум 4 символи)",
46
+ "Passwords do not match. Try again.": "Паролі не збігаються. Спробуйте знову.",
47
+ "[me]": "[Я]",
48
+ "[me invalid]": "[Я помилився]",
49
+ "! Invalid choice.": "! Неправильний вибір.",
50
+ "⨉ Cancelled.": "⨉ Скасовано.",
51
+ "Select a language": "Виберіть мову",
52
+ "Language Selector": "Вибір мови",
53
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Simple currency exchange console tool.
3
+ *
4
+ * Prompts for:
5
+ * - From currency (options)
6
+ * - To currency (options)
7
+ * - Amount (number)
8
+ *
9
+ * Controls:
10
+ * - Input "0" on any prompt to cancel.
11
+ * - Empty input on final confirmation submits.
12
+ */
13
+ import { localesMap } from "./i18n/index.js"
14
+ import { select } from "@nan0web/ui-cli"
15
+
16
+ /** Main exchange flow */
17
+ export async function runLanguage(t, ask, console, prompt, invalidPrompt) {
18
+ const lang = await select({
19
+ title: "\n=== " + t("Language Selector") + " ===",
20
+ prompt, invalidPrompt, console,
21
+ options: Array.from(localesMap.keys()),
22
+ })
23
+ console.success(`\n${lang.value}\n`)
24
+ return lang.value
25
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Playground entry point.
3
+ *
4
+ * Choose which demo to run:
5
+ * 1) Select a language
6
+ * 2) Registration Form
7
+ * 3) Currency Exchange
8
+ * 4) Top‑up Telephone
9
+ *
10
+ * Type 0 to exit.
11
+ */
12
+ import { argv } from "node:process"
13
+ import Logger from "@nan0web/log"
14
+ import { runLanguage } from "./language.form.js"
15
+ import { runRegistration } from "./registration.form.js"
16
+ import { runExchange } from "./currency.exchange.js"
17
+ import { runTopup } from "./topup.telephone.js"
18
+ import createT, { detectLocale } from "./i18n/index.js"
19
+ import createInput, { CancelError, select } from "./ui/index.js"
20
+
21
+ const console = new Logger(Logger.detectLevel(argv))
22
+ let t = createT(detectLocale(argv))
23
+ const ask = createInput(["0"])
24
+ const menuOptions = [
25
+ "Select a language", // t("Select a language")
26
+ "Registration Form", // t("Registration Form")
27
+ "Currency Exchange", // t("Currency Exchange")
28
+ "Top‑up Telephone", // t("Top‑up Telephone")
29
+ ]
30
+
31
+ async function main() {
32
+ console.info(Logger.style(Logger.LOGO, { color: "magenta" }))
33
+
34
+ while (true) {
35
+ try {
36
+ const prompt = t("[me]") + ": "
37
+ const invalidPrompt = Logger.style(t("[me invalid]", t("[me]")), { bgColor: "yellow" }) + ": "
38
+ const choice = await select({
39
+ title: t("Select demo:"),
40
+ prompt: t("[me]") + ": ",
41
+ invalidPrompt: Logger.style(t("[me]"), { bgColor: "yellow" }) + ": ",
42
+ options: menuOptions.map(t),
43
+ console,
44
+ })
45
+ switch (choice.index) {
46
+ case 0:
47
+ const lang = await runLanguage(t, ask, console, prompt, invalidPrompt)
48
+ t = createT(lang)
49
+ break
50
+ case 1:
51
+ await runRegistration(t, ask, console, prompt, invalidPrompt)
52
+ break
53
+ case 2:
54
+ await runExchange(t, ask, console, prompt, invalidPrompt)
55
+ break
56
+ case 3:
57
+ await runTopup(t, ask, console, prompt, invalidPrompt)
58
+ break
59
+ default:
60
+ console.warn(t("! Invalid choice."))
61
+ }
62
+ } catch (err) {
63
+ if (err instanceof CancelError) {
64
+ console.warn("\n" + t("⨉ Cancelled."))
65
+ continue
66
+ }
67
+ throw err
68
+ }
69
+ }
70
+ }
71
+
72
+ main()
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Simple registration form using stdin.
3
+ *
4
+ * Fields: username, password, confirm, emailOrTel
5
+ *
6
+ * Controls:
7
+ * - Input "0" -> cancel (onCancel)
8
+ * - Empty input on final prompt -> submit (onSubmit)
9
+ */
10
+ import { CancelError } from "@nan0web/ui-cli"
11
+
12
+ /**
13
+ * Main registration flow
14
+ * @todo add proper jsdoc to make autocomplete work for input as result of ask, console, t.
15
+ */
16
+ export async function runRegistration(t, ask, console) {
17
+ console.info("\n=== " + t("Registration Form") + " ===")
18
+ const data = {}
19
+
20
+ // username
21
+ {
22
+ const input = await ask(t("Username") + ": ", true)
23
+ if (input.cancelled) throw new CancelError()
24
+ data.username = input.value
25
+ }
26
+ // password
27
+ {
28
+ const input = await ask(
29
+ t("Password (min 4 chars)") + ": ",
30
+ (input) => input.value.length < 4,
31
+ t("! Password must be at least 4 characters") + ": "
32
+ )
33
+ if (input.cancelled) throw new CancelError()
34
+ data.password = input.value
35
+ }
36
+ // confirm password
37
+ {
38
+ await ask(t("Confirm Password") + ": ", (input) => {
39
+ if (input.value !== data.password) {
40
+ console.error(t("Passwords do not match. Try again."))
41
+ return true
42
+ }
43
+ return false
44
+ })
45
+ }
46
+ // email or telephone
47
+ {
48
+ const input = await ask(t("Email or Telephone") + ": ", true)
49
+ if (input.cancelled) throw new CancelError()
50
+ data.emailOrTel = input.value
51
+ }
52
+ // final confirmation – empty line submits, "0" cancels
53
+ {
54
+ await ask(t("Press ENTER to submit, type 0 to cancel") + ": ", i => "" != i.value)
55
+ }
56
+ console.success("\n" + t("Form submitted successfully!"))
57
+ console.info(JSON.stringify({ ...data, password: "****" }, null, 2))
58
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Simple telephone top‑up form.
3
+ *
4
+ * Fields: number, amount, currency
5
+ *
6
+ * Controls:
7
+ * - Input "0" on any prompt to cancel.
8
+ * - Empty input on final confirmation submits.
9
+ *
10
+ * Validation:
11
+ * - Phone number must contain only digits and be 7‑15 characters long.
12
+ */
13
+ import { CancelError } from "@nan0web/ui-cli"
14
+
15
+ const rates = {
16
+ USD: 1,
17
+ EUR: 0.9,
18
+ UAH: 27,
19
+ }
20
+
21
+ /** Main top‑up flow */
22
+ export async function runTopup(t, ask, console) {
23
+ /** Choose currency */
24
+ async function chooseCurrency(t) {
25
+ const list = Object.keys(rates)
26
+ console.info(t("Currency options:"))
27
+ list.forEach((c, i) => console.info(` ${i + 1}) ${c}`))
28
+ const input = await ask(
29
+ `${t("Select currency")} (1-${list.length}): `,
30
+ input => {
31
+ input.idx = Number(input.value) - 1
32
+ return ! (input.idx >= 0 && input.idx < list.length)
33
+ },
34
+ [t("Invalid choice."), t("Try again") + ":", ""].join(" ")
35
+ )
36
+ return list[input.idx] ?? null
37
+ }
38
+
39
+ /** Validate phone number */
40
+ function isValidPhone(number) {
41
+ return /^[0-9]{7,15}$/.test(number)
42
+ }
43
+
44
+ console.info("\n=== " + t("Top‑up Telephone") + " ===")
45
+ const numberInp = await ask(
46
+ t("Phone Number") + ": ",
47
+ input => !isValidPhone(input.value),
48
+ t("Invalid phone number. Use digits only, 7‑15 characters.") + ": "
49
+ )
50
+ if (numberInp.cancelled) throw new CancelError()
51
+ const number = numberInp.value
52
+ const amountInp = await ask(
53
+ t("Top‑up Amount") + ": ",
54
+ input => isNaN(input.value) || Number(input.value) <= 0 || Number(input.value) > 1_000_000,
55
+ t("Amount must be a positive number below 1 million.") + ": "
56
+ )
57
+ if (amountInp.cancelled) throw new CancelError()
58
+ const amount = Number(amountInp.value)
59
+ const currency = await chooseCurrency(t)
60
+ if (!currency) throw new CancelError()
61
+ console.success(`\n${t("Top‑up of")} ${amount} ${currency} ${t("to")} ${number} ${t("scheduled.")}\n`)
62
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Represents command options with default values.
3
+ * Provides utilities for handling command line options.
4
+ */
5
+ class CommandOptions {
6
+ /**
7
+ * Default option values.
8
+ * @type {object}
9
+ * @property {boolean} help - Whether help is requested
10
+ * @property {string} cwd - Current working directory
11
+ */
12
+ static DEFAULTS = {
13
+ help: false,
14
+ cwd: "",
15
+ }
16
+
17
+ /** @type {boolean} Whether help is requested */
18
+ help
19
+
20
+ /** @type {string} Current working directory */
21
+ cwd
22
+
23
+ /**
24
+ * Creates a new CommandOptions instance.
25
+ * @param {object} props - The properties for command options
26
+ * @param {boolean} [props.help=false] - Whether help is requested
27
+ * @param {string} [props.cwd=""] - Current working directory
28
+ */
29
+ constructor(props = {}) {
30
+ const {
31
+ help = CommandOptions.DEFAULTS.help,
32
+ cwd = CommandOptions.DEFAULTS.cwd,
33
+ } = props
34
+ this.help = Boolean(help)
35
+ this.cwd = String(cwd)
36
+ }
37
+
38
+ /** @type {Record<string, any>} */
39
+ get DEFAULTS() {
40
+ return /** @type {typeof CommandOptions} */ (this.constructor).DEFAULTS
41
+ }
42
+
43
+ /**
44
+ * Checks if all options have their default values.
45
+ * @returns {boolean} True if all options are at their default values, false otherwise
46
+ */
47
+ get empty() {
48
+ return Object.entries(this).every(
49
+ ([key, value]) => CommandOptions.DEFAULTS[key] === value
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Converts the options to a string representation.
55
+ * @returns {string} String representation of the options or "<no options>" if none set
56
+ */
57
+ toString() {
58
+ const opts = Object.entries(this).map(([key, value]) => {
59
+ if (this.DEFAULTS[key] === value) return false
60
+ const prefix = true === value ? "--" : "-"
61
+ return `${prefix}${key}${value ? ` ${value}` : ""}`
62
+ }).filter(Boolean)
63
+ if (0 === opts.length) return "<no options>"
64
+ return opts.join(" ")
65
+ }
66
+
67
+ /**
68
+ * Creates a CommandOptions instance from the given props.
69
+ * @param {CommandOptions|object} props - The properties to create from
70
+ * @returns {CommandOptions} A CommandOptions instance
71
+ */
72
+ static from(props) {
73
+ if (props instanceof CommandOptions) return props
74
+ return new this(props)
75
+ }
76
+ }
77
+
78
+ export default CommandOptions
@@ -0,0 +1,9 @@
1
+ import { CommandMessage } from "@nan0web/co"
2
+ import CommandOptions from "./Options.js"
3
+
4
+ export { CommandMessage, CommandOptions }
5
+
6
+ export default {
7
+ Message: CommandMessage,
8
+ Options: CommandOptions,
9
+ }
@@ -0,0 +1,129 @@
1
+ import { typeOf } from "@nan0web/types"
2
+ import { CommandMessage } from "../Command/index.js"
3
+ import UI from "./UI.js"
4
+
5
+ /** @typedef {Function} CommandFn */
6
+
7
+ /**
8
+ * Abstract base class for all apps.
9
+ * Each app processes input commands and produces output.
10
+ */
11
+ export default class CoreApp {
12
+ /** @type {string} App name */
13
+ name
14
+
15
+ /** @type {Map<string, CommandFn>} Registered command handlers */
16
+ commands
17
+
18
+ /** @type {object} App state */
19
+ state
20
+
21
+ /** @type {CommandMessage} Starting command parsed from argv */
22
+ startCommand
23
+
24
+ /**
25
+ * Creates a new CoreApp instance.
26
+ * @param {object} props - CoreApp properties
27
+ * @param {string} [props.name="CoreApp"] - App name
28
+ * @param {object} [props.state={}] - Initial state object
29
+ * @param {string[]} [props.argv=[]] - Command line arguments to parse
30
+ */
31
+ constructor(props = {}) {
32
+ const {
33
+ name = "CoreApp",
34
+ state = {},
35
+ argv = [],
36
+ } = props
37
+ this.name = String(name)
38
+ this.state = state
39
+ this.commands = new Map()
40
+ this.startCommand = CommandMessage.parse(argv)
41
+ }
42
+
43
+ /**
44
+ * Sets app state.
45
+ * @param {string|object} state - State key or object with multiple keys
46
+ * @param {any} [value] - State value if state is a string key
47
+ * @returns {object} Updated state
48
+ */
49
+ set(state, value) {
50
+ if ("string" === typeof state) {
51
+ this.state[state] = value
52
+ } else {
53
+ Object.assign(this.state, state)
54
+ }
55
+ // @todo save state
56
+ return this.state
57
+ }
58
+
59
+ /**
60
+ * Register a command handler.
61
+ * @param {string} commandName - Name of the command to register
62
+ * @param {Function} handler - async function or sync function that accepts params and returns output
63
+ */
64
+ registerCommand(commandName, handler) {
65
+ if (!typeOf(Function)(handler)) {
66
+ throw new TypeError("Handler must be a function")
67
+ }
68
+ this.commands.set(commandName, handler)
69
+ }
70
+
71
+ /**
72
+ * Returns a string representation of the app.
73
+ * @returns {string} String representation including name and state
74
+ */
75
+ toString() {
76
+ return `${this.constructor.name}(name=${this.name}, state=${JSON.stringify(this.state)})`
77
+ }
78
+
79
+ /**
80
+ * Process a command message.
81
+ * @param {CommandMessage} commandMessage - Command to process
82
+ * @param {UI} ui - UI instance to use for rendering
83
+ * @returns {Promise<any>} Output of the command
84
+ * @throws {Error} If the command is not registered
85
+ */
86
+ async processCommand(commandMessage, ui) {
87
+ const cmd = commandMessage.args[0]
88
+ const handler = this.commands.get(cmd)
89
+ if (!handler) {
90
+ throw new Error([
91
+ "Unknown command", ": ",
92
+ cmd, "\n",
93
+ "Available commands", ": ",
94
+ [...this.commands.keys()].join(", "),
95
+ ].join(""))
96
+ }
97
+ const Class = /** @type {typeof CommandMessage} */ (commandMessage.constructor)
98
+ const command = Class.from({
99
+ args: commandMessage.args.slice(1),
100
+ opts: commandMessage.opts,
101
+ })
102
+ return await handler.apply(this, [command, ui])
103
+ }
104
+
105
+ /**
106
+ * Process an array of command messages sequentially.
107
+ * @param {CommandMessage[]} commandMessages - Array of commands to process
108
+ * @param {UI} ui - UI instance to use for rendering
109
+ * @returns {Promise<any[]>} Array of command outputs
110
+ */
111
+ async processCommands(commandMessages, ui) {
112
+ const results = []
113
+ for (const cmdMsg of commandMessages) {
114
+ const result = await this.processCommand(cmdMsg, ui)
115
+ results.push(result)
116
+ }
117
+ return results
118
+ }
119
+
120
+ /**
121
+ * Select a command to run. Must be implemented by subclasses.
122
+ * @param {UI} ui - UI instance for interaction
123
+ * @returns {Promise<string>} Command name to execute
124
+ * @throws {Error} Always thrown as this method must be implemented by subclasses
125
+ */
126
+ async selectCommand(ui) {
127
+ throw new Error("Not implemented, must be implemented by subclass")
128
+ }
129
+ }