@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.
- package/.devcontainer/devcontainer.json +19 -0
- package/.devcontainer/install-prerequisites.sh +49 -0
- package/.github/workflows/copilot-setup-steps.yml +32 -0
- package/.github/workflows/pull-request.yml +27 -0
- package/.github/workflows/release-npm-package.yml +78 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/examples/tui-app/commands/greet.ts +75 -0
- package/examples/tui-app/commands/index.ts +3 -0
- package/examples/tui-app/commands/math.ts +114 -0
- package/examples/tui-app/commands/status.ts +75 -0
- package/examples/tui-app/index.ts +34 -0
- package/guides/01-hello-world.md +96 -0
- package/guides/02-adding-options.md +103 -0
- package/guides/03-multiple-commands.md +163 -0
- package/guides/04-subcommands.md +206 -0
- package/guides/05-interactive-tui.md +194 -0
- package/guides/06-config-validation.md +264 -0
- package/guides/07-async-cancellation.md +388 -0
- package/guides/08-complete-application.md +673 -0
- package/guides/README.md +74 -0
- package/package.json +32 -0
- package/src/__tests__/application.test.ts +425 -0
- package/src/__tests__/buildCliCommand.test.ts +125 -0
- package/src/__tests__/builtins.test.ts +133 -0
- package/src/__tests__/colors.test.ts +127 -0
- package/src/__tests__/command.test.ts +157 -0
- package/src/__tests__/commandClass.test.ts +130 -0
- package/src/__tests__/context.test.ts +97 -0
- package/src/__tests__/help.test.ts +412 -0
- package/src/__tests__/parser.test.ts +268 -0
- package/src/__tests__/registry.test.ts +195 -0
- package/src/__tests__/registryNew.test.ts +160 -0
- package/src/__tests__/schemaToFields.test.ts +176 -0
- package/src/__tests__/table.test.ts +146 -0
- package/src/__tests__/tui.test.ts +26 -0
- package/src/builtins/help.ts +85 -0
- package/src/builtins/index.ts +4 -0
- package/src/builtins/settings.ts +106 -0
- package/src/builtins/version.ts +72 -0
- package/src/cli/help.ts +174 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/output/colors.ts +74 -0
- package/src/cli/output/index.ts +2 -0
- package/src/cli/output/table.ts +141 -0
- package/src/cli/parser.ts +241 -0
- package/src/commands/help.ts +50 -0
- package/src/commands/index.ts +1 -0
- package/src/components/index.ts +147 -0
- package/src/core/application.ts +461 -0
- package/src/core/command.ts +269 -0
- package/src/core/context.ts +112 -0
- package/src/core/help.ts +214 -0
- package/src/core/index.ts +15 -0
- package/src/core/logger.ts +164 -0
- package/src/core/registry.ts +140 -0
- package/src/hooks/index.ts +131 -0
- package/src/index.ts +137 -0
- package/src/registry/commandRegistry.ts +77 -0
- package/src/registry/index.ts +1 -0
- package/src/tui/TuiApp.tsx +582 -0
- package/src/tui/TuiApplication.tsx +230 -0
- package/src/tui/app.ts +29 -0
- package/src/tui/components/ActionButton.tsx +36 -0
- package/src/tui/components/CliModal.tsx +81 -0
- package/src/tui/components/CommandSelector.tsx +159 -0
- package/src/tui/components/ConfigForm.tsx +148 -0
- package/src/tui/components/EditorModal.tsx +177 -0
- package/src/tui/components/FieldRow.tsx +30 -0
- package/src/tui/components/Header.tsx +31 -0
- package/src/tui/components/JsonHighlight.tsx +128 -0
- package/src/tui/components/LogsPanel.tsx +86 -0
- package/src/tui/components/ResultsPanel.tsx +93 -0
- package/src/tui/components/StatusBar.tsx +59 -0
- package/src/tui/components/index.ts +13 -0
- package/src/tui/components/types.ts +30 -0
- package/src/tui/context/KeyboardContext.tsx +118 -0
- package/src/tui/context/index.ts +7 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useClipboard.ts +66 -0
- package/src/tui/hooks/useCommandExecutor.ts +131 -0
- package/src/tui/hooks/useConfigState.ts +171 -0
- package/src/tui/hooks/useKeyboardHandler.ts +91 -0
- package/src/tui/hooks/useLogStream.ts +96 -0
- package/src/tui/hooks/useSpinner.ts +46 -0
- package/src/tui/index.ts +65 -0
- package/src/tui/theme.ts +21 -0
- package/src/tui/utils/buildCliCommand.ts +90 -0
- package/src/tui/utils/index.ts +13 -0
- package/src/tui/utils/parameterPersistence.ts +96 -0
- package/src/tui/utils/schemaToFields.ts +144 -0
- package/src/types/command.ts +103 -0
- package/src/types/execution.ts +11 -0
- package/src/types/index.ts +1 -0
- package/tsconfig.json +25 -0
package/guides/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Terminatui Framework Guides
|
|
2
|
+
|
|
3
|
+
Step-by-step tutorials for building CLI applications with the terminatui framework.
|
|
4
|
+
|
|
5
|
+
## Guide Overview
|
|
6
|
+
|
|
7
|
+
| # | Guide | Level | Topics |
|
|
8
|
+
|---|-------|-------|--------|
|
|
9
|
+
| 1 | [Hello World](01-hello-world.md) | Super Simple | Basic Command, Application |
|
|
10
|
+
| 2 | [Adding Options](02-adding-options.md) | Super Simple | Option types, defaults, aliases |
|
|
11
|
+
| 3 | [Multiple Commands](03-multiple-commands.md) | Basic | Multiple commands, project structure |
|
|
12
|
+
| 4 | [Subcommands](04-subcommands.md) | Basic | Nested subcommands, hierarchies |
|
|
13
|
+
| 5 | [Interactive TUI](05-interactive-tui.md) | Normal | TuiApplication, metadata, keyboard |
|
|
14
|
+
| 6 | [Config Validation](06-config-validation.md) | Normal | buildConfig, ConfigValidationError |
|
|
15
|
+
| 7 | [Async Cancellation](07-async-cancellation.md) | Complex | AbortSignal, cancellation, cleanup |
|
|
16
|
+
| 8 | [Complete Application](08-complete-application.md) | Complex | Full app, services, best practices |
|
|
17
|
+
|
|
18
|
+
## Learning Path
|
|
19
|
+
|
|
20
|
+
### Beginners
|
|
21
|
+
Start with guides 1-2 to understand the basics of commands and options.
|
|
22
|
+
|
|
23
|
+
### Intermediate
|
|
24
|
+
Continue with guides 3-4 to learn about organizing larger applications.
|
|
25
|
+
|
|
26
|
+
### Advanced
|
|
27
|
+
Work through guides 5-8 to master TUI features, validation, and production patterns.
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Create a new project
|
|
33
|
+
mkdir my-cli && cd my-cli
|
|
34
|
+
bun init -y
|
|
35
|
+
bun add @pablozaiden/terminatui
|
|
36
|
+
|
|
37
|
+
# Create your first command
|
|
38
|
+
cat > src/index.ts << 'EOF'
|
|
39
|
+
import { Command, Application, type AppContext, type OptionSchema } from "@pablozaiden/terminatui";
|
|
40
|
+
|
|
41
|
+
const options = {
|
|
42
|
+
name: { type: "string", description: "Your name" },
|
|
43
|
+
} satisfies OptionSchema;
|
|
44
|
+
|
|
45
|
+
class HelloCommand extends Command<typeof options> {
|
|
46
|
+
readonly name = "hello";
|
|
47
|
+
readonly description = "Say hello";
|
|
48
|
+
readonly options = options;
|
|
49
|
+
|
|
50
|
+
async execute(ctx: AppContext, config: Record<string, unknown>) {
|
|
51
|
+
const name = config["name"] ?? "World";
|
|
52
|
+
console.log(`Hello, ${name}!`);
|
|
53
|
+
return { success: true };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class MyCLI extends Application {
|
|
58
|
+
constructor() {
|
|
59
|
+
super({ name: "my-cli", version: "1.0.0", commands: [new HelloCommand()] });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await new MyCLI().run();
|
|
64
|
+
EOF
|
|
65
|
+
|
|
66
|
+
# Run it
|
|
67
|
+
bun src/index.ts hello --name "Developer"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Prerequisites
|
|
71
|
+
|
|
72
|
+
- [Bun](https://bun.sh) or Node.js 18+
|
|
73
|
+
- Basic TypeScript knowledge
|
|
74
|
+
- Terminal/command-line familiarity
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pablozaiden/terminatui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Terminal UI and Command Line Application Framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./cli": "./src/cli/index.ts",
|
|
11
|
+
"./tui": "./src/tui/index.ts",
|
|
12
|
+
"./components": "./src/components/index.ts",
|
|
13
|
+
"./hooks": "./src/hooks/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "bunx tsc --noEmit",
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"example": "bun run examples/tui-app/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@opentui/react": "0.1.68",
|
|
22
|
+
"tslog": "^4.9.3"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/bun": "latest",
|
|
26
|
+
"@types/react": "^19",
|
|
27
|
+
"typescript": "5.9.3"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"typescript": "5.9.3"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
+
import { Application } from "../core/application.ts";
|
|
3
|
+
import { Command } from "../core/command.ts";
|
|
4
|
+
import { AppContext } from "../core/context.ts";
|
|
5
|
+
import type { OptionSchema, OptionValues, OptionDef } from "../types/command.ts";
|
|
6
|
+
|
|
7
|
+
// Define a proper option schema
|
|
8
|
+
const testOptions = {
|
|
9
|
+
value: {
|
|
10
|
+
type: "string" as const,
|
|
11
|
+
description: "Test value"
|
|
12
|
+
} satisfies OptionDef
|
|
13
|
+
} as const satisfies OptionSchema;
|
|
14
|
+
|
|
15
|
+
// Test command implementations
|
|
16
|
+
class TestCommand extends Command<typeof testOptions> {
|
|
17
|
+
readonly name = "test";
|
|
18
|
+
readonly description = "A test command";
|
|
19
|
+
readonly options = testOptions;
|
|
20
|
+
|
|
21
|
+
executedWith: Record<string, unknown> | null = null;
|
|
22
|
+
|
|
23
|
+
override async execute(
|
|
24
|
+
_ctx: AppContext,
|
|
25
|
+
opts: OptionValues<typeof testOptions>
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
this.executedWith = opts as Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class TuiCommand extends Command<OptionSchema> {
|
|
32
|
+
readonly name = "tui-cmd";
|
|
33
|
+
readonly description = "A TUI command";
|
|
34
|
+
readonly options = {};
|
|
35
|
+
|
|
36
|
+
executed = false;
|
|
37
|
+
|
|
38
|
+
override async execute(_ctx: AppContext): Promise<void> {
|
|
39
|
+
this.executed = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("Application", () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
AppContext.clearCurrent();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("constructor", () => {
|
|
49
|
+
test("creates application with name and version", () => {
|
|
50
|
+
const app = new Application({
|
|
51
|
+
name: "test-app",
|
|
52
|
+
version: "1.0.0",
|
|
53
|
+
commands: [],
|
|
54
|
+
});
|
|
55
|
+
expect(app.name).toBe("test-app");
|
|
56
|
+
expect(app.version).toBe("1.0.0");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("creates context and sets as current", () => {
|
|
60
|
+
const app = new Application({
|
|
61
|
+
name: "test-app",
|
|
62
|
+
version: "1.0.0",
|
|
63
|
+
commands: [],
|
|
64
|
+
});
|
|
65
|
+
expect(AppContext.hasCurrent()).toBe(true);
|
|
66
|
+
expect(app.context).toBe(AppContext.current);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("registers provided commands", () => {
|
|
70
|
+
const cmd = new TestCommand();
|
|
71
|
+
const app = new Application({
|
|
72
|
+
name: "test-app",
|
|
73
|
+
version: "1.0.0",
|
|
74
|
+
commands: [cmd],
|
|
75
|
+
});
|
|
76
|
+
expect(app.registry.has("test")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("auto-registers version command", () => {
|
|
80
|
+
const app = new Application({
|
|
81
|
+
name: "test-app",
|
|
82
|
+
version: "1.0.0",
|
|
83
|
+
commands: [],
|
|
84
|
+
});
|
|
85
|
+
expect(app.registry.has("version")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("auto-registers help command", () => {
|
|
89
|
+
const app = new Application({
|
|
90
|
+
name: "test-app",
|
|
91
|
+
version: "1.0.0",
|
|
92
|
+
commands: [],
|
|
93
|
+
});
|
|
94
|
+
expect(app.registry.has("help")).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("injects help subcommand into commands", () => {
|
|
98
|
+
const cmd = new TestCommand();
|
|
99
|
+
// Creating the Application injects help into commands
|
|
100
|
+
new Application({
|
|
101
|
+
name: "test-app",
|
|
102
|
+
version: "1.0.0",
|
|
103
|
+
commands: [cmd],
|
|
104
|
+
});
|
|
105
|
+
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);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
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
|
+
test("runs default command when no args", async () => {
|
|
133
|
+
const cmd = new TuiCommand();
|
|
134
|
+
const app = new Application({
|
|
135
|
+
name: "test-app",
|
|
136
|
+
version: "1.0.0",
|
|
137
|
+
commands: [cmd],
|
|
138
|
+
defaultCommand: "tui-cmd",
|
|
139
|
+
});
|
|
140
|
+
await app.run([]);
|
|
141
|
+
expect(cmd.executed).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("runs specified command", async () => {
|
|
145
|
+
const cmd = new TestCommand();
|
|
146
|
+
const app = new Application({
|
|
147
|
+
name: "test-app",
|
|
148
|
+
version: "1.0.0",
|
|
149
|
+
commands: [cmd],
|
|
150
|
+
});
|
|
151
|
+
await app.run(["test"]);
|
|
152
|
+
expect(cmd.executedWith).not.toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("passes options to command", async () => {
|
|
156
|
+
const cmd = new TestCommand();
|
|
157
|
+
const app = new Application({
|
|
158
|
+
name: "test-app",
|
|
159
|
+
version: "1.0.0",
|
|
160
|
+
commands: [cmd],
|
|
161
|
+
});
|
|
162
|
+
await app.run(["test", "--value", "hello"]);
|
|
163
|
+
expect(cmd.executedWith).not.toBeNull();
|
|
164
|
+
expect(cmd.executedWith?.["value"]).toBe("hello");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("lifecycle hooks", () => {
|
|
169
|
+
test("calls onBeforeRun", async () => {
|
|
170
|
+
let called = false;
|
|
171
|
+
const cmd = new TestCommand();
|
|
172
|
+
const app = new Application({
|
|
173
|
+
name: "test-app",
|
|
174
|
+
version: "1.0.0",
|
|
175
|
+
commands: [cmd],
|
|
176
|
+
});
|
|
177
|
+
app.setHooks({
|
|
178
|
+
onBeforeRun: async () => {
|
|
179
|
+
called = true;
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
await app.run(["test"]);
|
|
183
|
+
expect(called).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("calls onAfterRun", async () => {
|
|
187
|
+
let called = false;
|
|
188
|
+
const cmd = new TestCommand();
|
|
189
|
+
const app = new Application({
|
|
190
|
+
name: "test-app",
|
|
191
|
+
version: "1.0.0",
|
|
192
|
+
commands: [cmd],
|
|
193
|
+
});
|
|
194
|
+
app.setHooks({
|
|
195
|
+
onAfterRun: async () => {
|
|
196
|
+
called = true;
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
await app.run(["test"]);
|
|
200
|
+
expect(called).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("calls onError on exception", async () => {
|
|
204
|
+
let errorCaught: Error | undefined;
|
|
205
|
+
|
|
206
|
+
class ErrorCommand extends Command<OptionSchema> {
|
|
207
|
+
readonly name = "error-cmd";
|
|
208
|
+
readonly description = "A command that throws";
|
|
209
|
+
readonly options = {};
|
|
210
|
+
|
|
211
|
+
override async execute(): Promise<void> {
|
|
212
|
+
throw new Error("Test error");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const app = new Application({
|
|
217
|
+
name: "test-app",
|
|
218
|
+
version: "1.0.0",
|
|
219
|
+
commands: [new ErrorCommand()],
|
|
220
|
+
});
|
|
221
|
+
app.setHooks({
|
|
222
|
+
onError: async (_ctx, error) => {
|
|
223
|
+
errorCaught = error;
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
await app.run(["error-cmd"]);
|
|
227
|
+
expect(errorCaught?.message).toBe("Test error");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("buildConfig", () => {
|
|
232
|
+
// Config type for testing
|
|
233
|
+
interface ParsedConfig {
|
|
234
|
+
value: string;
|
|
235
|
+
count: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const configOptions = {
|
|
239
|
+
value: { type: "string" as const, description: "A value" },
|
|
240
|
+
count: { type: "string" as const, description: "A count" },
|
|
241
|
+
} as const satisfies OptionSchema;
|
|
242
|
+
|
|
243
|
+
test("calls buildConfig before execute", async () => {
|
|
244
|
+
let buildConfigCalled = false;
|
|
245
|
+
let receivedConfig: ParsedConfig | null = null as ParsedConfig | null;
|
|
246
|
+
|
|
247
|
+
class ConfigCommand extends Command<typeof configOptions, ParsedConfig> {
|
|
248
|
+
readonly name = "config-cmd";
|
|
249
|
+
readonly description = "A command with buildConfig";
|
|
250
|
+
readonly options = configOptions;
|
|
251
|
+
|
|
252
|
+
override buildConfig(
|
|
253
|
+
_ctx: AppContext,
|
|
254
|
+
opts: OptionValues<typeof configOptions>
|
|
255
|
+
): ParsedConfig {
|
|
256
|
+
buildConfigCalled = true;
|
|
257
|
+
return {
|
|
258
|
+
value: opts.value as string,
|
|
259
|
+
count: parseInt(opts.count as string, 10),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
override async execute(_ctx: AppContext, config: ParsedConfig): Promise<void> {
|
|
264
|
+
receivedConfig = config;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const app = new Application({
|
|
269
|
+
name: "test-app",
|
|
270
|
+
version: "1.0.0",
|
|
271
|
+
commands: [new ConfigCommand()],
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await app.run(["config-cmd", "--value", "test", "--count", "42"]);
|
|
275
|
+
|
|
276
|
+
expect(buildConfigCalled).toBe(true);
|
|
277
|
+
expect(receivedConfig).toEqual({ value: "test", count: 42 });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("passes raw options when buildConfig is not defined", async () => {
|
|
281
|
+
let receivedOpts: Record<string, unknown> | null = null as Record<string, unknown> | null;
|
|
282
|
+
|
|
283
|
+
class NoConfigCommand extends Command<typeof testOptions> {
|
|
284
|
+
readonly name = "no-config-cmd";
|
|
285
|
+
readonly description = "A command without buildConfig";
|
|
286
|
+
readonly options = testOptions;
|
|
287
|
+
|
|
288
|
+
override async execute(
|
|
289
|
+
_ctx: AppContext,
|
|
290
|
+
opts: OptionValues<typeof testOptions>
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
receivedOpts = opts as Record<string, unknown>;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const app = new Application({
|
|
297
|
+
name: "test-app",
|
|
298
|
+
version: "1.0.0",
|
|
299
|
+
commands: [new NoConfigCommand()],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await app.run(["no-config-cmd", "--value", "hello"]);
|
|
303
|
+
|
|
304
|
+
expect(receivedOpts).toEqual({ value: "hello" });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("buildConfig errors are caught and handled", async () => {
|
|
308
|
+
let errorCaught: Error | undefined;
|
|
309
|
+
|
|
310
|
+
class FailConfigCommand extends Command<typeof testOptions, never> {
|
|
311
|
+
readonly name = "fail-config";
|
|
312
|
+
readonly description = "A command that fails buildConfig";
|
|
313
|
+
readonly options = testOptions;
|
|
314
|
+
|
|
315
|
+
override buildConfig(): never {
|
|
316
|
+
throw new Error("Config validation failed");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
override async execute(): Promise<void> {
|
|
320
|
+
// Should never be called
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const app = new Application({
|
|
325
|
+
name: "test-app",
|
|
326
|
+
version: "1.0.0",
|
|
327
|
+
commands: [new FailConfigCommand()],
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
app.setHooks({
|
|
331
|
+
onError: async (_ctx, error) => {
|
|
332
|
+
errorCaught = error;
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await app.run(["fail-config", "--value", "test"]);
|
|
337
|
+
|
|
338
|
+
expect(errorCaught?.message).toBe("Config validation failed");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("global options", () => {
|
|
343
|
+
test("parses --log-level before command", async () => {
|
|
344
|
+
const cmd = new TestCommand();
|
|
345
|
+
const app = new Application({
|
|
346
|
+
name: "test-app",
|
|
347
|
+
version: "1.0.0",
|
|
348
|
+
commands: [cmd],
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Should not throw - global option should be parsed and removed
|
|
352
|
+
await app.run(["--log-level", "debug", "test", "--value", "hello"]);
|
|
353
|
+
expect(cmd.executedWith?.["value"]).toBe("hello");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("parses --log-level after command", async () => {
|
|
357
|
+
const cmd = new TestCommand();
|
|
358
|
+
const app = new Application({
|
|
359
|
+
name: "test-app",
|
|
360
|
+
version: "1.0.0",
|
|
361
|
+
commands: [cmd],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await app.run(["test", "--log-level", "debug", "--value", "hello"]);
|
|
365
|
+
expect(cmd.executedWith?.["value"]).toBe("hello");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("applies log-level case-insensitively", async () => {
|
|
369
|
+
const cmd = new TestCommand();
|
|
370
|
+
const app = new Application({
|
|
371
|
+
name: "test-app",
|
|
372
|
+
version: "1.0.0",
|
|
373
|
+
commands: [cmd],
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// 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
|
|
379
|
+
|
|
380
|
+
await app.run(["--log-level", "Debug", "test"]);
|
|
381
|
+
expect(app.context.logger.getMinLevel()).toBe(2);
|
|
382
|
+
|
|
383
|
+
await app.run(["--log-level", "DEBUG", "test"]);
|
|
384
|
+
expect(app.context.logger.getMinLevel()).toBe(2);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("parses --detailed-logs flag", async () => {
|
|
388
|
+
const cmd = new TestCommand();
|
|
389
|
+
const app = new Application({
|
|
390
|
+
name: "test-app",
|
|
391
|
+
version: "1.0.0",
|
|
392
|
+
commands: [cmd],
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
await app.run(["--detailed-logs", "test"]);
|
|
396
|
+
// Should not throw - flag is recognized
|
|
397
|
+
expect(cmd.executedWith).not.toBeNull();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("parses --no-detailed-logs flag", async () => {
|
|
401
|
+
const cmd = new TestCommand();
|
|
402
|
+
const app = new Application({
|
|
403
|
+
name: "test-app",
|
|
404
|
+
version: "1.0.0",
|
|
405
|
+
commands: [cmd],
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await app.run(["--no-detailed-logs", "test"]);
|
|
409
|
+
// Should not throw - flag is recognized
|
|
410
|
+
expect(cmd.executedWith).not.toBeNull();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("parses --log-level=value format", async () => {
|
|
414
|
+
const cmd = new TestCommand();
|
|
415
|
+
const app = new Application({
|
|
416
|
+
name: "test-app",
|
|
417
|
+
version: "1.0.0",
|
|
418
|
+
commands: [cmd],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await app.run(["--log-level=warn", "test"]);
|
|
422
|
+
expect(app.context.logger.getMinLevel()).toBe(4); // Warn = 4
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { buildCliCommand } from "../tui/utils/buildCliCommand.ts";
|
|
3
|
+
import type { OptionSchema } from "../types/command.ts";
|
|
4
|
+
|
|
5
|
+
describe("buildCliCommand", () => {
|
|
6
|
+
test("builds command with no options", () => {
|
|
7
|
+
const result = buildCliCommand("myapp", ["run"], {}, {});
|
|
8
|
+
expect(result).toBe("myapp run");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("includes string options", () => {
|
|
12
|
+
const schema: OptionSchema = {
|
|
13
|
+
name: { type: "string", description: "Name" },
|
|
14
|
+
};
|
|
15
|
+
const values = { name: "John" };
|
|
16
|
+
|
|
17
|
+
const result = buildCliCommand("myapp", ["greet"], schema, values);
|
|
18
|
+
expect(result).toBe("myapp greet --name John");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("quotes string values with spaces", () => {
|
|
22
|
+
const schema: OptionSchema = {
|
|
23
|
+
name: { type: "string", description: "Name" },
|
|
24
|
+
};
|
|
25
|
+
const values = { name: "John Doe" };
|
|
26
|
+
|
|
27
|
+
const result = buildCliCommand("myapp", ["greet"], schema, values);
|
|
28
|
+
expect(result).toBe("myapp greet --name \"John Doe\"");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("excludes empty string values", () => {
|
|
32
|
+
const schema: OptionSchema = {
|
|
33
|
+
name: { type: "string", description: "Name" },
|
|
34
|
+
};
|
|
35
|
+
const values = { name: "" };
|
|
36
|
+
|
|
37
|
+
const result = buildCliCommand("myapp", ["greet"], schema, values);
|
|
38
|
+
expect(result).toBe("myapp greet");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("includes boolean flags only when true", () => {
|
|
42
|
+
const schema: OptionSchema = {
|
|
43
|
+
verbose: { type: "boolean", description: "Verbose" },
|
|
44
|
+
quiet: { type: "boolean", description: "Quiet" },
|
|
45
|
+
};
|
|
46
|
+
const values = { verbose: true, quiet: false };
|
|
47
|
+
|
|
48
|
+
const result = buildCliCommand("myapp", ["run"], schema, values);
|
|
49
|
+
expect(result).toBe("myapp run --verbose");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("includes number values", () => {
|
|
53
|
+
const schema: OptionSchema = {
|
|
54
|
+
count: { type: "number", description: "Count" },
|
|
55
|
+
};
|
|
56
|
+
const values = { count: 42 };
|
|
57
|
+
|
|
58
|
+
const result = buildCliCommand("myapp", ["process"], schema, values);
|
|
59
|
+
expect(result).toBe("myapp process --count 42");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("excludes undefined and null values", () => {
|
|
63
|
+
const schema: OptionSchema = {
|
|
64
|
+
name: { type: "string", description: "Name" },
|
|
65
|
+
count: { type: "number", description: "Count" },
|
|
66
|
+
};
|
|
67
|
+
const values = { name: undefined, count: null };
|
|
68
|
+
|
|
69
|
+
const result = buildCliCommand("myapp", ["run"], schema, values);
|
|
70
|
+
expect(result).toBe("myapp run");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("handles array values", () => {
|
|
74
|
+
const schema: OptionSchema = {
|
|
75
|
+
files: { type: "array", description: "Files" },
|
|
76
|
+
};
|
|
77
|
+
const values = { files: ["a.txt", "b.txt"] };
|
|
78
|
+
|
|
79
|
+
const result = buildCliCommand("myapp", ["process"], schema, values);
|
|
80
|
+
expect(result).toBe("myapp process --files a.txt --files b.txt");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("handles nested command path", () => {
|
|
84
|
+
const schema: OptionSchema = {
|
|
85
|
+
force: { type: "boolean", description: "Force" },
|
|
86
|
+
};
|
|
87
|
+
const values = { force: true };
|
|
88
|
+
|
|
89
|
+
const result = buildCliCommand("myapp", ["db", "migrate"], schema, values);
|
|
90
|
+
expect(result).toBe("myapp db migrate --force");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("converts camelCase to kebab-case", () => {
|
|
94
|
+
const schema: OptionSchema = {
|
|
95
|
+
outputDir: { type: "string", description: "Output directory" },
|
|
96
|
+
maxRetries: { type: "number", description: "Max retries" },
|
|
97
|
+
};
|
|
98
|
+
const values = { outputDir: "/tmp", maxRetries: 3 };
|
|
99
|
+
|
|
100
|
+
const result = buildCliCommand("myapp", ["build"], schema, values);
|
|
101
|
+
expect(result).toContain("--output-dir /tmp");
|
|
102
|
+
expect(result).toContain("--max-retries 3");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("skips values that match defaults", () => {
|
|
106
|
+
const schema: OptionSchema = {
|
|
107
|
+
verbose: { type: "boolean", description: "Verbose", default: false },
|
|
108
|
+
count: { type: "number", description: "Count", default: 10 },
|
|
109
|
+
};
|
|
110
|
+
const values = { verbose: false, count: 10 };
|
|
111
|
+
|
|
112
|
+
const result = buildCliCommand("myapp", ["run"], schema, values);
|
|
113
|
+
expect(result).toBe("myapp run");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("uses --no-flag for false when default is true", () => {
|
|
117
|
+
const schema: OptionSchema = {
|
|
118
|
+
color: { type: "boolean", description: "Color output", default: true },
|
|
119
|
+
};
|
|
120
|
+
const values = { color: false };
|
|
121
|
+
|
|
122
|
+
const result = buildCliCommand("myapp", ["run"], schema, values);
|
|
123
|
+
expect(result).toBe("myapp run --no-color");
|
|
124
|
+
});
|
|
125
|
+
});
|