@pablozaiden/terminatui 0.1.2 → 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.
- package/AGENTS.md +43 -0
- package/CLAUDE.md +1 -0
- package/README.md +64 -43
- package/bun.lock +85 -0
- package/examples/tui-app/commands/config/app/get.ts +62 -0
- package/examples/tui-app/commands/config/app/index.ts +23 -0
- package/examples/tui-app/commands/config/app/set.ts +96 -0
- package/examples/tui-app/commands/config/index.ts +28 -0
- package/examples/tui-app/commands/config/user/get.ts +61 -0
- package/examples/tui-app/commands/config/user/index.ts +23 -0
- package/examples/tui-app/commands/config/user/set.ts +57 -0
- package/examples/tui-app/commands/greet.ts +14 -11
- package/examples/tui-app/commands/math.ts +6 -9
- package/examples/tui-app/commands/status.ts +24 -13
- package/examples/tui-app/index.ts +7 -3
- package/guides/01-hello-world.md +7 -2
- package/guides/02-adding-options.md +2 -2
- package/guides/03-multiple-commands.md +6 -8
- package/guides/04-subcommands.md +8 -8
- package/guides/05-interactive-tui.md +45 -30
- package/guides/06-config-validation.md +4 -12
- package/guides/07-async-cancellation.md +15 -69
- package/guides/08-complete-application.md +13 -179
- package/guides/README.md +7 -3
- package/package.json +4 -8
- package/src/__tests__/application.test.ts +87 -68
- package/src/__tests__/buildCliCommand.test.ts +99 -119
- package/src/__tests__/builtins.test.ts +27 -75
- package/src/__tests__/command.test.ts +100 -131
- package/src/__tests__/context.test.ts +1 -26
- package/src/__tests__/helpCore.test.ts +227 -0
- package/src/__tests__/parser.test.ts +98 -244
- package/src/__tests__/registry.test.ts +33 -160
- package/src/__tests__/schemaToFields.test.ts +75 -158
- package/src/builtins/help.ts +19 -4
- package/src/builtins/settings.ts +18 -32
- package/src/builtins/version.ts +4 -4
- package/src/cli/output/colors.ts +1 -1
- package/src/cli/parser.ts +26 -95
- package/src/core/application.ts +192 -110
- package/src/core/command.ts +26 -9
- package/src/core/context.ts +31 -20
- package/src/core/help.ts +24 -18
- package/src/core/knownCommands.ts +13 -0
- package/src/core/logger.ts +39 -42
- package/src/core/registry.ts +5 -12
- package/src/tui/TuiApplication.tsx +63 -120
- package/src/tui/TuiRoot.tsx +135 -0
- package/src/tui/adapters/factory.ts +19 -0
- package/src/tui/adapters/ink/InkRenderer.tsx +135 -0
- package/src/tui/adapters/ink/components/Button.tsx +12 -0
- package/src/tui/adapters/ink/components/Code.tsx +6 -0
- package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
- package/src/tui/adapters/ink/components/Container.tsx +5 -0
- package/src/tui/adapters/ink/components/Field.tsx +12 -0
- package/src/tui/adapters/ink/components/Label.tsx +24 -0
- package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
- package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
- package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
- package/src/tui/adapters/ink/components/Panel.tsx +15 -0
- package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
- package/src/tui/adapters/ink/components/Select.tsx +44 -0
- package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
- package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
- package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
- package/src/tui/adapters/ink/components/Value.tsx +7 -0
- package/src/tui/adapters/ink/keyboard.ts +97 -0
- package/src/tui/adapters/ink/utils.ts +16 -0
- package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +115 -0
- package/src/tui/adapters/opentui/components/Button.tsx +13 -0
- package/src/tui/adapters/opentui/components/Code.tsx +12 -0
- package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
- package/src/tui/adapters/opentui/components/Container.tsx +56 -0
- package/src/tui/adapters/opentui/components/Field.tsx +18 -0
- package/src/tui/adapters/opentui/components/Label.tsx +15 -0
- package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
- package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
- package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
- package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
- package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
- package/src/tui/adapters/opentui/components/Select.tsx +59 -0
- package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
- package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
- package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
- package/src/tui/adapters/opentui/components/Value.tsx +13 -0
- package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
- package/src/tui/adapters/opentui/keyboard.ts +61 -0
- package/src/tui/adapters/types.ts +70 -0
- package/src/tui/components/ActionButton.tsx +0 -36
- package/src/tui/components/CommandSelector.tsx +52 -92
- package/src/tui/components/ConfigForm.tsx +68 -42
- package/src/tui/components/FieldRow.tsx +0 -30
- package/src/tui/components/Header.tsx +14 -13
- package/src/tui/components/JsonHighlight.tsx +10 -17
- package/src/tui/components/ModalBase.tsx +38 -0
- package/src/tui/components/ResultsPanel.tsx +27 -36
- package/src/tui/components/StatusBar.tsx +24 -39
- package/src/tui/components/logColors.ts +12 -0
- package/src/tui/context/ClipboardContext.tsx +87 -0
- package/src/tui/context/ExecutorContext.tsx +139 -0
- package/src/tui/context/KeyboardContext.tsx +85 -71
- package/src/tui/context/LogsContext.tsx +35 -0
- package/src/tui/context/NavigationContext.tsx +194 -0
- package/src/tui/context/RendererContext.tsx +20 -0
- package/src/tui/context/TuiAppContext.tsx +58 -0
- package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
- package/src/tui/hooks/useBackHandler.ts +34 -0
- package/src/tui/hooks/useClipboard.ts +40 -25
- package/src/tui/hooks/useClipboardProvider.ts +42 -0
- package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
- package/src/tui/modals/CliModal.tsx +82 -0
- package/src/tui/modals/EditorModal.tsx +207 -0
- package/src/tui/modals/LogsModal.tsx +98 -0
- package/src/tui/registry.ts +102 -0
- package/src/tui/screens/CommandSelectScreen.tsx +162 -0
- package/src/tui/screens/ConfigScreen.tsx +160 -0
- package/src/tui/screens/ErrorScreen.tsx +58 -0
- package/src/tui/screens/ResultsScreen.tsx +60 -0
- package/src/tui/screens/RunningScreen.tsx +72 -0
- package/src/tui/screens/ScreenBase.ts +6 -0
- package/src/tui/semantic/Button.tsx +7 -0
- package/src/tui/semantic/Code.tsx +7 -0
- package/src/tui/semantic/CodeHighlight.tsx +7 -0
- package/src/tui/semantic/Container.tsx +7 -0
- package/src/tui/semantic/Field.tsx +7 -0
- package/src/tui/semantic/Label.tsx +7 -0
- package/src/tui/semantic/MenuButton.tsx +7 -0
- package/src/tui/semantic/MenuItem.tsx +7 -0
- package/src/tui/semantic/Overlay.tsx +7 -0
- package/src/tui/semantic/Panel.tsx +7 -0
- package/src/tui/semantic/ScrollView.tsx +9 -0
- package/src/tui/semantic/Select.tsx +7 -0
- package/src/tui/semantic/Spacer.tsx +7 -0
- package/src/tui/semantic/Spinner.tsx +7 -0
- package/src/tui/semantic/TextInput.tsx +7 -0
- package/src/tui/semantic/Value.tsx +7 -0
- package/src/tui/semantic/types.ts +195 -0
- package/src/tui/theme.ts +25 -14
- package/src/tui/utils/buildCliCommand.ts +1 -0
- package/src/tui/utils/getEnumKeys.ts +3 -0
- package/src/tui/utils/parameterPersistence.ts +1 -0
- package/src/types/command.ts +0 -60
- package/examples/tui-app/commands/index.ts +0 -3
- package/src/__tests__/colors.test.ts +0 -127
- package/src/__tests__/commandClass.test.ts +0 -130
- package/src/__tests__/help.test.ts +0 -412
- package/src/__tests__/registryNew.test.ts +0 -160
- package/src/__tests__/table.test.ts +0 -146
- package/src/__tests__/tui.test.ts +0 -26
- package/src/builtins/index.ts +0 -4
- package/src/cli/help.ts +0 -174
- package/src/cli/index.ts +0 -3
- package/src/cli/output/index.ts +0 -2
- package/src/cli/output/table.ts +0 -141
- package/src/commands/help.ts +0 -50
- package/src/commands/index.ts +0 -1
- package/src/components/index.ts +0 -147
- package/src/core/index.ts +0 -15
- package/src/hooks/index.ts +0 -131
- package/src/index.ts +0 -137
- package/src/registry/commandRegistry.ts +0 -77
- package/src/registry/index.ts +0 -1
- package/src/tui/TuiApp.tsx +0 -582
- package/src/tui/app.ts +0 -29
- package/src/tui/components/CliModal.tsx +0 -81
- package/src/tui/components/EditorModal.tsx +0 -177
- package/src/tui/components/LogsPanel.tsx +0 -86
- package/src/tui/components/index.ts +0 -13
- package/src/tui/context/index.ts +0 -7
- package/src/tui/hooks/index.ts +0 -35
- package/src/tui/hooks/useKeyboardHandler.ts +0 -91
- package/src/tui/hooks/useLogStream.ts +0 -96
- package/src/tui/index.ts +0 -65
- package/src/tui/utils/index.ts +0 -13
- package/src/types/index.ts +0 -1
package/src/core/application.ts
CHANGED
|
@@ -1,31 +1,52 @@
|
|
|
1
1
|
import { AppContext, type AppConfig } from "./context.ts";
|
|
2
|
-
import { type AnyCommand, ConfigValidationError, type CommandResult } from "./command.ts";
|
|
2
|
+
import { type AnyCommand, ConfigValidationError, type CommandResult, type CommandExecutionContext } from "./command.ts";
|
|
3
3
|
import { CommandRegistry } from "./registry.ts";
|
|
4
4
|
import { ExecutionMode } from "../types/execution.ts";
|
|
5
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
6
|
import {
|
|
13
7
|
extractCommandChain,
|
|
14
8
|
schemaToParseArgsOptions,
|
|
15
9
|
parseOptionValues,
|
|
16
10
|
validateOptions,
|
|
17
11
|
} from "../cli/parser.ts";
|
|
12
|
+
import type { OptionSchema } from "../types/command.ts";
|
|
18
13
|
import { parseArgs, type ParseArgsConfig } from "util";
|
|
14
|
+
import { createVersionCommand } from "../builtins/version.ts";
|
|
15
|
+
import { createHelpCommandForParent, createRootHelpCommand } from "../builtins/help.ts";
|
|
16
|
+
import { KNOWN_COMMANDS, RESERVED_TOP_LEVEL_COMMAND_NAMES } from "./knownCommands.ts";
|
|
19
17
|
|
|
20
18
|
/**
|
|
21
19
|
* Global options available on all commands.
|
|
22
20
|
* These are handled by the framework before dispatching to commands.
|
|
23
21
|
*/
|
|
22
|
+
|
|
23
|
+
export type TuiModeOptions = "opentui" | "ink";
|
|
24
|
+
export type ModeOptions = TuiModeOptions | "cli" | "default";
|
|
25
|
+
|
|
24
26
|
export interface GlobalOptions {
|
|
25
27
|
"log-level"?: string;
|
|
26
28
|
"detailed-logs"?: boolean;
|
|
29
|
+
"mode"?: string;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
export const GLOBAL_OPTIONS_SCHEMA = {
|
|
33
|
+
"log-level": {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Minimum log level (e.g. info, debug)",
|
|
36
|
+
},
|
|
37
|
+
"detailed-logs": {
|
|
38
|
+
type: "boolean",
|
|
39
|
+
description: "Enable detailed logging",
|
|
40
|
+
default: false,
|
|
41
|
+
},
|
|
42
|
+
mode: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Execution mode",
|
|
45
|
+
default: "default",
|
|
46
|
+
enum: ["opentui", "ink", "cli", "default"],
|
|
47
|
+
},
|
|
48
|
+
} satisfies OptionSchema;
|
|
49
|
+
|
|
29
50
|
/**
|
|
30
51
|
* Application configuration options.
|
|
31
52
|
*/
|
|
@@ -53,11 +74,11 @@ export interface ApplicationConfig {
|
|
|
53
74
|
*/
|
|
54
75
|
export interface ApplicationHooks {
|
|
55
76
|
/** Called before running any command */
|
|
56
|
-
onBeforeRun?: (
|
|
77
|
+
onBeforeRun?: (commandName: string) => Promise<void> | void;
|
|
57
78
|
/** Called after command completes (success or failure) */
|
|
58
|
-
onAfterRun?: (
|
|
79
|
+
onAfterRun?: (commandName: string, error?: Error) => Promise<void> | void;
|
|
59
80
|
/** Called when an error occurs */
|
|
60
|
-
onError?: (
|
|
81
|
+
onError?: (error: Error) => Promise<void> | void;
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
/**
|
|
@@ -72,19 +93,24 @@ export interface ApplicationHooks {
|
|
|
72
93
|
* name: "myapp",
|
|
73
94
|
* version: "1.0.0",
|
|
74
95
|
* commands: [new RunCommand(), new CheckCommand()],
|
|
75
|
-
* defaultCommand: "
|
|
96
|
+
* defaultCommand: "version",
|
|
76
97
|
* });
|
|
77
98
|
*
|
|
78
|
-
* await app.run(
|
|
99
|
+
* await app.run();
|
|
79
100
|
* ```
|
|
80
101
|
*/
|
|
81
102
|
export class Application {
|
|
82
103
|
readonly name: string;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Default mode used when `--mode=default` is specified.
|
|
107
|
+
* Base Application defaults to `cli`.
|
|
108
|
+
*/
|
|
109
|
+
protected defaultMode: ModeOptions = "cli";
|
|
83
110
|
readonly displayName: string;
|
|
84
111
|
readonly version: string;
|
|
85
112
|
readonly commitHash?: string;
|
|
86
113
|
readonly registry: CommandRegistry;
|
|
87
|
-
readonly context: AppContext;
|
|
88
114
|
|
|
89
115
|
private readonly defaultCommandName?: string;
|
|
90
116
|
private hooks: ApplicationHooks = {};
|
|
@@ -102,8 +128,10 @@ export class Application {
|
|
|
102
128
|
version: config.version,
|
|
103
129
|
...config.config,
|
|
104
130
|
};
|
|
105
|
-
|
|
106
|
-
AppContext.setCurrent(
|
|
131
|
+
const context = new AppContext(appConfig, config.logger);
|
|
132
|
+
AppContext.setCurrent(context);
|
|
133
|
+
|
|
134
|
+
context.logger.silly(`Application initialized: ${this.name} v${this.version}`);
|
|
107
135
|
|
|
108
136
|
// Create registry and register commands
|
|
109
137
|
this.registry = new CommandRegistry();
|
|
@@ -114,6 +142,8 @@ export class Application {
|
|
|
114
142
|
* Register commands and inject help subcommands.
|
|
115
143
|
*/
|
|
116
144
|
private registerCommands(commands: AnyCommand[]): void {
|
|
145
|
+
this.assertNoReservedCommands(commands);
|
|
146
|
+
|
|
117
147
|
// Register version command at top level
|
|
118
148
|
this.registry.register(createVersionCommand(this.name, this.version, this.commitHash));
|
|
119
149
|
|
|
@@ -124,7 +154,35 @@ export class Application {
|
|
|
124
154
|
}
|
|
125
155
|
|
|
126
156
|
// Register root help command
|
|
127
|
-
|
|
157
|
+
// Use the full registry list so built-ins like `version` are included.
|
|
158
|
+
this.registry.register(createRootHelpCommand(this.registry.list(), this.name, this.version));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private assertNoReservedCommands(commands: AnyCommand[]): void {
|
|
162
|
+
for (const command of commands) {
|
|
163
|
+
this.assertNoReservedCommand(command, []);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private assertNoReservedCommand(command: AnyCommand, path: string[]): void {
|
|
168
|
+
if (RESERVED_TOP_LEVEL_COMMAND_NAMES.has(command.name as never)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Command name '${command.name}' is reserved by Terminatui and cannot be registered`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (command.subCommands) {
|
|
175
|
+
for (const subCommand of command.subCommands) {
|
|
176
|
+
if (subCommand.name === KNOWN_COMMANDS.help) {
|
|
177
|
+
const commandPath = [...path, command.name].join(" ");
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Subcommand name '${KNOWN_COMMANDS.help}' is reserved and is automatically injected (found under '${commandPath}')`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.assertNoReservedCommand(subCommand, [...path, command.name]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
128
186
|
}
|
|
129
187
|
|
|
130
188
|
/**
|
|
@@ -144,10 +202,11 @@ export class Application {
|
|
|
144
202
|
|
|
145
203
|
// Recursively inject into subcommands
|
|
146
204
|
for (const subCommand of command.subCommands) {
|
|
147
|
-
if (subCommand.name !==
|
|
205
|
+
if (subCommand.name !== KNOWN_COMMANDS.help) {
|
|
148
206
|
this.injectHelpCommand(subCommand);
|
|
149
207
|
}
|
|
150
208
|
}
|
|
209
|
+
|
|
151
210
|
}
|
|
152
211
|
|
|
153
212
|
/**
|
|
@@ -158,16 +217,39 @@ export class Application {
|
|
|
158
217
|
}
|
|
159
218
|
|
|
160
219
|
/**
|
|
161
|
-
* Run the application
|
|
162
|
-
*
|
|
163
|
-
*
|
|
220
|
+
* Run the application using Bun's process args.
|
|
221
|
+
*
|
|
222
|
+
* This is the common entrypoint for real apps.
|
|
223
|
+
*/
|
|
224
|
+
async run(): Promise<void> {
|
|
225
|
+
return this.runFromArgs(Bun.argv.slice(2));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Run the application with explicit argv.
|
|
230
|
+
*
|
|
231
|
+
* Useful for tests or manual programmatic invocation.
|
|
164
232
|
*/
|
|
165
|
-
async
|
|
233
|
+
async runFromArgs(argv: string[]): Promise<void> {
|
|
234
|
+
// configure logger
|
|
235
|
+
AppContext.current.logger.onLogEvent((event) => {
|
|
236
|
+
process.stderr.write(event.message + "\n");
|
|
237
|
+
});
|
|
238
|
+
|
|
166
239
|
try {
|
|
167
240
|
// Parse global options first
|
|
168
241
|
const { globalOptions, remainingArgs } = this.parseGlobalOptions(argv);
|
|
169
242
|
this.applyGlobalOptions(globalOptions);
|
|
170
243
|
|
|
244
|
+
const mode = globalOptions["mode"] as ModeOptions ?? "default";
|
|
245
|
+
const resolvedMode = mode === "default" ? this.defaultMode : mode;
|
|
246
|
+
|
|
247
|
+
if (resolvedMode !== "cli") {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Mode '${resolvedMode}' is not supported by Application. Use TuiApplication or set --mode=cli.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
171
253
|
// Extract command path from args
|
|
172
254
|
const { commands: commandPath, remaining: flagArgs } = extractCommandChain(remainingArgs);
|
|
173
255
|
|
|
@@ -185,16 +267,18 @@ export class Application {
|
|
|
185
267
|
}
|
|
186
268
|
|
|
187
269
|
// Show help
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
270
|
+
const rootHelp = this.registry.get(KNOWN_COMMANDS.help);
|
|
271
|
+
if (rootHelp) {
|
|
272
|
+
await this.executeCommand(rootHelp, [], [KNOWN_COMMANDS.help]);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
throw new Error("Root help command not registered");
|
|
193
277
|
}
|
|
194
278
|
|
|
195
279
|
// Check for unknown command in path
|
|
196
|
-
if (remainingPath.length > 0 && remainingPath[0] !==
|
|
197
|
-
|
|
280
|
+
if (remainingPath.length > 0 && remainingPath[0] !== KNOWN_COMMANDS.help) {
|
|
281
|
+
AppContext.current.logger.error(`Unknown command: ${remainingPath.join(" ")}`);
|
|
198
282
|
process.exitCode = 1;
|
|
199
283
|
return;
|
|
200
284
|
}
|
|
@@ -240,65 +324,44 @@ export class Application {
|
|
|
240
324
|
const parseArgsConfig = schemaToParseArgsOptions(schema);
|
|
241
325
|
|
|
242
326
|
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
327
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
328
|
+
const parseArgsOptions = {
|
|
329
|
+
args: flagArgs,
|
|
330
|
+
options: parseArgsConfig.options as ParseArgsConfig["options"],
|
|
331
|
+
allowNegative: true,
|
|
332
|
+
allowPositionals: false,
|
|
333
|
+
strict: false,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const result = parseArgs(parseArgsOptions);
|
|
337
|
+
parsedValues = result.values;
|
|
269
338
|
|
|
270
339
|
let options;
|
|
271
340
|
try {
|
|
272
341
|
options = parseOptionValues(schema, parsedValues);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// Enum validation error from parseOptionValues
|
|
344
|
+
AppContext.current.logger.error(`Error: ${(err as Error).message}\n`);
|
|
345
|
+
await this.printHelpForCommand(command, commandPath);
|
|
346
|
+
process.exitCode = 1;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
283
350
|
|
|
284
351
|
// Validate options (required, min/max, etc.)
|
|
285
352
|
const errors = validateOptions(schema, options);
|
|
286
353
|
if (errors.length > 0) {
|
|
287
354
|
for (const error of errors) {
|
|
288
|
-
|
|
355
|
+
AppContext.current.logger.error(`Error: ${error.message}`);
|
|
289
356
|
}
|
|
290
|
-
|
|
291
|
-
console.log(generateCommandHelp(command, {
|
|
292
|
-
appName: this.name,
|
|
293
|
-
commandPath: commandPath.length > 0 ? commandPath : [command.name],
|
|
294
|
-
}));
|
|
357
|
+
await this.printHelpForCommand(command, commandPath);
|
|
295
358
|
process.exitCode = 1;
|
|
296
359
|
return;
|
|
297
360
|
}
|
|
298
361
|
|
|
299
362
|
// Call onBeforeRun hook
|
|
300
363
|
if (this.hooks.onBeforeRun) {
|
|
301
|
-
await this.hooks.onBeforeRun(
|
|
364
|
+
await this.hooks.onBeforeRun(command.name);
|
|
302
365
|
}
|
|
303
366
|
|
|
304
367
|
let error: Error | undefined;
|
|
@@ -306,20 +369,21 @@ export class Application {
|
|
|
306
369
|
try {
|
|
307
370
|
// Call beforeExecute hook on command
|
|
308
371
|
if (command.beforeExecute) {
|
|
309
|
-
await command.beforeExecute(
|
|
372
|
+
await command.beforeExecute(options);
|
|
310
373
|
}
|
|
311
374
|
|
|
312
375
|
// Build config if command implements buildConfig, otherwise pass options as-is
|
|
313
376
|
let config: unknown;
|
|
314
377
|
if (command.buildConfig) {
|
|
315
|
-
config = await command.buildConfig(
|
|
378
|
+
config = await command.buildConfig(options);
|
|
316
379
|
} else {
|
|
317
380
|
config = options;
|
|
318
381
|
}
|
|
319
382
|
|
|
320
383
|
// Execute the command with the config
|
|
321
|
-
const
|
|
322
|
-
|
|
384
|
+
const ctx: CommandExecutionContext = { signal: new AbortController().signal };
|
|
385
|
+
const result = await command.execute(config, ctx);
|
|
386
|
+
|
|
323
387
|
// In CLI mode, handle result output
|
|
324
388
|
if (mode === ExecutionMode.Cli && result) {
|
|
325
389
|
const commandResult = result as CommandResult;
|
|
@@ -339,7 +403,7 @@ export class Application {
|
|
|
339
403
|
// Always call afterExecute hook
|
|
340
404
|
if (command.afterExecute) {
|
|
341
405
|
try {
|
|
342
|
-
await command.afterExecute(
|
|
406
|
+
await command.afterExecute(options, error);
|
|
343
407
|
} catch (afterError) {
|
|
344
408
|
// afterExecute error takes precedence if no prior error
|
|
345
409
|
if (!error) {
|
|
@@ -351,7 +415,7 @@ export class Application {
|
|
|
351
415
|
|
|
352
416
|
// Call onAfterRun hook
|
|
353
417
|
if (this.hooks.onAfterRun) {
|
|
354
|
-
await this.hooks.onAfterRun(
|
|
418
|
+
await this.hooks.onAfterRun(command.name, error);
|
|
355
419
|
}
|
|
356
420
|
|
|
357
421
|
// Re-throw if there was an error
|
|
@@ -360,6 +424,17 @@ export class Application {
|
|
|
360
424
|
}
|
|
361
425
|
}
|
|
362
426
|
|
|
427
|
+
private async printHelpForCommand(command: AnyCommand, commandPath: string[]): Promise<void> {
|
|
428
|
+
const resolvedCommandPath = commandPath.length > 0 ? commandPath : [command.name];
|
|
429
|
+
|
|
430
|
+
const helpCommand = command.subCommands?.find((sub) => sub.name === KNOWN_COMMANDS.help);
|
|
431
|
+
if (!helpCommand) {
|
|
432
|
+
throw new Error(`Help command not injected for '${resolvedCommandPath.join(" ")}'`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
await this.executeCommand(helpCommand, [], [...resolvedCommandPath, KNOWN_COMMANDS.help]);
|
|
436
|
+
}
|
|
437
|
+
|
|
363
438
|
/**
|
|
364
439
|
* Detect the execution mode based on command and args.
|
|
365
440
|
*/
|
|
@@ -377,43 +452,57 @@ export class Application {
|
|
|
377
452
|
* Parse global options from argv.
|
|
378
453
|
* Returns the parsed global options and remaining args.
|
|
379
454
|
*/
|
|
380
|
-
|
|
455
|
+
protected parseGlobalOptions(argv: string[]): {
|
|
381
456
|
globalOptions: GlobalOptions;
|
|
382
457
|
remainingArgs: string[];
|
|
383
458
|
} {
|
|
384
|
-
const
|
|
459
|
+
const parseArgsConfig = schemaToParseArgsOptions(GLOBAL_OPTIONS_SCHEMA);
|
|
460
|
+
|
|
461
|
+
const result = parseArgs({
|
|
462
|
+
args: argv,
|
|
463
|
+
options: parseArgsConfig.options as ParseArgsConfig["options"],
|
|
464
|
+
allowPositionals: true,
|
|
465
|
+
allowNegative: true,
|
|
466
|
+
strict: false,
|
|
467
|
+
tokens: true,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const rawGlobalOptions = parseOptionValues(GLOBAL_OPTIONS_SCHEMA, result.values) as GlobalOptions;
|
|
471
|
+
|
|
472
|
+
const globalOptions: GlobalOptions = { ...rawGlobalOptions };
|
|
473
|
+
|
|
385
474
|
const remainingArgs: string[] = [];
|
|
475
|
+
for (const token of result.tokens ?? []) {
|
|
476
|
+
if (token.kind === "positional") {
|
|
477
|
+
remainingArgs.push(token.value);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
386
480
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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;
|
|
481
|
+
if (token.kind === "option") {
|
|
482
|
+
const name = token.name;
|
|
483
|
+
if (name && !(name in GLOBAL_OPTIONS_SCHEMA)) {
|
|
484
|
+
remainingArgs.push(token.rawName);
|
|
485
|
+
|
|
486
|
+
if (token.value !== undefined) {
|
|
487
|
+
remainingArgs.push(String(token.value));
|
|
488
|
+
} else if (token.inlineValue !== undefined) {
|
|
489
|
+
remainingArgs.push(String((token as { inlineValue?: unknown }).inlineValue));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
406
492
|
}
|
|
407
493
|
}
|
|
408
494
|
|
|
409
|
-
return {
|
|
495
|
+
return {
|
|
496
|
+
globalOptions,
|
|
497
|
+
remainingArgs,
|
|
498
|
+
};
|
|
410
499
|
}
|
|
411
500
|
|
|
412
501
|
/**
|
|
413
502
|
* Apply global options to the application context.
|
|
414
503
|
*/
|
|
415
|
-
|
|
416
|
-
const logger =
|
|
504
|
+
protected applyGlobalOptions(options: GlobalOptions): void {
|
|
505
|
+
const logger = AppContext.current.logger;
|
|
417
506
|
|
|
418
507
|
// Apply detailed-logs
|
|
419
508
|
if (options["detailed-logs"] !== undefined) {
|
|
@@ -438,24 +527,17 @@ export class Application {
|
|
|
438
527
|
*/
|
|
439
528
|
private async handleError(error: Error): Promise<void> {
|
|
440
529
|
if (this.hooks.onError) {
|
|
441
|
-
await this.hooks.onError(
|
|
530
|
+
await this.hooks.onError(error);
|
|
442
531
|
} else {
|
|
443
532
|
// Default error handling
|
|
444
533
|
if (error instanceof ConfigValidationError) {
|
|
445
534
|
// Format validation errors more clearly
|
|
446
535
|
const fieldInfo = error.field ? ` (${error.field})` : "";
|
|
447
|
-
|
|
536
|
+
AppContext.current.logger.error(`Configuration error${fieldInfo}: ${error.message}`);
|
|
448
537
|
} else {
|
|
449
|
-
|
|
538
|
+
AppContext.current.logger.error(`Error: ${error.message}`);
|
|
450
539
|
}
|
|
451
540
|
process.exitCode = 1;
|
|
452
541
|
}
|
|
453
542
|
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Get the application context.
|
|
457
|
-
*/
|
|
458
|
-
getContext(): AppContext {
|
|
459
|
-
return this.context;
|
|
460
|
-
}
|
|
461
543
|
}
|
package/src/core/command.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import type { AppContext } from "./context.ts";
|
|
3
2
|
import type { OptionSchema, OptionValues } from "../types/command.ts";
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -93,7 +92,7 @@ export type AnyCommand = Command<any, any>;
|
|
|
93
92
|
* description = "Run the application";
|
|
94
93
|
* options = runOptions;
|
|
95
94
|
*
|
|
96
|
-
* async buildConfig(
|
|
95
|
+
* async buildConfig(opts: OptionValues<typeof runOptions>): Promise<RunConfig> {
|
|
97
96
|
* const repoPath = path.resolve(opts.repo);
|
|
98
97
|
* if (!existsSync(repoPath)) {
|
|
99
98
|
* throw new ConfigValidationError(`Repository not found: ${repoPath}`, "repo");
|
|
@@ -101,7 +100,7 @@ export type AnyCommand = Command<any, any>;
|
|
|
101
100
|
* return { repoPath, iterations: parseInt(opts.iterations) };
|
|
102
101
|
* }
|
|
103
102
|
*
|
|
104
|
-
* async execute(
|
|
103
|
+
* async execute(config: RunConfig) {
|
|
105
104
|
* // config is already validated
|
|
106
105
|
* return { success: true, data: result };
|
|
107
106
|
* }
|
|
@@ -141,6 +140,9 @@ export abstract class Command<
|
|
|
141
140
|
/** Whether this command runs immediately without config screen (like "check") */
|
|
142
141
|
immediateExecution?: boolean;
|
|
143
142
|
|
|
143
|
+
/** If true, this command should not appear in the TUI command list. */
|
|
144
|
+
tuiHidden?: boolean;
|
|
145
|
+
|
|
144
146
|
/**
|
|
145
147
|
* Build and validate a configuration object from parsed options.
|
|
146
148
|
*
|
|
@@ -153,24 +155,23 @@ export abstract class Command<
|
|
|
153
155
|
* @throws ConfigValidationError if validation fails
|
|
154
156
|
* @returns The validated configuration object
|
|
155
157
|
*/
|
|
156
|
-
buildConfig?(
|
|
158
|
+
buildConfig?(opts: OptionValues<TOptions>): Promise<TConfig> | TConfig;
|
|
157
159
|
|
|
158
160
|
/**
|
|
159
161
|
* Execute the command.
|
|
160
162
|
* The framework will call this method for both CLI and TUI modes.
|
|
161
163
|
*
|
|
162
|
-
* @param ctx - Application context
|
|
163
164
|
* @param config - The configuration object (from buildConfig, or raw options if buildConfig is not implemented)
|
|
164
165
|
* @param execCtx - Execution context with abort signal for cancellation support
|
|
165
166
|
* @returns Optional result for display in TUI results panel
|
|
166
167
|
*/
|
|
167
|
-
abstract execute(
|
|
168
|
+
abstract execute(config: TConfig, execCtx?: CommandExecutionContext): Promise<CommandResult | void> | CommandResult | void;
|
|
168
169
|
|
|
169
170
|
/**
|
|
170
171
|
* Called before buildConfig. Use for early validation, resource acquisition, etc.
|
|
171
172
|
* If this throws, buildConfig and execute will not be called but afterExecute will still run.
|
|
172
173
|
*/
|
|
173
|
-
beforeExecute?(
|
|
174
|
+
beforeExecute?(opts: OptionValues<TOptions>): Promise<void> | void;
|
|
174
175
|
|
|
175
176
|
/**
|
|
176
177
|
* Called after execute, even if execute threw an error.
|
|
@@ -178,7 +179,6 @@ export abstract class Command<
|
|
|
178
179
|
* @param error The error thrown by beforeExecute, buildConfig, or execute, if any
|
|
179
180
|
*/
|
|
180
181
|
afterExecute?(
|
|
181
|
-
ctx: AppContext,
|
|
182
182
|
opts: OptionValues<TOptions>,
|
|
183
183
|
error?: Error
|
|
184
184
|
): Promise<void> | void;
|
|
@@ -250,7 +250,24 @@ export abstract class Command<
|
|
|
250
250
|
* Called by the framework during registration.
|
|
251
251
|
*/
|
|
252
252
|
validate(): void {
|
|
253
|
-
|
|
253
|
+
this.validateSubCommands();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private validateSubCommands(): void {
|
|
257
|
+
if (!this.subCommands || this.subCommands.length === 0) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const names = new Set<string>();
|
|
262
|
+
for (const subCommand of this.subCommands) {
|
|
263
|
+
if (names.has(subCommand.name)) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Duplicate subcommand '${subCommand.name}' under '${this.name}'`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
names.add(subCommand.name);
|
|
269
|
+
subCommand.validate();
|
|
270
|
+
}
|
|
254
271
|
}
|
|
255
272
|
|
|
256
273
|
/**
|
package/src/core/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Logger, createLogger, type LoggerConfig } from "./logger.ts";
|
|
1
|
+
import { type Logger, createLogger, type LoggerConfig, type LogEvent } from "./logger.ts";
|
|
2
|
+
import { appendFileSync } from "fs";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Application configuration stored in context.
|
|
@@ -8,10 +9,14 @@ export interface AppConfig {
|
|
|
8
9
|
name: string;
|
|
9
10
|
/** Application version */
|
|
10
11
|
version: string;
|
|
12
|
+
/** Log target */
|
|
13
|
+
logTarget?: LogTarget[];
|
|
11
14
|
/** Additional configuration values */
|
|
12
15
|
[key: string]: unknown;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
export type LogTarget = "memory" | "file" | "none";
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* AppContext is the central container for application-wide services and state.
|
|
17
22
|
* It holds the logger, configuration, and a generic service registry.
|
|
@@ -22,6 +27,7 @@ export interface AppConfig {
|
|
|
22
27
|
export class AppContext {
|
|
23
28
|
private static _current: AppContext | null = null;
|
|
24
29
|
private readonly services = new Map<string, unknown>();
|
|
30
|
+
private readonly startTime = Date.now();
|
|
25
31
|
|
|
26
32
|
/** The application logger */
|
|
27
33
|
public readonly logger: Logger;
|
|
@@ -29,9 +35,27 @@ export class AppContext {
|
|
|
29
35
|
/** The application configuration */
|
|
30
36
|
public readonly config: AppConfig;
|
|
31
37
|
|
|
38
|
+
public logTarget: LogTarget[] = [];
|
|
39
|
+
|
|
40
|
+
public readonly logHistory: LogEvent[] = [];
|
|
41
|
+
|
|
32
42
|
constructor(config: AppConfig, loggerConfig?: LoggerConfig) {
|
|
33
43
|
this.config = config;
|
|
34
44
|
this.logger = createLogger(loggerConfig);
|
|
45
|
+
this.logTarget = config.logTarget ?? ["none"];
|
|
46
|
+
|
|
47
|
+
this.logger.onLogEvent((event) => {
|
|
48
|
+
if (this.logTarget.includes("memory")) {
|
|
49
|
+
this.logHistory.push(event);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (this.logTarget.includes("file")) {
|
|
53
|
+
const logFileName = `${this.config.name}-${this.startTime}.log`;
|
|
54
|
+
const logLine = event.message + "\n";
|
|
55
|
+
|
|
56
|
+
appendFileSync(logFileName, logLine);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
/**
|
|
@@ -40,35 +64,22 @@ export class AppContext {
|
|
|
40
64
|
*/
|
|
41
65
|
static get current(): AppContext {
|
|
42
66
|
if (!AppContext._current) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"Ensure Application.run() has been called."
|
|
46
|
-
);
|
|
67
|
+
// return a fake context to avoid optional chaining everywhere
|
|
68
|
+
return new AppContext({ name: "unknown", version: "0.0.0" });
|
|
47
69
|
}
|
|
48
70
|
return AppContext._current;
|
|
49
71
|
}
|
|
50
72
|
|
|
51
|
-
/**
|
|
52
|
-
* Check if a current context exists.
|
|
53
|
-
*/
|
|
54
|
-
static hasCurrent(): boolean {
|
|
55
|
-
return AppContext._current !== null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
73
|
/**
|
|
59
74
|
* Set the current application context.
|
|
60
75
|
* Called internally by Application.
|
|
61
76
|
*/
|
|
62
77
|
static setCurrent(context: AppContext): void {
|
|
63
|
-
|
|
64
|
-
|
|
78
|
+
if (!context) {
|
|
79
|
+
throw new Error("Cannot set null or undefined context");
|
|
80
|
+
}
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
* Clear the current context.
|
|
68
|
-
* Useful for testing.
|
|
69
|
-
*/
|
|
70
|
-
static clearCurrent(): void {
|
|
71
|
-
AppContext._current = null;
|
|
82
|
+
AppContext._current = context;
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
/**
|