@pablozaiden/terminatui 0.2.0 → 0.3.0-beta-1

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 (175) hide show
  1. package/AGENTS.md +14 -2
  2. package/CLAUDE.md +1 -0
  3. package/README.md +64 -43
  4. package/bun.lock +85 -0
  5. package/examples/tui-app/commands/config/app/get.ts +6 -10
  6. package/examples/tui-app/commands/config/app/index.ts +2 -6
  7. package/examples/tui-app/commands/config/app/set.ts +23 -13
  8. package/examples/tui-app/commands/config/index.ts +2 -6
  9. package/examples/tui-app/commands/config/user/get.ts +6 -10
  10. package/examples/tui-app/commands/config/user/index.ts +2 -6
  11. package/examples/tui-app/commands/config/user/set.ts +6 -10
  12. package/examples/tui-app/commands/greet.ts +13 -11
  13. package/examples/tui-app/commands/math.ts +5 -9
  14. package/examples/tui-app/commands/status.ts +21 -12
  15. package/examples/tui-app/index.ts +6 -3
  16. package/guides/01-hello-world.md +7 -2
  17. package/guides/02-adding-options.md +2 -2
  18. package/guides/03-multiple-commands.md +6 -8
  19. package/guides/04-subcommands.md +8 -8
  20. package/guides/05-interactive-tui.md +45 -30
  21. package/guides/06-config-validation.md +4 -12
  22. package/guides/07-async-cancellation.md +14 -16
  23. package/guides/08-complete-application.md +12 -42
  24. package/guides/README.md +7 -3
  25. package/package.json +4 -8
  26. package/src/__tests__/application.test.ts +87 -68
  27. package/src/__tests__/buildCliCommand.test.ts +99 -119
  28. package/src/__tests__/builtins.test.ts +27 -75
  29. package/src/__tests__/command.test.ts +100 -131
  30. package/src/__tests__/context.test.ts +1 -26
  31. package/src/__tests__/helpCore.test.ts +227 -0
  32. package/src/__tests__/parser.test.ts +98 -244
  33. package/src/__tests__/registry.test.ts +33 -160
  34. package/src/__tests__/schemaToFields.test.ts +75 -158
  35. package/src/builtins/help.ts +12 -4
  36. package/src/builtins/settings.ts +18 -32
  37. package/src/builtins/version.ts +4 -4
  38. package/src/cli/output/colors.ts +1 -1
  39. package/src/cli/parser.ts +26 -95
  40. package/src/core/application.ts +192 -110
  41. package/src/core/command.ts +26 -9
  42. package/src/core/context.ts +31 -20
  43. package/src/core/help.ts +24 -18
  44. package/src/core/knownCommands.ts +13 -0
  45. package/src/core/logger.ts +39 -42
  46. package/src/core/registry.ts +5 -12
  47. package/src/tui/TuiApplication.tsx +63 -120
  48. package/src/tui/TuiRoot.tsx +135 -0
  49. package/src/tui/adapters/factory.ts +19 -0
  50. package/src/tui/adapters/ink/InkRenderer.tsx +135 -0
  51. package/src/tui/adapters/ink/components/Button.tsx +12 -0
  52. package/src/tui/adapters/ink/components/Code.tsx +6 -0
  53. package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
  54. package/src/tui/adapters/ink/components/Container.tsx +5 -0
  55. package/src/tui/adapters/ink/components/Field.tsx +12 -0
  56. package/src/tui/adapters/ink/components/Label.tsx +24 -0
  57. package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
  58. package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
  59. package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
  60. package/src/tui/adapters/ink/components/Panel.tsx +15 -0
  61. package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
  62. package/src/tui/adapters/ink/components/Select.tsx +44 -0
  63. package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
  64. package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
  65. package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
  66. package/src/tui/adapters/ink/components/Value.tsx +7 -0
  67. package/src/tui/adapters/ink/keyboard.ts +97 -0
  68. package/src/tui/adapters/ink/utils.ts +16 -0
  69. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +115 -0
  70. package/src/tui/adapters/opentui/components/Button.tsx +13 -0
  71. package/src/tui/adapters/opentui/components/Code.tsx +12 -0
  72. package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
  73. package/src/tui/adapters/opentui/components/Container.tsx +56 -0
  74. package/src/tui/adapters/opentui/components/Field.tsx +18 -0
  75. package/src/tui/adapters/opentui/components/Label.tsx +15 -0
  76. package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
  77. package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
  78. package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
  79. package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
  80. package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
  81. package/src/tui/adapters/opentui/components/Select.tsx +59 -0
  82. package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
  83. package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
  84. package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
  85. package/src/tui/adapters/opentui/components/Value.tsx +13 -0
  86. package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
  87. package/src/tui/adapters/opentui/keyboard.ts +61 -0
  88. package/src/tui/adapters/types.ts +70 -0
  89. package/src/tui/components/ActionButton.tsx +0 -36
  90. package/src/tui/components/CommandSelector.tsx +45 -92
  91. package/src/tui/components/ConfigForm.tsx +68 -42
  92. package/src/tui/components/FieldRow.tsx +0 -30
  93. package/src/tui/components/Header.tsx +14 -13
  94. package/src/tui/components/JsonHighlight.tsx +10 -17
  95. package/src/tui/components/ModalBase.tsx +38 -0
  96. package/src/tui/components/ResultsPanel.tsx +27 -36
  97. package/src/tui/components/StatusBar.tsx +24 -39
  98. package/src/tui/components/logColors.ts +12 -0
  99. package/src/tui/context/ClipboardContext.tsx +87 -0
  100. package/src/tui/context/ExecutorContext.tsx +139 -0
  101. package/src/tui/context/KeyboardContext.tsx +85 -71
  102. package/src/tui/context/LogsContext.tsx +35 -0
  103. package/src/tui/context/NavigationContext.tsx +194 -0
  104. package/src/tui/context/RendererContext.tsx +20 -0
  105. package/src/tui/context/TuiAppContext.tsx +58 -0
  106. package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
  107. package/src/tui/hooks/useBackHandler.ts +34 -0
  108. package/src/tui/hooks/useClipboard.ts +40 -25
  109. package/src/tui/hooks/useClipboardProvider.ts +42 -0
  110. package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
  111. package/src/tui/modals/CliModal.tsx +82 -0
  112. package/src/tui/modals/EditorModal.tsx +207 -0
  113. package/src/tui/modals/LogsModal.tsx +98 -0
  114. package/src/tui/registry.ts +102 -0
  115. package/src/tui/screens/CommandSelectScreen.tsx +162 -0
  116. package/src/tui/screens/ConfigScreen.tsx +160 -0
  117. package/src/tui/screens/ErrorScreen.tsx +58 -0
  118. package/src/tui/screens/ResultsScreen.tsx +60 -0
  119. package/src/tui/screens/RunningScreen.tsx +72 -0
  120. package/src/tui/screens/ScreenBase.ts +6 -0
  121. package/src/tui/semantic/Button.tsx +7 -0
  122. package/src/tui/semantic/Code.tsx +7 -0
  123. package/src/tui/semantic/CodeHighlight.tsx +7 -0
  124. package/src/tui/semantic/Container.tsx +7 -0
  125. package/src/tui/semantic/Field.tsx +7 -0
  126. package/src/tui/semantic/Label.tsx +7 -0
  127. package/src/tui/semantic/MenuButton.tsx +7 -0
  128. package/src/tui/semantic/MenuItem.tsx +7 -0
  129. package/src/tui/semantic/Overlay.tsx +7 -0
  130. package/src/tui/semantic/Panel.tsx +7 -0
  131. package/src/tui/semantic/ScrollView.tsx +9 -0
  132. package/src/tui/semantic/Select.tsx +7 -0
  133. package/src/tui/semantic/Spacer.tsx +7 -0
  134. package/src/tui/semantic/Spinner.tsx +7 -0
  135. package/src/tui/semantic/TextInput.tsx +7 -0
  136. package/src/tui/semantic/Value.tsx +7 -0
  137. package/src/tui/semantic/types.ts +195 -0
  138. package/src/tui/theme.ts +25 -14
  139. package/src/tui/utils/buildCliCommand.ts +1 -0
  140. package/src/tui/utils/getEnumKeys.ts +3 -0
  141. package/src/tui/utils/parameterPersistence.ts +1 -0
  142. package/src/types/command.ts +0 -60
  143. package/examples/tui-app/commands/index.ts +0 -4
  144. package/src/__tests__/colors.test.ts +0 -127
  145. package/src/__tests__/commandClass.test.ts +0 -130
  146. package/src/__tests__/help.test.ts +0 -412
  147. package/src/__tests__/registryNew.test.ts +0 -160
  148. package/src/__tests__/table.test.ts +0 -146
  149. package/src/__tests__/tui.test.ts +0 -26
  150. package/src/builtins/index.ts +0 -4
  151. package/src/cli/help.ts +0 -174
  152. package/src/cli/index.ts +0 -3
  153. package/src/cli/output/index.ts +0 -2
  154. package/src/cli/output/table.ts +0 -141
  155. package/src/commands/help.ts +0 -50
  156. package/src/commands/index.ts +0 -1
  157. package/src/components/index.ts +0 -147
  158. package/src/core/index.ts +0 -15
  159. package/src/hooks/index.ts +0 -131
  160. package/src/index.ts +0 -137
  161. package/src/registry/commandRegistry.ts +0 -77
  162. package/src/registry/index.ts +0 -1
  163. package/src/tui/TuiApp.tsx +0 -619
  164. package/src/tui/app.ts +0 -29
  165. package/src/tui/components/CliModal.tsx +0 -81
  166. package/src/tui/components/EditorModal.tsx +0 -177
  167. package/src/tui/components/LogsPanel.tsx +0 -86
  168. package/src/tui/components/index.ts +0 -13
  169. package/src/tui/context/index.ts +0 -7
  170. package/src/tui/hooks/index.ts +0 -35
  171. package/src/tui/hooks/useKeyboardHandler.ts +0 -91
  172. package/src/tui/hooks/useLogStream.ts +0 -96
  173. package/src/tui/index.ts +0 -65
  174. package/src/tui/utils/index.ts +0 -13
  175. package/src/types/index.ts +0 -1
@@ -23,7 +23,6 @@ import path from "node:path";
23
23
  import {
24
24
  Command,
25
25
  ConfigValidationError,
26
- type AppContext,
27
26
  type OptionSchema,
28
27
  type OptionValues,
29
28
  type CommandResult,
@@ -69,10 +68,7 @@ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
69
68
  readonly displayName = "File Downloader";
70
69
  readonly actionLabel = "Download";
71
70
 
72
- override buildConfig(
73
- _ctx: AppContext,
74
- opts: OptionValues<typeof options>
75
- ): DownloadConfig {
71
+ override buildConfig(opts: OptionValues<typeof options>): DownloadConfig {
76
72
  // Validate URL
77
73
  const urlStr = opts["url"] as string;
78
74
  if (!urlStr) {
@@ -107,16 +103,15 @@ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
107
103
 
108
104
  ```typescript
109
105
  async execute(
110
- ctx: AppContext,
111
106
  config: DownloadConfig,
112
- execCtx?: CommandExecutionContext
107
+ execCtx: CommandExecutionContext
113
108
  ): Promise<CommandResult> {
114
109
  const { url, outputDir, fileName, chunkSize } = config;
115
110
  const outputPath = path.join(outputDir, fileName);
116
- const signal = execCtx?.signal;
111
+ const signal = execCtx.signal;
117
112
 
118
- ctx.logger.info(`Starting download: ${url}`);
119
- ctx.logger.info(`Output: ${outputPath}`);
113
+ console.log(`Starting download: ${url}`);
114
+ console.log(`Output: ${outputPath}`);
120
115
 
121
116
  // Create output directory
122
117
  await Bun.write(path.join(outputDir, ".keep"), "");
@@ -128,7 +123,8 @@ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
128
123
 
129
124
  try {
130
125
  // Check for cancellation before starting
131
- if (signal?.aborted) {
126
+ if (signal.aborted) {
127
+
132
128
  return { success: false, message: "Download cancelled before start" };
133
129
  }
134
130
 
@@ -157,8 +153,9 @@ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
157
153
  // Read chunks with cancellation checks
158
154
  while (true) {
159
155
  // Check for cancellation between chunks
160
- if (signal?.aborted) {
161
- ctx.logger.warn("Download cancelled by user");
156
+ if (signal.aborted) {
157
+
158
+ console.warn("Download cancelled by user");
162
159
  throw new Error("AbortError");
163
160
  }
164
161
 
@@ -177,10 +174,10 @@ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
177
174
  const percent = ((downloadedBytes / totalBytes) * 100).toFixed(1);
178
175
  const mbDownloaded = (downloadedBytes / 1024 / 1024).toFixed(2);
179
176
  const mbTotal = (totalBytes / 1024 / 1024).toFixed(2);
180
- process.stdout.write(`\rProgress: ${percent}% (${mbDownloaded}/${mbTotal} MB)`);
177
+ Bun.write(Bun.stdout, `\rProgress: ${percent}% (${mbDownloaded}/${mbTotal} MB)`);
181
178
  } else {
182
179
  const mbDownloaded = (downloadedBytes / 1024 / 1024).toFixed(2);
183
- process.stdout.write(`\rDownloaded: ${mbDownloaded} MB`);
180
+ Bun.write(Bun.stdout, `\rDownloaded: ${mbDownloaded} MB`);
184
181
  }
185
182
  }
186
183
 
@@ -200,7 +197,8 @@ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
200
197
 
201
198
  } catch (error) {
202
199
  // Handle cancellation
203
- if (signal?.aborted || (error as Error).name === "AbortError") {
200
+ if (signal.aborted || (error as Error).name === "AbortError") {
201
+
204
202
  console.log("\nDownload cancelled.");
205
203
 
206
204
  // Cleanup partial file
@@ -152,7 +152,6 @@ Create `src/commands/add.ts`:
152
152
  import {
153
153
  Command,
154
154
  ConfigValidationError,
155
- type AppContext,
156
155
  type OptionSchema,
157
156
  type OptionValues,
158
157
  type CommandResult,
@@ -190,15 +189,10 @@ export class AddCommand extends Command<typeof options, AddConfig> {
190
189
  readonly options = options;
191
190
  readonly displayName = "Add Task";
192
191
  readonly actionLabel = "Create Task";
193
- readonly group = "Tasks";
194
- readonly order = 1;
195
192
 
196
193
  private db = new Database();
197
194
 
198
- override buildConfig(
199
- _ctx: AppContext,
200
- opts: OptionValues<typeof options>
201
- ): AddConfig {
195
+ override buildConfig(opts: OptionValues<typeof options>): AddConfig {
202
196
  const title = (opts["title"] as string)?.trim();
203
197
  if (!title) {
204
198
  throw new ConfigValidationError("Task title cannot be empty", "title");
@@ -215,13 +209,11 @@ export class AddCommand extends Command<typeof options, AddConfig> {
215
209
  return { title, priority };
216
210
  }
217
211
 
218
- async execute(ctx: AppContext, config: AddConfig): Promise<CommandResult> {
219
- ctx.logger.debug(`Creating task: ${config.title}`);
220
-
212
+ async execute(config: AddConfig): Promise<CommandResult> {
221
213
  const task = await this.db.addTask(config.title, config.priority);
222
-
223
- const notifications = new NotificationService(ctx.logger);
224
- notifications.taskAdded(task);
214
+
215
+ // (Example) optional: log to console or your own logger
216
+ console.log(`Created task: ${task.title}`);
225
217
 
226
218
  return {
227
219
  success: true,
@@ -237,7 +229,6 @@ Create `src/commands/list.ts`:
237
229
  ```typescript
238
230
  import {
239
231
  Command,
240
- type AppContext,
241
232
  type OptionSchema,
242
233
  type OptionValues,
243
234
  type CommandResult,
@@ -272,27 +263,20 @@ export class ListCommand extends Command<typeof options, ListConfig> {
272
263
  readonly options = options;
273
264
  readonly displayName = "List Tasks";
274
265
  readonly actionLabel = "Show Tasks";
275
- readonly group = "Tasks";
276
- readonly order = 2;
277
266
 
278
267
  // Execute immediately when selected in TUI
279
268
  readonly immediateExecution = true;
280
269
 
281
270
  private db = new Database();
282
271
 
283
- override buildConfig(
284
- _ctx: AppContext,
285
- opts: OptionValues<typeof options>
286
- ): ListConfig {
272
+ override buildConfig(opts: OptionValues<typeof options>): ListConfig {
287
273
  return {
288
274
  filter: (opts["filter"] as ListConfig["filter"]) ?? "all",
289
275
  priority: opts["priority"] as Task["priority"] | undefined,
290
276
  };
291
277
  }
292
278
 
293
- async execute(ctx: AppContext, config: ListConfig): Promise<CommandResult> {
294
- ctx.logger.debug(`Listing tasks: filter=${config.filter}`);
295
-
279
+ async execute(config: ListConfig): Promise<CommandResult> {
296
280
  let tasks = await this.db.listTasks(config.filter);
297
281
 
298
282
  if (config.priority) {
@@ -314,7 +298,6 @@ Create `src/commands/complete.ts`:
314
298
  import {
315
299
  Command,
316
300
  ConfigValidationError,
317
- type AppContext,
318
301
  type OptionSchema,
319
302
  type OptionValues,
320
303
  type CommandResult,
@@ -342,15 +325,10 @@ export class CompleteCommand extends Command<typeof options, CompleteConfig> {
342
325
  readonly options = options;
343
326
  readonly displayName = "Complete Task";
344
327
  readonly actionLabel = "Mark Complete";
345
- readonly group = "Tasks";
346
- readonly order = 3;
347
328
 
348
329
  private db = new Database();
349
330
 
350
- override buildConfig(
351
- _ctx: AppContext,
352
- opts: OptionValues<typeof options>
353
- ): CompleteConfig {
331
+ override buildConfig(opts: OptionValues<typeof options>): CompleteConfig {
354
332
  const id = opts["id"] as string;
355
333
  if (!id?.trim()) {
356
334
  throw new ConfigValidationError("Task ID is required", "id");
@@ -358,11 +336,9 @@ export class CompleteCommand extends Command<typeof options, CompleteConfig> {
358
336
  return { id: id.trim() };
359
337
  }
360
338
 
361
- async execute(ctx: AppContext, config: CompleteConfig): Promise<CommandResult> {
362
- ctx.logger.debug(`Completing task: ${config.id}`);
363
-
339
+ async execute(config: CompleteConfig): Promise<CommandResult> {
364
340
  const task = await this.db.completeTask(config.id);
365
-
341
+
366
342
  if (!task) {
367
343
  return {
368
344
  success: false,
@@ -370,8 +346,7 @@ export class CompleteCommand extends Command<typeof options, CompleteConfig> {
370
346
  };
371
347
  }
372
348
 
373
- const notifications = new NotificationService(ctx.logger);
374
- notifications.taskCompleted(task);
349
+ console.log(`Completed task: ${task.title}`);
375
350
 
376
351
  return {
377
352
  success: true,
@@ -387,7 +362,6 @@ Create `src/commands/stats.ts`:
387
362
  ```typescript
388
363
  import {
389
364
  Command,
390
- type AppContext,
391
365
  type OptionSchema,
392
366
  type CommandResult,
393
367
  } from "@pablozaiden/terminatui";
@@ -402,15 +376,11 @@ export class StatsCommand extends Command<typeof options> {
402
376
  readonly options = options;
403
377
  readonly displayName = "Statistics";
404
378
  readonly actionLabel = "Show Stats";
405
- readonly group = "Analytics";
406
- readonly order = 1;
407
379
  readonly immediateExecution = true;
408
380
 
409
381
  private db = new Database();
410
382
 
411
- async execute(ctx: AppContext): Promise<CommandResult> {
412
- ctx.logger.debug("Fetching statistics");
413
-
383
+ async execute(): Promise<CommandResult> {
414
384
  const stats = await this.db.getStats();
415
385
 
416
386
  return {
package/guides/README.md CHANGED
@@ -36,7 +36,7 @@ bun add @pablozaiden/terminatui
36
36
 
37
37
  # Create your first command
38
38
  cat > src/index.ts << 'EOF'
39
- import { Command, Application, type AppContext, type OptionSchema } from "@pablozaiden/terminatui";
39
+ import { Command, Application, type OptionSchema } from "@pablozaiden/terminatui";
40
40
 
41
41
  const options = {
42
42
  name: { type: "string", description: "Your name" },
@@ -47,7 +47,7 @@ class HelloCommand extends Command<typeof options> {
47
47
  readonly description = "Say hello";
48
48
  readonly options = options;
49
49
 
50
- async execute(ctx: AppContext, config: Record<string, unknown>) {
50
+ async execute(config: Record<string, unknown>) {
51
51
  const name = config["name"] ?? "World";
52
52
  console.log(`Hello, ${name}!`);
53
53
  return { success: true };
@@ -60,7 +60,11 @@ class MyCLI extends Application {
60
60
  }
61
61
  }
62
62
 
63
+ // Recommended: let Terminatui read `Bun.argv.slice(2)`
63
64
  await new MyCLI().run();
65
+
66
+ // For tests or programmatic invocation:
67
+ // await new MyCLI().runFromArgs(["hello", "--name", "Developer"]);
64
68
  EOF
65
69
 
66
70
  # Run it
@@ -69,6 +73,6 @@ bun src/index.ts hello --name "Developer"
69
73
 
70
74
  ## Prerequisites
71
75
 
72
- - [Bun](https://bun.sh) or Node.js 18+
76
+ - [Bun](https://bun.sh)
73
77
  - Basic TypeScript knowledge
74
78
  - Terminal/command-line familiarity
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/terminatui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-beta-1",
4
4
  "description": "Terminal UI and Command Line Application Framework",
5
5
  "repository": {
6
6
  "url": "https://github.com/PabloZaiden/terminatui"
@@ -8,13 +8,6 @@
8
8
  "type": "module",
9
9
  "main": "src/index.ts",
10
10
  "types": "src/index.ts",
11
- "exports": {
12
- ".": "./src/index.ts",
13
- "./cli": "./src/cli/index.ts",
14
- "./tui": "./src/tui/index.ts",
15
- "./components": "./src/components/index.ts",
16
- "./hooks": "./src/hooks/index.ts"
17
- },
18
11
  "scripts": {
19
12
  "build": "bunx tsc --noEmit",
20
13
  "test": "bun test",
@@ -22,6 +15,9 @@
22
15
  },
23
16
  "dependencies": {
24
17
  "@opentui/react": "0.1.68",
18
+ "ink": "^6.6.0",
19
+ "ink-select-input": "^6.2.0",
20
+ "ink-text-input": "^6.0.0",
25
21
  "tslog": "^4.9.3"
26
22
  },
27
23
  "devDependencies": {
@@ -1,8 +1,10 @@
1
- import { describe, test, expect, afterEach } from "bun:test";
1
+ import { describe, test, expect } from "bun:test";
2
2
  import { Application } from "../core/application.ts";
3
3
  import { Command } from "../core/command.ts";
4
- import { AppContext } from "../core/context.ts";
5
4
  import type { OptionSchema, OptionValues, OptionDef } from "../types/command.ts";
5
+ import { AppContext } from "../core/context.ts";
6
+ import { LogLevel } from "../core/logger.ts";
7
+ import { KNOWN_COMMANDS } from "../core/knownCommands.ts";
6
8
 
7
9
  // Define a proper option schema
8
10
  const testOptions = {
@@ -21,7 +23,6 @@ class TestCommand extends Command<typeof testOptions> {
21
23
  executedWith: Record<string, unknown> | null = null;
22
24
 
23
25
  override async execute(
24
- _ctx: AppContext,
25
26
  opts: OptionValues<typeof testOptions>
26
27
  ): Promise<void> {
27
28
  this.executedWith = opts as Record<string, unknown>;
@@ -35,17 +36,57 @@ class TuiCommand extends Command<OptionSchema> {
35
36
 
36
37
  executed = false;
37
38
 
38
- override async execute(_ctx: AppContext): Promise<void> {
39
+ override async execute(): Promise<void> {
39
40
  this.executed = true;
40
41
  }
41
42
  }
42
43
 
43
44
  describe("Application", () => {
44
- afterEach(() => {
45
- AppContext.clearCurrent();
46
- });
47
-
48
45
  describe("constructor", () => {
46
+ test("rejects reserved help command definitions", () => {
47
+ class ReservedCommand extends Command<OptionSchema> {
48
+ readonly name = KNOWN_COMMANDS.help;
49
+ readonly description = "tries to override built-in";
50
+ readonly options = {};
51
+
52
+ override async execute(): Promise<void> {}
53
+ }
54
+
55
+ expect(() => {
56
+ new Application({
57
+ name: "test-app",
58
+ version: "1.0.0",
59
+ commands: [new ReservedCommand()],
60
+ });
61
+ }).toThrow(/reserved/i);
62
+
63
+ class SubCommand extends Command<OptionSchema> {
64
+ readonly name = KNOWN_COMMANDS.help;
65
+ readonly description = "user help";
66
+ readonly options = {};
67
+
68
+ override async execute(): Promise<void> {}
69
+ }
70
+
71
+ class ParentCommand extends Command<OptionSchema> {
72
+ readonly name = "parent";
73
+ readonly description = "parent";
74
+ readonly options = {};
75
+
76
+ override subCommands = [new SubCommand()];
77
+
78
+ override async execute(): Promise<void> {}
79
+ }
80
+
81
+ expect(() => {
82
+ new Application({
83
+ name: "test-app",
84
+ version: "1.0.0",
85
+ commands: [new ParentCommand()],
86
+ });
87
+ }).toThrow(/automatically injected/i);
88
+ });
89
+
49
90
  test("creates application with name and version", () => {
50
91
  const app = new Application({
51
92
  name: "test-app",
@@ -56,14 +97,16 @@ describe("Application", () => {
56
97
  expect(app.version).toBe("1.0.0");
57
98
  });
58
99
 
59
- test("creates context and sets as current", () => {
60
- const app = new Application({
100
+ test("creates context as side effect of creating application", () => {
101
+ // side effect of creating an application is setting the current context
102
+ new Application({
61
103
  name: "test-app",
62
104
  version: "1.0.0",
63
105
  commands: [],
64
106
  });
65
- expect(AppContext.hasCurrent()).toBe(true);
66
- expect(app.context).toBe(AppContext.current);
107
+
108
+ expect(AppContext.current.config.name).toBe("test-app");
109
+ expect(AppContext.current.config.version).toBe("1.0.0");
67
110
  });
68
111
 
69
112
  test("registers provided commands", () => {
@@ -91,7 +134,7 @@ describe("Application", () => {
91
134
  version: "1.0.0",
92
135
  commands: [],
93
136
  });
94
- expect(app.registry.has("help")).toBe(true);
137
+ expect(app.registry.has(KNOWN_COMMANDS.help)).toBe(true);
95
138
  });
96
139
 
97
140
  test("injects help subcommand into commands", () => {
@@ -103,32 +146,11 @@ describe("Application", () => {
103
146
  commands: [cmd],
104
147
  });
105
148
  expect(cmd.subCommands).toBeDefined();
106
- expect(cmd.subCommands?.some((c) => c.name === "help")).toBe(true);
107
- });
108
- });
109
-
110
- describe("getContext", () => {
111
- test("returns the application context", () => {
112
- const app = new Application({
113
- name: "test-app",
114
- version: "1.0.0",
115
- commands: [],
116
- });
117
- expect(app.getContext()).toBe(app.context);
149
+ expect(cmd.subCommands?.some((c) => c.name === KNOWN_COMMANDS.help)).toBe(true);
118
150
  });
119
151
  });
120
152
 
121
153
  describe("run", () => {
122
- test("shows help when no args and no default command", async () => {
123
- const app = new Application({
124
- name: "test-app",
125
- version: "1.0.0",
126
- commands: [new TestCommand()],
127
- });
128
- // Should not throw
129
- await app.run([]);
130
- });
131
-
132
154
  test("runs default command when no args", async () => {
133
155
  const cmd = new TuiCommand();
134
156
  const app = new Application({
@@ -137,31 +159,30 @@ describe("Application", () => {
137
159
  commands: [cmd],
138
160
  defaultCommand: "tui-cmd",
139
161
  });
140
- await app.run([]);
162
+ await app.runFromArgs([]);
141
163
  expect(cmd.executed).toBe(true);
142
164
  });
143
165
 
144
- test("runs specified command", async () => {
166
+ test("runs specified command and passes options", async () => {
145
167
  const cmd = new TestCommand();
146
168
  const app = new Application({
147
169
  name: "test-app",
148
170
  version: "1.0.0",
149
171
  commands: [cmd],
150
172
  });
151
- await app.run(["test"]);
152
- expect(cmd.executedWith).not.toBeNull();
173
+
174
+ await app.runFromArgs(["test", "--value", "hello"]);
175
+ expect(cmd.executedWith?.["value"]).toBe("hello");
153
176
  });
154
177
 
155
- test("passes options to command", async () => {
156
- const cmd = new TestCommand();
178
+ test("with no args and no default, prints help (no throw)", async () => {
157
179
  const app = new Application({
158
180
  name: "test-app",
159
181
  version: "1.0.0",
160
- commands: [cmd],
182
+ commands: [new TestCommand()],
161
183
  });
162
- await app.run(["test", "--value", "hello"]);
163
- expect(cmd.executedWith).not.toBeNull();
164
- expect(cmd.executedWith?.["value"]).toBe("hello");
184
+
185
+ await app.runFromArgs([]);
165
186
  });
166
187
  });
167
188
 
@@ -179,7 +200,7 @@ describe("Application", () => {
179
200
  called = true;
180
201
  },
181
202
  });
182
- await app.run(["test"]);
203
+ await app.runFromArgs(["test"]);
183
204
  expect(called).toBe(true);
184
205
  });
185
206
 
@@ -196,7 +217,7 @@ describe("Application", () => {
196
217
  called = true;
197
218
  },
198
219
  });
199
- await app.run(["test"]);
220
+ await app.runFromArgs(["test"]);
200
221
  expect(called).toBe(true);
201
222
  });
202
223
 
@@ -219,11 +240,11 @@ describe("Application", () => {
219
240
  commands: [new ErrorCommand()],
220
241
  });
221
242
  app.setHooks({
222
- onError: async (_ctx, error) => {
243
+ onError: async (error) => {
223
244
  errorCaught = error;
224
245
  },
225
246
  });
226
- await app.run(["error-cmd"]);
247
+ await app.runFromArgs(["error-cmd"]);
227
248
  expect(errorCaught?.message).toBe("Test error");
228
249
  });
229
250
  });
@@ -250,7 +271,6 @@ describe("Application", () => {
250
271
  readonly options = configOptions;
251
272
 
252
273
  override buildConfig(
253
- _ctx: AppContext,
254
274
  opts: OptionValues<typeof configOptions>
255
275
  ): ParsedConfig {
256
276
  buildConfigCalled = true;
@@ -260,7 +280,7 @@ describe("Application", () => {
260
280
  };
261
281
  }
262
282
 
263
- override async execute(_ctx: AppContext, config: ParsedConfig): Promise<void> {
283
+ override async execute(config: ParsedConfig): Promise<void> {
264
284
  receivedConfig = config;
265
285
  }
266
286
  }
@@ -271,7 +291,7 @@ describe("Application", () => {
271
291
  commands: [new ConfigCommand()],
272
292
  });
273
293
 
274
- await app.run(["config-cmd", "--value", "test", "--count", "42"]);
294
+ await app.runFromArgs(["config-cmd", "--value", "test", "--count", "42"]);
275
295
 
276
296
  expect(buildConfigCalled).toBe(true);
277
297
  expect(receivedConfig).toEqual({ value: "test", count: 42 });
@@ -286,7 +306,6 @@ describe("Application", () => {
286
306
  readonly options = testOptions;
287
307
 
288
308
  override async execute(
289
- _ctx: AppContext,
290
309
  opts: OptionValues<typeof testOptions>
291
310
  ): Promise<void> {
292
311
  receivedOpts = opts as Record<string, unknown>;
@@ -299,7 +318,7 @@ describe("Application", () => {
299
318
  commands: [new NoConfigCommand()],
300
319
  });
301
320
 
302
- await app.run(["no-config-cmd", "--value", "hello"]);
321
+ await app.runFromArgs(["no-config-cmd", "--value", "hello"]);
303
322
 
304
323
  expect(receivedOpts).toEqual({ value: "hello" });
305
324
  });
@@ -328,12 +347,12 @@ describe("Application", () => {
328
347
  });
329
348
 
330
349
  app.setHooks({
331
- onError: async (_ctx, error) => {
350
+ onError: async (error) => {
332
351
  errorCaught = error;
333
352
  },
334
353
  });
335
354
 
336
- await app.run(["fail-config", "--value", "test"]);
355
+ await app.runFromArgs(["fail-config", "--value", "test"]);
337
356
 
338
357
  expect(errorCaught?.message).toBe("Config validation failed");
339
358
  });
@@ -349,7 +368,7 @@ describe("Application", () => {
349
368
  });
350
369
 
351
370
  // Should not throw - global option should be parsed and removed
352
- await app.run(["--log-level", "debug", "test", "--value", "hello"]);
371
+ await app.runFromArgs(["--log-level", "debug", "test", "--value", "hello"]);
353
372
  expect(cmd.executedWith?.["value"]).toBe("hello");
354
373
  });
355
374
 
@@ -361,7 +380,7 @@ describe("Application", () => {
361
380
  commands: [cmd],
362
381
  });
363
382
 
364
- await app.run(["test", "--log-level", "debug", "--value", "hello"]);
383
+ await app.runFromArgs(["test", "--log-level", "debug", "--value", "hello"]);
365
384
  expect(cmd.executedWith?.["value"]).toBe("hello");
366
385
  });
367
386
 
@@ -374,14 +393,14 @@ describe("Application", () => {
374
393
  });
375
394
 
376
395
  // All of these should work (case-insensitive)
377
- await app.run(["--log-level", "debug", "test"]);
378
- expect(app.context.logger.getMinLevel()).toBe(2); // Debug = 2
396
+ await app.runFromArgs(["--log-level", "debug", "test"]);
397
+ expect(AppContext.current.logger.getMinLevel()).toBe(LogLevel.debug);
379
398
 
380
- await app.run(["--log-level", "Debug", "test"]);
381
- expect(app.context.logger.getMinLevel()).toBe(2);
399
+ await app.runFromArgs(["--log-level", "Debug", "test"]);
400
+ expect(AppContext.current.logger.getMinLevel()).toBe(LogLevel.debug);
382
401
 
383
- await app.run(["--log-level", "DEBUG", "test"]);
384
- expect(app.context.logger.getMinLevel()).toBe(2);
402
+ await app.runFromArgs(["--log-level", "DEBUG", "test"]);
403
+ expect(AppContext.current.logger.getMinLevel()).toBe(LogLevel.debug);
385
404
  });
386
405
 
387
406
  test("parses --detailed-logs flag", async () => {
@@ -392,7 +411,7 @@ describe("Application", () => {
392
411
  commands: [cmd],
393
412
  });
394
413
 
395
- await app.run(["--detailed-logs", "test"]);
414
+ await app.runFromArgs(["--detailed-logs", "test"]);
396
415
  // Should not throw - flag is recognized
397
416
  expect(cmd.executedWith).not.toBeNull();
398
417
  });
@@ -405,7 +424,7 @@ describe("Application", () => {
405
424
  commands: [cmd],
406
425
  });
407
426
 
408
- await app.run(["--no-detailed-logs", "test"]);
427
+ await app.runFromArgs(["--no-detailed-logs", "test"]);
409
428
  // Should not throw - flag is recognized
410
429
  expect(cmd.executedWith).not.toBeNull();
411
430
  });
@@ -418,8 +437,8 @@ describe("Application", () => {
418
437
  commands: [cmd],
419
438
  });
420
439
 
421
- await app.run(["--log-level=warn", "test"]);
422
- expect(app.context.logger.getMinLevel()).toBe(4); // Warn = 4
440
+ await app.runFromArgs(["--log-level=warn", "test"]);
441
+ expect(AppContext.current.logger.getMinLevel()).toBe(LogLevel.warn);
423
442
  });
424
443
  });
425
444
  });