shutro-erp 0.2.1 → 0.3.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 (229) hide show
  1. package/README.md +17 -10
  2. package/dist/account_portal/.kosh/bindings.d.ts +22 -0
  3. package/dist/account_portal/.kosh/bindings.js +15 -0
  4. package/dist/account_portal/.kosh/schema.d.ts +1350 -0
  5. package/dist/account_portal/index.d.ts +3 -0
  6. package/dist/account_portal/index.js +73 -0
  7. package/dist/account_portal/module.d.ts +5983 -0
  8. package/dist/account_portal/module.js +18 -0
  9. package/dist/accounting/.kosh/bindings.js +1 -1
  10. package/dist/accounting/.kosh/schema.d.ts +142 -9
  11. package/dist/accounting/chart.d.ts +40 -8
  12. package/dist/accounting/chart.js +124 -30
  13. package/dist/accounting/closing.d.ts +6 -1
  14. package/dist/accounting/closing.js +5 -1
  15. package/dist/accounting/demo.js +6 -5
  16. package/dist/accounting/index.d.ts +3 -3
  17. package/dist/accounting/index.js +200 -23
  18. package/dist/accounting/module.d.ts +3326 -1086
  19. package/dist/accounting/module.js +408 -66
  20. package/dist/accounting/payments.d.ts +25 -0
  21. package/dist/accounting/payments.js +96 -4
  22. package/dist/accounting/web/RegisterPayment.d.ts +3 -2
  23. package/dist/accounting/web/RegisterPayment.js +39 -6
  24. package/dist/accounting/web/SettingsPage.js +5 -1
  25. package/dist/accounting/web/messages.js +10 -0
  26. package/dist/cli/init.js +172 -48
  27. package/dist/cli/launch.d.ts +16 -0
  28. package/dist/cli/launch.js +138 -0
  29. package/dist/cli/prompt.d.ts +35 -0
  30. package/dist/cli/prompt.js +46 -0
  31. package/dist/cli/templates.d.ts +11 -3
  32. package/dist/cli/templates.js +32 -7
  33. package/dist/cli/tui.d.ts +7 -0
  34. package/dist/cli/tui.js +254 -0
  35. package/dist/cli/ui.d.ts +25 -5
  36. package/dist/cli/ui.js +49 -9
  37. package/dist/contacts/.kosh/bindings.js +1 -1
  38. package/dist/contacts/.kosh/schema.d.ts +3 -1
  39. package/dist/contacts/index.js +24 -1
  40. package/dist/contacts/module.d.ts +42 -0
  41. package/dist/contacts/module.js +3 -1
  42. package/dist/import/convert.d.ts +55 -0
  43. package/dist/import/convert.js +224 -0
  44. package/dist/import/csv.d.ts +28 -0
  45. package/dist/import/csv.js +115 -0
  46. package/dist/import/index.d.ts +33 -0
  47. package/dist/import/index.js +60 -0
  48. package/dist/import/mapping.d.ts +15 -0
  49. package/dist/import/mapping.js +33 -0
  50. package/dist/import/match.d.ts +24 -0
  51. package/dist/import/match.js +60 -0
  52. package/dist/import/models.d.ts +2 -0
  53. package/dist/import/models.js +102 -0
  54. package/dist/import/routes.d.ts +13 -0
  55. package/dist/import/routes.js +392 -0
  56. package/dist/import/run.d.ts +131 -0
  57. package/dist/import/run.js +568 -0
  58. package/dist/import/service.d.ts +153 -0
  59. package/dist/import/service.js +367 -0
  60. package/dist/import/web/ImportButton.d.ts +7 -0
  61. package/dist/import/web/ImportButton.js +31 -0
  62. package/dist/import/web/ImportWizard.d.ts +75 -0
  63. package/dist/import/web/ImportWizard.js +217 -0
  64. package/dist/import/web/index.d.ts +13 -0
  65. package/dist/import/web/index.js +18 -0
  66. package/dist/import/web/messages.d.ts +2 -0
  67. package/dist/import/web/messages.js +139 -0
  68. package/dist/import/worker.d.ts +3 -0
  69. package/dist/import/worker.js +52 -0
  70. package/dist/import/xlsx.d.ts +20 -0
  71. package/dist/import/xlsx.js +192 -0
  72. package/dist/l10n_bd/.kosh/bindings.d.ts +22 -0
  73. package/dist/l10n_bd/.kosh/bindings.js +15 -0
  74. package/dist/l10n_bd/.kosh/schema.d.ts +1281 -0
  75. package/dist/l10n_bd/documents.d.ts +8 -0
  76. package/dist/l10n_bd/documents.js +183 -0
  77. package/dist/l10n_bd/index.d.ts +8 -0
  78. package/dist/l10n_bd/index.js +156 -0
  79. package/dist/l10n_bd/module.d.ts +5705 -0
  80. package/dist/l10n_bd/module.js +163 -0
  81. package/dist/l10n_bd/mushak.d.ts +67 -0
  82. package/dist/l10n_bd/mushak.js +139 -0
  83. package/dist/l10n_bd/routes.d.ts +3 -0
  84. package/dist/l10n_bd/routes.js +205 -0
  85. package/dist/l10n_bd/web/MushakPage.d.ts +7 -0
  86. package/dist/l10n_bd/web/MushakPage.js +53 -0
  87. package/dist/l10n_bd/web/index.d.ts +13 -0
  88. package/dist/l10n_bd/web/index.js +18 -0
  89. package/dist/l10n_bd/web/messages.d.ts +2 -0
  90. package/dist/l10n_bd/web/messages.js +101 -0
  91. package/dist/l10n_in/.kosh/bindings.d.ts +22 -0
  92. package/dist/l10n_in/.kosh/bindings.js +15 -0
  93. package/dist/l10n_in/.kosh/schema.d.ts +1343 -0
  94. package/dist/l10n_in/documents.d.ts +8 -0
  95. package/dist/l10n_in/documents.js +114 -0
  96. package/dist/l10n_in/einvoice.d.ts +253 -0
  97. package/dist/l10n_in/einvoice.js +485 -0
  98. package/dist/l10n_in/gstr.d.ts +109 -0
  99. package/dist/l10n_in/gstr.js +430 -0
  100. package/dist/l10n_in/index.d.ts +20 -0
  101. package/dist/l10n_in/index.js +232 -0
  102. package/dist/l10n_in/module.d.ts +7823 -0
  103. package/dist/l10n_in/module.js +427 -0
  104. package/dist/l10n_in/routes-einvoice.d.ts +15 -0
  105. package/dist/l10n_in/routes-einvoice.js +124 -0
  106. package/dist/l10n_in/routes.d.ts +17 -0
  107. package/dist/l10n_in/routes.js +282 -0
  108. package/dist/l10n_in/web/EInvoicePanel.d.ts +8 -0
  109. package/dist/l10n_in/web/EInvoicePanel.js +63 -0
  110. package/dist/l10n_in/web/GstPage.d.ts +7 -0
  111. package/dist/l10n_in/web/GstPage.js +57 -0
  112. package/dist/l10n_in/web/GstSettings.d.ts +10 -0
  113. package/dist/l10n_in/web/GstSettings.js +36 -0
  114. package/dist/l10n_in/web/index.d.ts +15 -0
  115. package/dist/l10n_in/web/index.js +22 -0
  116. package/dist/l10n_in/web/messages.d.ts +2 -0
  117. package/dist/l10n_in/web/messages.js +166 -0
  118. package/dist/modules-sdk/demo.d.ts +2 -0
  119. package/dist/modules-sdk/demo.js +7 -3
  120. package/dist/modules-sdk/index.d.ts +2 -2
  121. package/dist/modules-sdk/index.js +1 -1
  122. package/dist/modules-sdk/manifest.d.ts +132 -0
  123. package/dist/modules-sdk/manifest.js +22 -0
  124. package/dist/notify/inbox.js +21 -0
  125. package/dist/notify/messages.js +4 -0
  126. package/dist/notify/models.d.ts +1 -1
  127. package/dist/notify/models.js +2 -0
  128. package/dist/notify/web/messages.js +4 -0
  129. package/dist/portal/.kosh/bindings.d.ts +22 -0
  130. package/dist/portal/.kosh/bindings.js +15 -0
  131. package/dist/portal/.kosh/schema.d.ts +346 -0
  132. package/dist/portal/announce.d.ts +16 -0
  133. package/dist/portal/announce.js +117 -0
  134. package/dist/portal/documents.d.ts +17 -0
  135. package/dist/portal/documents.js +61 -0
  136. package/dist/portal/index.d.ts +20 -0
  137. package/dist/portal/index.js +65 -0
  138. package/dist/portal/module.d.ts +1405 -0
  139. package/dist/portal/module.js +84 -0
  140. package/dist/portal/routes-documents.d.ts +2 -0
  141. package/dist/portal/routes-documents.js +391 -0
  142. package/dist/portal/routes.d.ts +13 -0
  143. package/dist/portal/routes.js +313 -0
  144. package/dist/portal/rules.d.ts +11 -0
  145. package/dist/portal/rules.js +25 -0
  146. package/dist/portal/web/PortalAccess.d.ts +8 -0
  147. package/dist/portal/web/PortalAccess.js +66 -0
  148. package/dist/portal/web/index.d.ts +12 -0
  149. package/dist/portal/web/index.js +17 -0
  150. package/dist/portal/web/messages.d.ts +2 -0
  151. package/dist/portal/web/messages.js +33 -0
  152. package/dist/product/.kosh/bindings.js +1 -1
  153. package/dist/product/.kosh/schema.d.ts +12 -3
  154. package/dist/product/demo.js +6 -5
  155. package/dist/product/index.d.ts +1 -0
  156. package/dist/product/index.js +17 -4
  157. package/dist/product/module.d.ts +346 -14
  158. package/dist/product/module.js +48 -18
  159. package/dist/product/taxes.d.ts +59 -0
  160. package/dist/product/taxes.js +61 -0
  161. package/dist/project/.kosh/bindings.js +1 -1
  162. package/dist/project/.kosh/schema.d.ts +3 -1
  163. package/dist/project/module.d.ts +20 -0
  164. package/dist/project_portal/.kosh/bindings.d.ts +22 -0
  165. package/dist/project_portal/.kosh/bindings.js +15 -0
  166. package/dist/project_portal/.kosh/schema.d.ts +671 -0
  167. package/dist/project_portal/index.d.ts +9 -0
  168. package/dist/project_portal/index.js +42 -0
  169. package/dist/project_portal/module.d.ts +2142 -0
  170. package/dist/project_portal/module.js +22 -0
  171. package/dist/purchase/.kosh/bindings.js +1 -1
  172. package/dist/purchase/.kosh/schema.d.ts +15 -4
  173. package/dist/purchase/demo.js +3 -3
  174. package/dist/purchase/index.js +1 -1
  175. package/dist/purchase/module.d.ts +232 -8
  176. package/dist/purchase/module.js +51 -9
  177. package/dist/purchase_account/.kosh/bindings.js +1 -1
  178. package/dist/purchase_account/.kosh/schema.d.ts +144 -10
  179. package/dist/purchase_account/module.d.ts +2008 -641
  180. package/dist/purchase_account/module.js +2 -2
  181. package/dist/purchase_portal/.kosh/bindings.d.ts +22 -0
  182. package/dist/purchase_portal/.kosh/bindings.js +15 -0
  183. package/dist/purchase_portal/.kosh/schema.d.ts +822 -0
  184. package/dist/purchase_portal/index.d.ts +9 -0
  185. package/dist/purchase_portal/index.js +53 -0
  186. package/dist/purchase_portal/module.d.ts +2758 -0
  187. package/dist/purchase_portal/module.js +18 -0
  188. package/dist/reporting/documents.js +20 -3
  189. package/dist/reporting/index.d.ts +1 -0
  190. package/dist/reporting/index.js +1 -0
  191. package/dist/reporting/routes-documents.js +9 -2
  192. package/dist/reporting/words.d.ts +11 -0
  193. package/dist/reporting/words.js +229 -0
  194. package/dist/sale_account/.kosh/bindings.js +1 -1
  195. package/dist/sale_account/.kosh/schema.d.ts +144 -10
  196. package/dist/sale_account/module.d.ts +2008 -641
  197. package/dist/sale_account/module.js +3 -3
  198. package/dist/sale_portal/.kosh/bindings.d.ts +22 -0
  199. package/dist/sale_portal/.kosh/bindings.js +15 -0
  200. package/dist/sale_portal/.kosh/schema.d.ts +736 -0
  201. package/dist/sale_portal/index.d.ts +9 -0
  202. package/dist/sale_portal/index.js +71 -0
  203. package/dist/sale_portal/module.d.ts +2718 -0
  204. package/dist/sale_portal/module.js +19 -0
  205. package/dist/sale_project/.kosh/bindings.js +1 -1
  206. package/dist/sale_project/.kosh/schema.d.ts +15 -4
  207. package/dist/sale_project/module.d.ts +390 -10
  208. package/dist/sales/.kosh/bindings.js +1 -1
  209. package/dist/sales/.kosh/schema.d.ts +15 -4
  210. package/dist/sales/demo.js +3 -3
  211. package/dist/sales/index.js +67 -1
  212. package/dist/sales/module.d.ts +232 -8
  213. package/dist/sales/module.js +51 -12
  214. package/dist/server/auth/base.js +2 -0
  215. package/dist/server/auth/service.d.ts +2 -0
  216. package/dist/server/auth/service.js +7 -1
  217. package/dist/server/index.d.ts +1 -1
  218. package/dist/server/index.js +1 -1
  219. package/dist/server/server.js +26 -1
  220. package/dist/ui/api.d.ts +2 -0
  221. package/dist/ui/app.js +1 -1
  222. package/dist/ui/context.d.ts +3 -1
  223. package/dist/ui/context.js +2 -2
  224. package/dist/ui/index.d.ts +1 -1
  225. package/dist/ui/index.js +1 -1
  226. package/dist/ui/lib/format.d.ts +2 -0
  227. package/dist/ui/lib/format.js +3 -1
  228. package/dist/ui/views/FormView.js +19 -6
  229. package/package.json +50 -2
package/dist/cli/init.js CHANGED
@@ -15,11 +15,13 @@ import { basename, dirname, join, resolve } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { parseArgs } from 'node:util';
17
17
  import { base, createServer, DEMO_PASSWORD, FRAMEWORK_TABLES, installBase, installDemo, KOSH_VERSION, migrate, pg, recordInstalledApps, } from '../server/index.js';
18
+ import { launch } from './launch.js';
18
19
  import { seed } from './project.js';
19
- import { defaultsPrompter, terminalPrompter } from './prompt.js';
20
+ import { choose, chooseMany, defaultsPrompter, terminalPrompter } from './prompt.js';
20
21
  import * as templates from './templates.js';
21
- import { APPS, bridgesFor, SELECTABLE_APPS } from './templates.js';
22
- import { createUi, PLAIN_TERMINAL } from './ui.js';
22
+ import { APPS, bridgesFor, COUNTRY_NAMES, LOCALIZED_COUNTRIES, SELECTABLE_APPS, } from './templates.js';
23
+ import { CancelledError, interactivePrompter } from './tui.js';
24
+ import { createUi, PLAIN_TERMINAL, railed } from './ui.js';
23
25
  /**
24
26
  * The version of the `shutro-erp` package this CLI runs from: new projects depend on it. In the workspace (no such
25
27
  * package around the CLI) the framework version stands in.
@@ -48,6 +50,8 @@ Options (each also answers its question; with --yes the rest take their defaults
48
50
  --docker-db start PostgreSQL in Docker (compose.yaml) instead
49
51
  --create-database create the database when it does not exist
50
52
  --company <name> the first company --currency <code> its currency (ISO 4217)
53
+ --country <code> the company's country (ISO 3166): ${LOCALIZED_COUNTRIES.join(', ')} install its localization
54
+ with Accounting (default: BD for BDT, IN for INR, none otherwise; "none" for none)
51
55
  --lang <en|bn> language --tz <zone> time zone (IANA)
52
56
  --admin-login <login> --admin-name <name> --admin-email <email>
53
57
  --admin-password <pw> (or KOSH_ADMIN_PASSWORD; at least 12 characters)
@@ -55,10 +59,13 @@ Options (each also answers its question; with --yes the rest take their defaults
55
59
  --storage-dir <dir> attachments in a folder (default ./var/attachments)
56
60
  --s3-endpoint <url> --s3-bucket <name> --s3-access-key <key> --s3-secret-key <secret> [--s3-region <r>]
57
61
  --valkey-url <url> optional: rate limits and caches shared across servers
58
- --port <port> the API's port (default 3000)
62
+ --port <port> the API's port (default 3000, or the next free one)
63
+ --web-port <port> the back-office's port in development (default 5173, or the next free one)
59
64
  --demo load demo data: a sample company with contacts, products, orders, invoices and
60
65
  projects, made by demo users (dina, rahim, karim, sumi; one shared password)
61
66
  --no-2fa skip the authenticator enrolment (administrator rights wait for it)
67
+ --no-start do not install, start and open the installation at the end (in a terminal it
68
+ installs the dependencies, starts the API and the back-office, opens the browser)
62
69
  --yes do not ask; use flags and defaults
63
70
  `;
64
71
  const dockerAvailable = () => spawnSync('docker', ['version', '--format', '{{.Server.Version}}'], { encoding: 'utf8' })
@@ -228,9 +235,19 @@ async function ask(prompter, flags, cwd, positional, ui) {
228
235
  if (flag('database-url') === undefined) {
229
236
  const docker = flags['docker-db'] === true ||
230
237
  (dockerAvailable() && flags['yes'] !== true
231
- ? (await q(null, 'PostgreSQL: (1) a server I already have, or (2) start one in Docker for me', '2', {
232
- validate: (v) => (v === '1' || v === '2' ? null : '1 or 2'),
233
- })) === '2'
238
+ ? (await choose(prompter, {
239
+ message: 'Which PostgreSQL?',
240
+ fallback: 'PostgreSQL: (1) a server I already have, or (2) start one in Docker for me',
241
+ choices: [
242
+ { value: 'server', label: 'A server I already have', hint: 'host, port, user' },
243
+ {
244
+ value: 'docker',
245
+ label: 'Start one in Docker for me',
246
+ hint: 'PostgreSQL 18, its own volume',
247
+ },
248
+ ],
249
+ default: 'docker',
250
+ })) === 'docker'
234
251
  : false);
235
252
  if (docker) {
236
253
  if (!dockerAvailable())
@@ -312,9 +329,41 @@ async function ask(prompter, flags, cwd, positional, ui) {
312
329
  const currency = (await q('currency', 'Currency (ISO code)', 'BDT', {
313
330
  validate: (v) => (/^[A-Za-z]{3}$/.test(v) ? null : 'Three letters, e.g. BDT, USD, EUR'),
314
331
  })).toUpperCase();
315
- const lang = await q('lang', 'Language (en, bn)', 'en', {
316
- validate: (v) => (['en', 'bn'].includes(v) ? null : 'en or bn'),
317
- });
332
+ const countryFlag = flag('country');
333
+ if (countryFlag !== undefined && !/^([A-Za-z]{2}|none)$/i.test(countryFlag))
334
+ throw new Error('--country: Two letters, e.g. BD, or none');
335
+ const countryAnswer = (countryFlag ??
336
+ (await choose(prompter, {
337
+ message: 'Country (taxes, chart of accounts, fiscal year)',
338
+ fallback: `Country (ISO code; ${LOCALIZED_COUNTRIES.join(', ')} set up taxes and the chart; "none" for none)`,
339
+ choices: [
340
+ ...LOCALIZED_COUNTRIES.map((c) => ({
341
+ value: c,
342
+ label: COUNTRY_NAMES[c] ?? c,
343
+ hint: 'its taxes, chart and returns',
344
+ })),
345
+ {
346
+ value: 'none',
347
+ label: 'Another country',
348
+ hint: 'the generic chart; taxes are yours to add',
349
+ },
350
+ ],
351
+ default: currency === 'BDT' ? 'BD' : currency === 'INR' ? 'IN' : 'none',
352
+ }))).toUpperCase();
353
+ const country = countryAnswer === 'NONE' ? '' : countryAnswer;
354
+ const langFlag = flag('lang');
355
+ if (langFlag !== undefined && !['en', 'bn'].includes(langFlag))
356
+ throw new Error('--lang: en or bn');
357
+ const lang = langFlag ??
358
+ (await choose(prompter, {
359
+ message: 'Language',
360
+ fallback: 'Language (en, bn)',
361
+ choices: [
362
+ { value: 'en', label: 'English' },
363
+ { value: 'bn', label: 'বাংলা', hint: 'Bangla' },
364
+ ],
365
+ default: 'en',
366
+ }));
318
367
  const tz = await q('tz', 'Time zone', Intl.DateTimeFormat().resolvedOptions().timeZone, {
319
368
  validate: (v) => {
320
369
  try {
@@ -355,36 +404,43 @@ async function ask(prompter, flags, cwd, positional, ui) {
355
404
  ui.warn('The passwords differ; try again.');
356
405
  }
357
406
  ui.step('apps', 'Apps', 'All are selected; remove the ones you do not need');
358
- for (const key of SELECTABLE_APPS) {
359
- const app = APPS[key];
360
- ui.write(` ${ui.terminal.emoji ? app.icon : ui.style('cyan', '•')} ${ui.style('bold', key.padEnd(12))} ${ui.style('gray', app.label)}\n`);
361
- }
362
- const appList = await q('apps', 'Apps to install (comma-separated)', SELECTABLE_APPS.join(','), {
363
- validate: (v) => {
364
- const unknown = v
365
- .split(',')
366
- .map((s) => s.trim())
367
- .filter((s) => s !== '' && !(s in APPS));
368
- return unknown.length === 0 ? null : `Unknown: ${unknown.join(', ')}`;
369
- },
370
- });
371
- const chosen = [
372
- ...new Set(appList
373
- .split(',')
374
- .map((s) => s.trim())
375
- .filter((s) => s in APPS)),
376
- ];
377
- // The bridges of the chosen apps come along (RFC 0008 §5.1).
378
- const apps = [...chosen, ...bridgesFor(chosen).filter((b) => !chosen.includes(b))];
407
+ const appsFlag = flag('apps');
408
+ if (appsFlag === undefined && prompter.multiselect === undefined)
409
+ for (const key of SELECTABLE_APPS) {
410
+ const app = APPS[key];
411
+ ui.write(` ${ui.terminal.emoji ? app.icon : ui.style('cyan', '•')} ${ui.style('bold', key.padEnd(12))} ${ui.style('gray', app.label)}\n`);
412
+ }
413
+ const unknownApps = (appsFlag ?? '')
414
+ .split(',')
415
+ .map((s) => s.trim())
416
+ .filter((s) => s !== '' && !(s in APPS));
417
+ if (unknownApps.length > 0)
418
+ throw new Error(`--apps: Unknown: ${unknownApps.join(', ')}`);
419
+ const appList = appsFlag?.split(',').map((s) => s.trim()) ??
420
+ (await chooseMany(prompter, {
421
+ message: 'Apps to install',
422
+ fallback: 'Apps to install (comma-separated)',
423
+ choices: SELECTABLE_APPS.map((key) => ({ value: key, label: key, hint: APPS[key].label })),
424
+ defaults: SELECTABLE_APPS,
425
+ }));
426
+ const chosen = [...new Set(appList.filter((s) => s in APPS))];
427
+ // The bridges of the chosen apps come along (RFC 0008 §5.1), and the country's localization (RFC 0016).
428
+ const apps = [...chosen, ...bridgesFor(chosen, country).filter((b) => !chosen.includes(b))];
379
429
  const demo = flags['demo'] === true ||
380
430
  (await prompter.confirm(' Load demo data (a sample company with orders, invoices and projects; not for production)?', false));
381
431
  ui.step('storage', 'File storage', 'Where attachments and generated documents are kept');
382
432
  let storage;
383
433
  const s3 = flag('s3-endpoint') !== undefined ||
384
434
  (flag('storage-dir') === undefined &&
385
- (await q(null, 'Store files in (1) a folder or (2) S3-compatible storage', '1', {
386
- validate: (v) => (v === '1' || v === '2' ? null : '1 or 2'),
387
- })) === '2');
435
+ (await choose(prompter, {
436
+ message: 'Where are files kept?',
437
+ fallback: 'Store files in (1) a folder or (2) S3-compatible storage',
438
+ choices: [
439
+ { value: 'dir', label: 'A folder on this server', hint: './var/attachments' },
440
+ { value: 's3', label: 'S3-compatible storage', hint: 'AWS S3, MinIO, SeaweedFS, R2' },
441
+ ],
442
+ default: 'dir',
443
+ })) === 's3');
388
444
  if (s3)
389
445
  storage = {
390
446
  kind: 's3',
@@ -407,17 +463,20 @@ async function ask(prompter, flags, cwd, positional, ui) {
407
463
  };
408
464
  else
409
465
  storage = { kind: 'dir', dir: await q('storage-dir', 'Folder', './var/attachments') };
410
- ui.step('server', 'Server', 'The API port and an optional shared cache');
411
- const valkeyUrl = await q('valkey-url', 'Valkey/Redis URL (optional, Enter to skip)', '');
412
- const port = Number(await q('port', 'API port', '3000', {
413
- validate: (v) => /^\d+$/.test(v) && Number(v) > 0 && Number(v) < 65536 ? null : 'A port number',
466
+ ui.step('server', 'Server', 'The ports of the API and the back-office, and an optional shared cache');
467
+ const portNumber = (v) => /^\d+$/.test(v) && Number(v) > 0 && Number(v) < 65536 ? null : 'A port number (1–65535)';
468
+ const port = Number(await q('port', 'API port', String(await freePort(3000)), { validate: portNumber }));
469
+ const webPort = Number(await q('web-port', 'Back-office (web) port', String(await freePort(port === 5173 ? 5174 : 5173)), {
470
+ validate: (v) => portNumber(v) ?? (Number(v) === port ? 'Another port than the API' : null),
414
471
  }));
472
+ const valkeyUrl = await q('valkey-url', 'Valkey/Redis URL (optional, Enter to skip)', '');
415
473
  return {
416
474
  dir,
417
475
  name,
418
476
  databaseUrl,
419
477
  company,
420
478
  currency,
479
+ country,
421
480
  lang,
422
481
  tz,
423
482
  admin: { login, name: adminName, email, password: password },
@@ -426,6 +485,7 @@ async function ask(prompter, flags, cwd, positional, ui) {
426
485
  storage,
427
486
  valkeyUrl,
428
487
  port,
488
+ webPort,
429
489
  postgres,
430
490
  dockerDb,
431
491
  vapid: (await import('../notify/index.js')).generateVapidKeys(),
@@ -444,6 +504,7 @@ async function modulesFor(apps) {
444
504
  (await import('../mail/index.js')).mail,
445
505
  (await import('../automation/index.js')).automation,
446
506
  (await import('../studio/index.js')).studio,
507
+ (await import('../import/index.js')).dataImport,
447
508
  ];
448
509
  for (const app of apps) {
449
510
  if (app === 'permissions')
@@ -471,6 +532,10 @@ async function modulesFor(apps) {
471
532
  modules.push((await import('../project/index.js')).project);
472
533
  if (app === 'sale_project')
473
534
  modules.push((await import('../sale_project/index.js')).saleProject);
535
+ if (app === 'l10n_bd')
536
+ modules.push((await import('../l10n_bd/index.js')).l10nBd);
537
+ if (app === 'l10n_in')
538
+ modules.push((await import('../l10n_in/index.js')).l10nIn);
474
539
  }
475
540
  return modules;
476
541
  }
@@ -486,7 +551,7 @@ function envFile(a) {
486
551
  lines.push(`S3_ENDPOINT=${a.storage.endpoint}`, `S3_BUCKET=${a.storage.bucket}`, `S3_ACCESS_KEY=${a.storage.accessKey}`, `S3_SECRET_KEY=${a.storage.secretKey}`, `S3_REGION=${a.storage.region}`);
487
552
  if (a.valkeyUrl !== '')
488
553
  lines.push(`VALKEY_URL=${a.valkeyUrl}`);
489
- lines.push('', '# Where users open the back-office (links in emails and notifications).', 'KOSH_BASE_URL=http://localhost:5173', '# Email notifications: an SMTP server (Mailpit in development: smtp://localhost:1025, inbox on :8025).', '# SMTP_URL=smtp://user:password@smtp.example.com:587', '# MAIL_FROM=Kosh <no-reply@example.com>', '# Replies by email (RFC 0006 part 7): the inbound mailbox domain, and the webhook secret your provider sends.', `MAIL_INBOUND_SECRET=${a.inboundSecret}`, '# MAIL_INBOUND_DOMAIN=mail.example.com', '# MAIL_INBOUND_ALIAS=reply', `MAIL_INBOUND_WEBHOOK_SECRET=${a.inboundWebhookSecret}`, '# Without a webhook provider, a mailbox read every 30 s: IMAP_URL=imaps://user:password@host:993/INBOX', '', '# Kosh Studio (RFC 0012): propose (default: changes become patches) or write (development, one server: Studio', '# writes src/modules/<name>/ and applies at once). KOSH_STUDIO_DIR: the project folder (default: the working one).', '# KOSH_STUDIO=write', '# Proposals as pull requests (GitHub) or merge requests (GitLab): the repository and a token with the rights', '# to push branches and open them. Without them, proposals are patches to download.', '# KOSH_STUDIO_GIT_URL=https://github.com/acme/erp', '# KOSH_STUDIO_GIT_TOKEN=', '# KOSH_STUDIO_GIT_BASE=main', '', '# The AI assistant (RFC 0013): off until an administrator switches it on in Settings → AI assistant.', '# ANTHROPIC_API_KEY=', '# KOSH_ASSISTANT_MODEL=claude-opus-5-5', '# KOSH_ASSISTANT_MONTHLY_TOKENS=2000000', '', '# SMS and WhatsApp through Twilio (opt-in per user; a phone number on the user).', '# TWILIO_ACCOUNT_SID=', '# TWILIO_AUTH_TOKEN=', '# TWILIO_SMS_FROM=+1...', '# TWILIO_WHATSAPP_FROM=+1...', '', '# Web push (generated by kosh init; keep them, browsers subscribed with the public key stay subscribed).', `VAPID_PUBLIC_KEY=${a.vapid.publicKey}`, `VAPID_PRIVATE_KEY=${a.vapid.privateKey}`, `VAPID_SUBJECT=${a.admin.email === '' ? 'mailto:admin@localhost' : `mailto:${a.admin.email}`}`);
554
+ lines.push('', '# Where users open the back-office (links in emails and notifications).', `KOSH_BASE_URL=http://localhost:${a.webPort}`, '# Email notifications: an SMTP server (Mailpit in development: smtp://localhost:1025, inbox on :8025).', '# SMTP_URL=smtp://user:password@smtp.example.com:587', '# MAIL_FROM=Kosh <no-reply@example.com>', '# Replies by email (RFC 0006 part 7): the inbound mailbox domain, and the webhook secret your provider sends.', `MAIL_INBOUND_SECRET=${a.inboundSecret}`, '# MAIL_INBOUND_DOMAIN=mail.example.com', '# MAIL_INBOUND_ALIAS=reply', `MAIL_INBOUND_WEBHOOK_SECRET=${a.inboundWebhookSecret}`, '# Without a webhook provider, a mailbox read every 30 s: IMAP_URL=imaps://user:password@host:993/INBOX', '', '# Kosh Studio (RFC 0012): propose (default: changes become patches) or write (development, one server: Studio', '# writes src/modules/<name>/ and applies at once). KOSH_STUDIO_DIR: the project folder (default: the working one).', '# KOSH_STUDIO=write', '# Proposals as pull requests (GitHub) or merge requests (GitLab): the repository and a token with the rights', '# to push branches and open them. Without them, proposals are patches to download.', '# KOSH_STUDIO_GIT_URL=https://github.com/acme/erp', '# KOSH_STUDIO_GIT_TOKEN=', '# KOSH_STUDIO_GIT_BASE=main', '', '# The AI assistant (RFC 0013): off until an administrator switches it on in Settings → AI assistant.', '# ANTHROPIC_API_KEY=', '# KOSH_ASSISTANT_MODEL=claude-opus-5-5', '# KOSH_ASSISTANT_MONTHLY_TOKENS=2000000', '', '# SMS and WhatsApp through Twilio (opt-in per user; a phone number on the user).', '# TWILIO_ACCOUNT_SID=', '# TWILIO_AUTH_TOKEN=', '# TWILIO_SMS_FROM=+1...', '# TWILIO_WHATSAPP_FROM=+1...', '', '# Web push (generated by kosh init; keep them, browsers subscribed with the public key stay subscribed).', `VAPID_PUBLIC_KEY=${a.vapid.publicKey}`, `VAPID_PRIVATE_KEY=${a.vapid.privateKey}`, `VAPID_SUBJECT=${a.admin.email === '' ? 'mailto:admin@localhost' : `mailto:${a.admin.email}`}`);
490
555
  lines.push('', '# PostgreSQL in Docker (compose.yaml): docker compose up -d db, or everything with docker compose up -d --build', `POSTGRES_USER=${a.postgres.user}`, `POSTGRES_PASSWORD=${a.postgres.password}`, `POSTGRES_DB=${a.postgres.db}`, `POSTGRES_PORT=${a.postgres.port}`);
491
556
  return `${lines.join('\n')}\n`;
492
557
  }
@@ -496,6 +561,7 @@ async function writeProject(a, ui) {
496
561
  name: a.name,
497
562
  apps: a.apps,
498
563
  port: a.port,
564
+ webPort: a.webPort,
499
565
  koshVersion: releaseVersion(),
500
566
  dockerDb: a.postgres.db,
501
567
  fileStorage: a.storage.kind === 'dir',
@@ -694,6 +760,7 @@ export async function runInit(argv, io) {
694
760
  'create-database': { type: 'boolean' },
695
761
  company: { type: 'string' },
696
762
  currency: { type: 'string' },
763
+ country: { type: 'string' },
697
764
  lang: { type: 'string' },
698
765
  tz: { type: 'string' },
699
766
  'admin-login': { type: 'string' },
@@ -709,7 +776,9 @@ export async function runInit(argv, io) {
709
776
  's3-region': { type: 'string' },
710
777
  'valkey-url': { type: 'string' },
711
778
  port: { type: 'string' },
779
+ 'web-port': { type: 'string' },
712
780
  'no-2fa': { type: 'boolean' },
781
+ 'no-start': { type: 'boolean' },
713
782
  demo: { type: 'boolean' },
714
783
  'docker-db': { type: 'boolean' },
715
784
  yes: { type: 'boolean', short: 'y' },
@@ -723,15 +792,34 @@ export async function runInit(argv, io) {
723
792
  const terminal = io.terminal ?? PLAIN_TERMINAL;
724
793
  const interactive2fa = values['no-2fa'] !== true && values.yes !== true;
725
794
  // Six question steps, then the project files and the database, then (interactively) the second factor.
726
- const ui = createUi(io.write, terminal, { steps: interactive2fa ? 9 : 8 });
795
+ // At the end, in a terminal: install, start and open the installation (not for scripts, CI and tests).
796
+ const autoStart = values['no-start'] !== true && io.prompter === undefined && process.stdout.isTTY === true;
797
+ const ui = createUi(io.write, terminal, {
798
+ steps: (interactive2fa ? 9 : 8) + (autoStart ? 1 : 0),
799
+ });
800
+ const interactive = io.prompter === undefined &&
801
+ values.yes !== true &&
802
+ process.stdin.isTTY === true &&
803
+ railed(terminal);
727
804
  const asked = io.prompter ??
728
805
  (values.yes === true
729
806
  ? defaultsPrompter()
730
- : terminalPrompter(process.stdin, process.stdout, {
731
- marker: ui.style('cyan', '? '),
732
- fallback: (t) => ui.style('gray', t),
733
- problem: (t) => ui.style('yellow', `${terminal.unicode ? '▲' : '!'} ${t}`),
734
- }));
807
+ : interactive
808
+ ? interactivePrompter(process.stdin, process.stdout, terminal)
809
+ : terminalPrompter(process.stdin, process.stdout, {
810
+ marker: ui.style('cyan', '? '),
811
+ fallback: (t) => ui.style('gray', t),
812
+ problem: (t) => ui.style('yellow', `${terminal.unicode ? '▲' : '!'} ${t}`),
813
+ }));
814
+ const paused = async (work) => {
815
+ ui.pause();
816
+ try {
817
+ return await work();
818
+ }
819
+ finally {
820
+ ui.resume();
821
+ }
822
+ };
735
823
  // A question stops the spinner while it is asked (the prompter writes to the terminal itself).
736
824
  const prompter = {
737
825
  async ask(q) {
@@ -752,13 +840,23 @@ export async function runInit(argv, io) {
752
840
  ui.resume();
753
841
  }
754
842
  },
843
+ ...(asked.select === undefined
844
+ ? {}
845
+ : { select: (q) => paused(() => asked.select(q)) }),
846
+ ...(asked.multiselect === undefined
847
+ ? {}
848
+ : {
849
+ multiselect: (q) => paused(() => asked.multiselect(q)),
850
+ }),
755
851
  close: () => asked.close(),
756
852
  };
757
853
  let server;
758
854
  const started = Date.now();
759
855
  try {
760
856
  await ui.banner(`The ERP framework on Shutro · v${releaseVersion()}`);
761
- ui.write(` ${ui.style('gray', 'Kosh setup: press Enter to keep the value in [brackets].')}\n`);
857
+ ui.intro('Kosh setup', interactive
858
+ ? 'Enter keeps the default · ↑ ↓ choose · Space ticks · Ctrl+C quits'
859
+ : 'Kosh setup: press Enter to keep the value in [brackets].');
762
860
  const answers = await ask(prompter, values, io.cwd, positionals[0], ui);
763
861
  ui.step('files', 'Project files', answers.dir);
764
862
  await ui.task('Writing the project', async () => {
@@ -803,7 +901,10 @@ export async function runInit(argv, io) {
803
901
  else if (values.yes === true && values['no-2fa'] !== true)
804
902
  ui.info('The authenticator app is enrolled interactively: run npx kosh init again (without --yes).');
805
903
  const rows = [
806
- ['Company', `${answers.company} (${answers.currency})`],
904
+ [
905
+ 'Company',
906
+ `${answers.company} (${answers.currency}${answers.country === '' ? '' : `, ${answers.country}`})`,
907
+ ],
807
908
  [
808
909
  'Administrator',
809
910
  `${answers.admin.login} ${ui.style(enrolled ? 'green' : 'yellow', enrolled ? '· 2FA on' : '· 2FA pending')}`,
@@ -818,6 +919,7 @@ export async function runInit(argv, io) {
818
919
  'mail',
819
920
  'automation',
820
921
  'studio',
922
+ 'import',
821
923
  ...answers.apps,
822
924
  ].join(', '),
823
925
  ],
@@ -825,20 +927,42 @@ export async function runInit(argv, io) {
825
927
  ? [['Demo users', `dina, rahim, karim, sumi · password "${DEMO_PASSWORD}"`]]
826
928
  : []),
827
929
  ['Database', answers.dockerDb ? `Docker (port ${answers.postgres.port})` : 'your server'],
930
+ ['Back-office', `http://localhost:${answers.webPort}`],
828
931
  ['API', `http://localhost:${answers.port}`],
829
932
  ['Took', `${((Date.now() - started) / 1000).toFixed(1)}s`],
830
933
  ];
934
+ if (!autoStart)
935
+ ui.outro(ui.style('green', 'Kosh is set up'));
831
936
  ui.card(`${terminal.emoji ? '🚀 ' : ''}Kosh is set up`, rows, enrolled || values['no-2fa'] === true ? 'green' : 'yellow');
937
+ if (autoStart) {
938
+ if (!enrolled)
939
+ ui.warn('Administrator rights need a second factor: run `kosh init` again to enrol the authenticator app.');
940
+ ui.step('rocket', 'Starting Kosh', 'Dependencies, the API, the back-office, then your browser');
941
+ // The installer's own connection is not needed any more: the API has its own.
942
+ await server?.close();
943
+ server = undefined;
944
+ await launch({
945
+ dir: answers.dir,
946
+ port: answers.port,
947
+ webPort: answers.webPort,
948
+ login: answers.admin.login,
949
+ }, ui);
950
+ return 0;
951
+ }
832
952
  const relative = answers.dir === resolve(io.cwd) ? '' : `cd ${positionals[0] ?? answers.dir}\n `;
833
953
  const cmd = (c) => ui.style('cyan', c);
834
954
  ui.write(`\n ${ui.style('bold', 'Next')}\n ${relative}${cmd('npm install')}\n ${cmd('npm start')} ${ui.style('gray', `# the API on http://localhost:${answers.port}`)}\n` +
835
- ` ${cmd('npm run web')} ${ui.style('gray', '# the back-office on http://localhost:5173')}\n\n` +
955
+ ` ${cmd('npm run web')} ${ui.style('gray', `# the back-office on http://localhost:${answers.webPort}`)}\n\n` +
836
956
  ` Sign in as "${answers.admin.login}"${enrolled ? ' with your password and a code from the authenticator app' : ''}.\n`);
837
957
  if (!enrolled)
838
958
  ui.warn('Administrator rights need a second factor: run `kosh init` again to enrol the authenticator app.');
839
959
  return 0;
840
960
  }
841
961
  catch (error) {
962
+ if (error instanceof CancelledError) {
963
+ ui.outro(ui.style('red', 'Setup cancelled: nothing more was done.'));
964
+ return 130;
965
+ }
842
966
  ui.write('\n');
843
967
  ui.fail(`kosh init stopped: ${error instanceof Error ? error.message : String(error)}`);
844
968
  return 1;
@@ -0,0 +1,16 @@
1
+ import type { Ui } from './ui.js';
2
+ export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
3
+ /** The package manager that ran the CLI (`npx`, `pnpm dlx`, `yarn dlx`, `bunx`), from its user agent; npm otherwise. */
4
+ export declare function packageManager(env?: NodeJS.ProcessEnv): PackageManager;
5
+ /** The command that opens `url` in the default browser on `platform`. */
6
+ export declare function browserCommand(url: string, platform?: string): readonly [string, readonly string[]];
7
+ export interface LaunchOptions {
8
+ readonly dir: string;
9
+ readonly port: number;
10
+ readonly webPort: number;
11
+ readonly login: string;
12
+ }
13
+ /**
14
+ * Installs, starts and opens the installation; returns when the user stops it (Ctrl+C) or a server stops by itself.
15
+ */
16
+ export declare function launch(o: LaunchOptions, ui: Ui): Promise<void>;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * After `kosh init`: the project's dependencies installed with the package manager that ran the installer, the API
3
+ * and the back-office started, each waited for until it answers, and the back-office opened in the default browser.
4
+ * Both keep running in this terminal until Ctrl+C, which stops them together. Their output is kept, and shown when
5
+ * one of them fails to start.
6
+ */
7
+ import { spawn, spawnSync } from 'node:child_process';
8
+ /** The package manager that ran the CLI (`npx`, `pnpm dlx`, `yarn dlx`, `bunx`), from its user agent; npm otherwise. */
9
+ export function packageManager(env = process.env) {
10
+ const agent = env['npm_config_user_agent'] ?? '';
11
+ for (const pm of ['pnpm', 'yarn', 'bun'])
12
+ if (agent.startsWith(`${pm}/`))
13
+ return pm;
14
+ return 'npm';
15
+ }
16
+ /** The command that opens `url` in the default browser on `platform`. */
17
+ export function browserCommand(url, platform = process.platform) {
18
+ if (platform === 'win32')
19
+ return ['cmd', ['/c', 'start', '""', url]];
20
+ if (platform === 'darwin')
21
+ return ['open', [url]];
22
+ return ['xdg-open', [url]];
23
+ }
24
+ const windows = process.platform === 'win32';
25
+ /** A child process running `pm args` in `cwd`, its output kept (the last 200 lines). */
26
+ function start(pm, args, cwd) {
27
+ const child = spawn(pm, args, {
28
+ cwd,
29
+ env: { ...process.env, FORCE_COLOR: '0' },
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ // npm, pnpm… are .cmd scripts on Windows; elsewhere a process group, stopped as a whole.
32
+ shell: windows,
33
+ detached: !windows,
34
+ });
35
+ const lines = [];
36
+ const keep = (chunk) => {
37
+ lines.push(...chunk
38
+ .toString()
39
+ .split(/\r?\n/)
40
+ .filter((l) => l.trim() !== ''));
41
+ lines.splice(0, Math.max(0, lines.length - 200));
42
+ };
43
+ child.stdout?.on('data', keep);
44
+ child.stderr?.on('data', keep);
45
+ const exited = new Promise((done) => child.on('close', (code) => done(code ?? 1)));
46
+ return { child, lines, exited };
47
+ }
48
+ /** Stops a started process and what it started. */
49
+ function stop(child) {
50
+ if (child.pid === undefined || child.exitCode !== null)
51
+ return;
52
+ try {
53
+ if (windows)
54
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
55
+ else
56
+ process.kill(-child.pid, 'SIGTERM');
57
+ }
58
+ catch {
59
+ // already gone
60
+ }
61
+ }
62
+ /** Resolves when `url` answers (any HTTP status below 500); rejects when `exited` settles first or on timeout. */
63
+ async function answers(url, exited, seconds) {
64
+ let gone = null;
65
+ void exited.then((code) => {
66
+ gone = code;
67
+ });
68
+ for (let i = 0; i < seconds * 2; i++) {
69
+ if (gone !== null)
70
+ throw new Error(`it stopped (exit code ${gone})`);
71
+ try {
72
+ const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
73
+ if (res.status < 500)
74
+ return;
75
+ }
76
+ catch {
77
+ // not yet
78
+ }
79
+ await new Promise((r) => setTimeout(r, 500));
80
+ }
81
+ throw new Error(`no answer at ${url} within ${seconds}s`);
82
+ }
83
+ /**
84
+ * Installs, starts and opens the installation; returns when the user stops it (Ctrl+C) or a server stops by itself.
85
+ */
86
+ export async function launch(o, ui) {
87
+ const pm = packageManager();
88
+ await ui.task(`Installing the dependencies (${pm} install)`, async (task) => {
89
+ task.update('the first run downloads them');
90
+ const run = start(pm, ['install'], o.dir);
91
+ const code = await run.exited;
92
+ if (code !== 0)
93
+ throw new Error(`${pm} install failed:\n${run.lines.slice(-15).join('\n')}`);
94
+ });
95
+ const api = start(pm, ['run', 'start'], o.dir);
96
+ const web = start(pm, ['run', 'web'], o.dir);
97
+ const stopAll = () => {
98
+ stop(api.child);
99
+ stop(web.child);
100
+ };
101
+ process.once('exit', stopAll);
102
+ const failed = (name, run) => (error) => {
103
+ stopAll();
104
+ const tail = run.lines.slice(-15).join('\n');
105
+ throw new Error(`The ${name} did not start: ${error instanceof Error ? error.message : String(error)}${tail === '' ? '' : `\n${tail}`}`);
106
+ };
107
+ const apiUrl = `http://localhost:${o.port}`;
108
+ const webUrl = `http://localhost:${o.webPort}`;
109
+ await ui
110
+ .task(`Starting the API on ${apiUrl}`, () => answers(`${apiUrl}/health/ready`, api.exited, 90))
111
+ .catch(failed('API', api));
112
+ await ui
113
+ .task(`Starting the back-office on ${webUrl}`, () => answers(webUrl, web.exited, 90))
114
+ .catch(failed('back-office', web));
115
+ const [command, args] = browserCommand(webUrl);
116
+ const opened = spawn(command, args, { stdio: 'ignore', detached: true, shell: false });
117
+ opened.on('error', () => ui.info(`Open ${webUrl} in your browser.`));
118
+ opened.unref();
119
+ ui.ok(`Opened ${ui.style('cyan', webUrl)} in your browser · sign in as "${o.login}"`);
120
+ ui.outro(`${ui.style('green', 'Kosh is running')} ${ui.style('gray', `back-office ${webUrl} · API ${apiUrl} · Ctrl+C stops both`)}`);
121
+ // Until Ctrl+C, or until one of them stops by itself (its last lines are shown).
122
+ const which = await Promise.race([
123
+ new Promise((done) => {
124
+ process.once('SIGINT', () => done('stopped'));
125
+ process.once('SIGTERM', () => done('stopped'));
126
+ }),
127
+ api.exited.then(() => 'api'),
128
+ web.exited.then(() => 'web'),
129
+ ]);
130
+ stopAll();
131
+ if (which === 'stopped') {
132
+ ui.write(`\n ${ui.style('gray', `Stopped. Start again with: ${pm} start and ${pm} run web`)}\n`);
133
+ return;
134
+ }
135
+ const run = which === 'api' ? api : web;
136
+ ui.fail(`The ${which === 'api' ? 'API' : 'back-office'} stopped:`);
137
+ ui.write(`${run.lines.slice(-15).join('\n')}\n`);
138
+ }
@@ -7,11 +7,46 @@ export interface Question {
7
7
  /** An error message for an invalid answer (asked again), or `null`. */
8
8
  readonly validate?: (value: string) => string | null;
9
9
  }
10
+ /** One option of a choice: what is answered, what is shown, an optional hint beside it. */
11
+ export interface Choice {
12
+ readonly value: string;
13
+ readonly label: string;
14
+ readonly hint?: string;
15
+ }
16
+ export interface SelectQuestion {
17
+ readonly message: string;
18
+ readonly choices: readonly Choice[];
19
+ /** The value chosen when Enter is pressed at once. */
20
+ readonly default?: string;
21
+ }
22
+ export interface MultiQuestion {
23
+ readonly message: string;
24
+ readonly choices: readonly Choice[];
25
+ /** The values ticked to begin with. */
26
+ readonly defaults?: readonly string[];
27
+ /** At least one. */
28
+ readonly required?: boolean;
29
+ }
10
30
  export interface Prompter {
11
31
  ask(question: Question): Promise<string>;
12
32
  confirm(message: string, fallback: boolean): Promise<boolean>;
33
+ /** One of several (a list chosen with the keyboard); without it, {@link choose} asks a question instead. */
34
+ select?(question: SelectQuestion): Promise<string>;
35
+ /** Several of several; without it, {@link chooseMany} asks for a comma-separated list. */
36
+ multiselect?(question: MultiQuestion): Promise<string[]>;
13
37
  close(): void;
14
38
  }
39
+ /**
40
+ * One of `choices`: the prompter's list when it has one, otherwise the question `fallback` (pipes, scripts, tests),
41
+ * answered with an option's number, value or label.
42
+ */
43
+ export declare function choose(prompter: Prompter, q: SelectQuestion & {
44
+ readonly fallback: string;
45
+ }): Promise<string>;
46
+ /** Several of `choices`: the prompter's list, or a comma-separated answer to `fallback`. */
47
+ export declare function chooseMany(prompter: Prompter, q: MultiQuestion & {
48
+ readonly fallback: string;
49
+ }): Promise<string[]>;
15
50
  /** Thrown when the input ends before every question is answered (piped answers ran out, Ctrl+D). */
16
51
  export declare class InputEndedError extends Error {
17
52
  constructor(question: string);