@pablozaiden/terminatui 0.1.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 (95) hide show
  1. package/.devcontainer/devcontainer.json +19 -0
  2. package/.devcontainer/install-prerequisites.sh +49 -0
  3. package/.github/workflows/copilot-setup-steps.yml +32 -0
  4. package/.github/workflows/pull-request.yml +27 -0
  5. package/.github/workflows/release-npm-package.yml +78 -0
  6. package/LICENSE +21 -0
  7. package/README.md +524 -0
  8. package/examples/tui-app/commands/greet.ts +75 -0
  9. package/examples/tui-app/commands/index.ts +3 -0
  10. package/examples/tui-app/commands/math.ts +114 -0
  11. package/examples/tui-app/commands/status.ts +75 -0
  12. package/examples/tui-app/index.ts +34 -0
  13. package/guides/01-hello-world.md +96 -0
  14. package/guides/02-adding-options.md +103 -0
  15. package/guides/03-multiple-commands.md +163 -0
  16. package/guides/04-subcommands.md +206 -0
  17. package/guides/05-interactive-tui.md +194 -0
  18. package/guides/06-config-validation.md +264 -0
  19. package/guides/07-async-cancellation.md +388 -0
  20. package/guides/08-complete-application.md +673 -0
  21. package/guides/README.md +74 -0
  22. package/package.json +32 -0
  23. package/src/__tests__/application.test.ts +425 -0
  24. package/src/__tests__/buildCliCommand.test.ts +125 -0
  25. package/src/__tests__/builtins.test.ts +133 -0
  26. package/src/__tests__/colors.test.ts +127 -0
  27. package/src/__tests__/command.test.ts +157 -0
  28. package/src/__tests__/commandClass.test.ts +130 -0
  29. package/src/__tests__/context.test.ts +97 -0
  30. package/src/__tests__/help.test.ts +412 -0
  31. package/src/__tests__/parser.test.ts +268 -0
  32. package/src/__tests__/registry.test.ts +195 -0
  33. package/src/__tests__/registryNew.test.ts +160 -0
  34. package/src/__tests__/schemaToFields.test.ts +176 -0
  35. package/src/__tests__/table.test.ts +146 -0
  36. package/src/__tests__/tui.test.ts +26 -0
  37. package/src/builtins/help.ts +85 -0
  38. package/src/builtins/index.ts +4 -0
  39. package/src/builtins/settings.ts +106 -0
  40. package/src/builtins/version.ts +72 -0
  41. package/src/cli/help.ts +174 -0
  42. package/src/cli/index.ts +3 -0
  43. package/src/cli/output/colors.ts +74 -0
  44. package/src/cli/output/index.ts +2 -0
  45. package/src/cli/output/table.ts +141 -0
  46. package/src/cli/parser.ts +241 -0
  47. package/src/commands/help.ts +50 -0
  48. package/src/commands/index.ts +1 -0
  49. package/src/components/index.ts +147 -0
  50. package/src/core/application.ts +461 -0
  51. package/src/core/command.ts +269 -0
  52. package/src/core/context.ts +112 -0
  53. package/src/core/help.ts +214 -0
  54. package/src/core/index.ts +15 -0
  55. package/src/core/logger.ts +164 -0
  56. package/src/core/registry.ts +140 -0
  57. package/src/hooks/index.ts +131 -0
  58. package/src/index.ts +137 -0
  59. package/src/registry/commandRegistry.ts +77 -0
  60. package/src/registry/index.ts +1 -0
  61. package/src/tui/TuiApp.tsx +582 -0
  62. package/src/tui/TuiApplication.tsx +230 -0
  63. package/src/tui/app.ts +29 -0
  64. package/src/tui/components/ActionButton.tsx +36 -0
  65. package/src/tui/components/CliModal.tsx +81 -0
  66. package/src/tui/components/CommandSelector.tsx +159 -0
  67. package/src/tui/components/ConfigForm.tsx +148 -0
  68. package/src/tui/components/EditorModal.tsx +177 -0
  69. package/src/tui/components/FieldRow.tsx +30 -0
  70. package/src/tui/components/Header.tsx +31 -0
  71. package/src/tui/components/JsonHighlight.tsx +128 -0
  72. package/src/tui/components/LogsPanel.tsx +86 -0
  73. package/src/tui/components/ResultsPanel.tsx +93 -0
  74. package/src/tui/components/StatusBar.tsx +59 -0
  75. package/src/tui/components/index.ts +13 -0
  76. package/src/tui/components/types.ts +30 -0
  77. package/src/tui/context/KeyboardContext.tsx +118 -0
  78. package/src/tui/context/index.ts +7 -0
  79. package/src/tui/hooks/index.ts +35 -0
  80. package/src/tui/hooks/useClipboard.ts +66 -0
  81. package/src/tui/hooks/useCommandExecutor.ts +131 -0
  82. package/src/tui/hooks/useConfigState.ts +171 -0
  83. package/src/tui/hooks/useKeyboardHandler.ts +91 -0
  84. package/src/tui/hooks/useLogStream.ts +96 -0
  85. package/src/tui/hooks/useSpinner.ts +46 -0
  86. package/src/tui/index.ts +65 -0
  87. package/src/tui/theme.ts +21 -0
  88. package/src/tui/utils/buildCliCommand.ts +90 -0
  89. package/src/tui/utils/index.ts +13 -0
  90. package/src/tui/utils/parameterPersistence.ts +96 -0
  91. package/src/tui/utils/schemaToFields.ts +144 -0
  92. package/src/types/command.ts +103 -0
  93. package/src/types/execution.ts +11 -0
  94. package/src/types/index.ts +1 -0
  95. package/tsconfig.json +25 -0
@@ -0,0 +1,461 @@
1
+ import { AppContext, type AppConfig } from "./context.ts";
2
+ import { type AnyCommand, ConfigValidationError, type CommandResult } from "./command.ts";
3
+ import { CommandRegistry } from "./registry.ts";
4
+ import { ExecutionMode } from "../types/execution.ts";
5
+ import { LogLevel, type LoggerConfig } from "./logger.ts";
6
+ import { generateAppHelp, generateCommandHelp } from "./help.ts";
7
+ import {
8
+ createVersionCommand,
9
+ createHelpCommandForParent,
10
+ createRootHelpCommand,
11
+ } from "../builtins/index.ts";
12
+ import {
13
+ extractCommandChain,
14
+ schemaToParseArgsOptions,
15
+ parseOptionValues,
16
+ validateOptions,
17
+ } from "../cli/parser.ts";
18
+ import { parseArgs, type ParseArgsConfig } from "util";
19
+
20
+ /**
21
+ * Global options available on all commands.
22
+ * These are handled by the framework before dispatching to commands.
23
+ */
24
+ export interface GlobalOptions {
25
+ "log-level"?: string;
26
+ "detailed-logs"?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Application configuration options.
31
+ */
32
+ export interface ApplicationConfig {
33
+ /** Application name (used in CLI, help, version) */
34
+ name: string;
35
+ /** Display name for TUI (human-readable, e.g., "My App") */
36
+ displayName?: string;
37
+ /** Application version */
38
+ version: string;
39
+ /** Optional commit hash for version display (shows "(dev)" if not set) */
40
+ commitHash?: string;
41
+ /** Commands to register */
42
+ commands: AnyCommand[];
43
+ /** Default command when no args provided (by name) */
44
+ defaultCommand?: string;
45
+ /** Logger configuration */
46
+ logger?: LoggerConfig;
47
+ /** Additional config values */
48
+ config?: Record<string, unknown>;
49
+ }
50
+
51
+ /**
52
+ * Application lifecycle hooks.
53
+ */
54
+ export interface ApplicationHooks {
55
+ /** Called before running any command */
56
+ onBeforeRun?: (ctx: AppContext, commandName: string) => Promise<void> | void;
57
+ /** Called after command completes (success or failure) */
58
+ onAfterRun?: (ctx: AppContext, commandName: string, error?: Error) => Promise<void> | void;
59
+ /** Called when an error occurs */
60
+ onError?: (ctx: AppContext, error: Error) => Promise<void> | void;
61
+ }
62
+
63
+ /**
64
+ * Main application class.
65
+ *
66
+ * The Application is the entry point for a Terminatui-based CLI/TUI app.
67
+ * It manages the command registry, context, lifecycle, and execution flow.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * const app = new Application({
72
+ * name: "myapp",
73
+ * version: "1.0.0",
74
+ * commands: [new RunCommand(), new CheckCommand()],
75
+ * defaultCommand: "interactive",
76
+ * });
77
+ *
78
+ * await app.run(process.argv.slice(2));
79
+ * ```
80
+ */
81
+ export class Application {
82
+ readonly name: string;
83
+ readonly displayName: string;
84
+ readonly version: string;
85
+ readonly commitHash?: string;
86
+ readonly registry: CommandRegistry;
87
+ readonly context: AppContext;
88
+
89
+ private readonly defaultCommandName?: string;
90
+ private hooks: ApplicationHooks = {};
91
+
92
+ constructor(config: ApplicationConfig) {
93
+ this.name = config.name;
94
+ this.displayName = config.displayName ?? config.name;
95
+ this.version = config.version;
96
+ this.commitHash = config.commitHash;
97
+ this.defaultCommandName = config.defaultCommand;
98
+
99
+ // Create context
100
+ const appConfig: AppConfig = {
101
+ name: config.name,
102
+ version: config.version,
103
+ ...config.config,
104
+ };
105
+ this.context = new AppContext(appConfig, config.logger);
106
+ AppContext.setCurrent(this.context);
107
+
108
+ // Create registry and register commands
109
+ this.registry = new CommandRegistry();
110
+ this.registerCommands(config.commands);
111
+ }
112
+
113
+ /**
114
+ * Register commands and inject help subcommands.
115
+ */
116
+ private registerCommands(commands: AnyCommand[]): void {
117
+ // Register version command at top level
118
+ this.registry.register(createVersionCommand(this.name, this.version, this.commitHash));
119
+
120
+ // Register user commands with help injected
121
+ for (const command of commands) {
122
+ this.injectHelpCommand(command);
123
+ this.registry.register(command);
124
+ }
125
+
126
+ // Register root help command
127
+ this.registry.register(createRootHelpCommand(commands, this.name, this.version));
128
+ }
129
+
130
+ /**
131
+ * Recursively inject help subcommand into a command and its subcommands.
132
+ */
133
+ private injectHelpCommand(command: AnyCommand): void {
134
+ // Create help subcommand for this command
135
+ const helpCmd = createHelpCommandForParent(command, this.name, this.version);
136
+
137
+ // Initialize subCommands array if needed
138
+ if (!command.subCommands) {
139
+ command.subCommands = [];
140
+ }
141
+
142
+ // Add help as subcommand
143
+ command.subCommands.push(helpCmd);
144
+
145
+ // Recursively inject into subcommands
146
+ for (const subCommand of command.subCommands) {
147
+ if (subCommand.name !== "help") {
148
+ this.injectHelpCommand(subCommand);
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Set lifecycle hooks.
155
+ */
156
+ setHooks(hooks: ApplicationHooks): void {
157
+ this.hooks = { ...this.hooks, ...hooks };
158
+ }
159
+
160
+ /**
161
+ * Run the application with the given arguments.
162
+ *
163
+ * @param argv Command-line arguments (typically process.argv.slice(2))
164
+ */
165
+ async run(argv: string[]): Promise<void> {
166
+ try {
167
+ // Parse global options first
168
+ const { globalOptions, remainingArgs } = this.parseGlobalOptions(argv);
169
+ this.applyGlobalOptions(globalOptions);
170
+
171
+ // Extract command path from args
172
+ const { commands: commandPath, remaining: flagArgs } = extractCommandChain(remainingArgs);
173
+
174
+ // Resolve command
175
+ const { command, remainingPath } = this.resolveCommand(commandPath);
176
+
177
+ if (!command) {
178
+ // No command found - show help or run default
179
+ if (this.defaultCommandName && commandPath.length === 0) {
180
+ const defaultCmd = this.registry.get(this.defaultCommandName);
181
+ if (defaultCmd) {
182
+ await this.executeCommand(defaultCmd, flagArgs, []);
183
+ return;
184
+ }
185
+ }
186
+
187
+ // Show help
188
+ console.log(generateAppHelp(this.registry.list(), {
189
+ appName: this.name,
190
+ version: this.version,
191
+ }));
192
+ return;
193
+ }
194
+
195
+ // Check for unknown command in path
196
+ if (remainingPath.length > 0 && remainingPath[0] !== "help") {
197
+ console.error(`Unknown command: ${remainingPath.join(" ")}`);
198
+ process.exitCode = 1;
199
+ return;
200
+ }
201
+
202
+ // Execute the command
203
+ await this.executeCommand(command, flagArgs, commandPath);
204
+ } catch (error) {
205
+ await this.handleError(error as Error);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Resolve a command from the path.
211
+ */
212
+ private resolveCommand(commandPath: string[]): {
213
+ command: AnyCommand | undefined;
214
+ remainingPath: string[];
215
+ } {
216
+ if (commandPath.length === 0) {
217
+ return { command: undefined, remainingPath: [] };
218
+ }
219
+
220
+ const result = this.registry.resolve(commandPath);
221
+ return {
222
+ command: result.command,
223
+ remainingPath: result.remainingPath,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Execute a command with full lifecycle.
229
+ */
230
+ private async executeCommand(
231
+ command: AnyCommand,
232
+ flagArgs: string[],
233
+ commandPath: string[]
234
+ ): Promise<void> {
235
+ // Determine execution mode
236
+ const mode = this.detectExecutionMode(command, flagArgs);
237
+
238
+ // Parse options
239
+ const schema = command.options ?? {};
240
+ const parseArgsConfig = schemaToParseArgsOptions(schema);
241
+
242
+ let parsedValues: Record<string, unknown> = {};
243
+ let parseError: string | undefined;
244
+
245
+ try {
246
+ const parseArgsOptions = {
247
+ args: flagArgs,
248
+ options: parseArgsConfig.options as ParseArgsConfig["options"],
249
+ allowPositionals: false,
250
+ strict: true, // Enable strict mode to catch unknown options
251
+ };
252
+ const result = parseArgs(parseArgsOptions);
253
+ parsedValues = result.values;
254
+ } catch (err) {
255
+ // Capture parse error (e.g., unknown option)
256
+ parseError = (err as Error).message;
257
+ }
258
+
259
+ // If there was a parse error, show it and help
260
+ if (parseError) {
261
+ console.error(`Error: ${parseError}\n`);
262
+ console.log(generateCommandHelp(command, {
263
+ appName: this.name,
264
+ commandPath: commandPath.length > 0 ? commandPath : [command.name],
265
+ }));
266
+ process.exitCode = 1;
267
+ return;
268
+ }
269
+
270
+ let options;
271
+ try {
272
+ options = parseOptionValues(schema, parsedValues);
273
+ } catch (err) {
274
+ // Enum validation error from parseOptionValues
275
+ console.error(`Error: ${(err as Error).message}\n`);
276
+ console.log(generateCommandHelp(command, {
277
+ appName: this.name,
278
+ commandPath: commandPath.length > 0 ? commandPath : [command.name],
279
+ }));
280
+ process.exitCode = 1;
281
+ return;
282
+ }
283
+
284
+ // Validate options (required, min/max, etc.)
285
+ const errors = validateOptions(schema, options);
286
+ if (errors.length > 0) {
287
+ for (const error of errors) {
288
+ console.error(`Error: ${error.message}`);
289
+ }
290
+ console.log(); // Blank line
291
+ console.log(generateCommandHelp(command, {
292
+ appName: this.name,
293
+ commandPath: commandPath.length > 0 ? commandPath : [command.name],
294
+ }));
295
+ process.exitCode = 1;
296
+ return;
297
+ }
298
+
299
+ // Call onBeforeRun hook
300
+ if (this.hooks.onBeforeRun) {
301
+ await this.hooks.onBeforeRun(this.context, command.name);
302
+ }
303
+
304
+ let error: Error | undefined;
305
+
306
+ try {
307
+ // Call beforeExecute hook on command
308
+ if (command.beforeExecute) {
309
+ await command.beforeExecute(this.context, options);
310
+ }
311
+
312
+ // Build config if command implements buildConfig, otherwise pass options as-is
313
+ let config: unknown;
314
+ if (command.buildConfig) {
315
+ config = await command.buildConfig(this.context, options);
316
+ } else {
317
+ config = options;
318
+ }
319
+
320
+ // Execute the command with the config
321
+ const result = await command.execute(this.context, config);
322
+
323
+ // In CLI mode, handle result output
324
+ if (mode === ExecutionMode.Cli && result) {
325
+ const commandResult = result as CommandResult;
326
+ if (commandResult.success) {
327
+ // Output data as JSON to stdout if present
328
+ if (commandResult.data !== undefined) {
329
+ console.log(JSON.stringify(commandResult.data, null, 2));
330
+ }
331
+ } else {
332
+ // Set exit code for failures
333
+ process.exitCode = 1;
334
+ }
335
+ }
336
+ } catch (e) {
337
+ error = e as Error;
338
+ } finally {
339
+ // Always call afterExecute hook
340
+ if (command.afterExecute) {
341
+ try {
342
+ await command.afterExecute(this.context, options, error);
343
+ } catch (afterError) {
344
+ // afterExecute error takes precedence if no prior error
345
+ if (!error) {
346
+ error = afterError as Error;
347
+ }
348
+ }
349
+ }
350
+ }
351
+
352
+ // Call onAfterRun hook
353
+ if (this.hooks.onAfterRun) {
354
+ await this.hooks.onAfterRun(this.context, command.name, error);
355
+ }
356
+
357
+ // Re-throw if there was an error
358
+ if (error) {
359
+ throw error;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Detect the execution mode based on command and args.
365
+ */
366
+ private detectExecutionMode(command: AnyCommand, args: string[]): ExecutionMode {
367
+ // If no args and command supports TUI, use TUI mode
368
+ if (args.length === 0 && command.supportsTui()) {
369
+ return ExecutionMode.Tui;
370
+ }
371
+
372
+ // Otherwise use CLI mode
373
+ return ExecutionMode.Cli;
374
+ }
375
+
376
+ /**
377
+ * Parse global options from argv.
378
+ * Returns the parsed global options and remaining args.
379
+ */
380
+ private parseGlobalOptions(argv: string[]): {
381
+ globalOptions: GlobalOptions;
382
+ remainingArgs: string[];
383
+ } {
384
+ const globalOptions: GlobalOptions = {};
385
+ const remainingArgs: string[] = [];
386
+
387
+ let i = 0;
388
+ while (i < argv.length) {
389
+ const arg = argv[i]!;
390
+
391
+ if (arg === "--log-level" && i + 1 < argv.length) {
392
+ globalOptions["log-level"] = argv[i + 1];
393
+ i += 2;
394
+ } else if (arg.startsWith("--log-level=")) {
395
+ globalOptions["log-level"] = arg.slice("--log-level=".length);
396
+ i += 1;
397
+ } else if (arg === "--detailed-logs") {
398
+ globalOptions["detailed-logs"] = true;
399
+ i += 1;
400
+ } else if (arg === "--no-detailed-logs") {
401
+ globalOptions["detailed-logs"] = false;
402
+ i += 1;
403
+ } else {
404
+ remainingArgs.push(arg);
405
+ i += 1;
406
+ }
407
+ }
408
+
409
+ return { globalOptions, remainingArgs };
410
+ }
411
+
412
+ /**
413
+ * Apply global options to the application context.
414
+ */
415
+ private applyGlobalOptions(options: GlobalOptions): void {
416
+ const logger = this.context.logger;
417
+
418
+ // Apply detailed-logs
419
+ if (options["detailed-logs"] !== undefined) {
420
+ logger.setDetailed(options["detailed-logs"]);
421
+ }
422
+
423
+ // Apply log-level (case-insensitive)
424
+ if (options["log-level"] !== undefined) {
425
+ const levelStr = options["log-level"].toLowerCase();
426
+ // Find the matching log level (case-insensitive)
427
+ const level = Object.entries(LogLevel).find(
428
+ ([key, val]) => typeof val === "number" && key.toLowerCase() === levelStr
429
+ )?.[1] as LogLevel | undefined;
430
+ if (level !== undefined) {
431
+ logger.setMinLevel(level);
432
+ }
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Handle an error during execution.
438
+ */
439
+ private async handleError(error: Error): Promise<void> {
440
+ if (this.hooks.onError) {
441
+ await this.hooks.onError(this.context, error);
442
+ } else {
443
+ // Default error handling
444
+ if (error instanceof ConfigValidationError) {
445
+ // Format validation errors more clearly
446
+ const fieldInfo = error.field ? ` (${error.field})` : "";
447
+ console.error(`Configuration error${fieldInfo}: ${error.message}`);
448
+ } else {
449
+ console.error(`Error: ${error.message}`);
450
+ }
451
+ process.exitCode = 1;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Get the application context.
457
+ */
458
+ getContext(): AppContext {
459
+ return this.context;
460
+ }
461
+ }