@pablozaiden/terminatui 0.3.0-beta-1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -3
- package/src/__tests__/configOnChange.test.ts +63 -0
- package/src/builtins/version.ts +1 -1
- package/src/index.ts +22 -0
- package/src/tui/adapters/ink/InkRenderer.tsx +4 -0
- package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +4 -0
- package/src/tui/adapters/types.ts +1 -0
- package/src/tui/screens/ConfigScreen.tsx +6 -1
- package/src/tui/screens/ResultsScreen.tsx +9 -1
- package/src/tui/screens/RunningScreen.tsx +1 -1
- package/.devcontainer/devcontainer.json +0 -19
- package/.devcontainer/install-prerequisites.sh +0 -49
- package/.github/workflows/copilot-setup-steps.yml +0 -32
- package/.github/workflows/pull-request.yml +0 -27
- package/.github/workflows/release-npm-package.yml +0 -81
- package/AGENTS.md +0 -43
- package/CLAUDE.md +0 -1
- package/bun.lock +0 -321
- package/examples/tui-app/commands/config/app/get.ts +0 -62
- package/examples/tui-app/commands/config/app/index.ts +0 -23
- package/examples/tui-app/commands/config/app/set.ts +0 -96
- package/examples/tui-app/commands/config/index.ts +0 -28
- package/examples/tui-app/commands/config/user/get.ts +0 -61
- package/examples/tui-app/commands/config/user/index.ts +0 -23
- package/examples/tui-app/commands/config/user/set.ts +0 -57
- package/examples/tui-app/commands/greet.ts +0 -78
- package/examples/tui-app/commands/math.ts +0 -111
- package/examples/tui-app/commands/status.ts +0 -86
- package/examples/tui-app/index.ts +0 -38
- package/guides/01-hello-world.md +0 -101
- package/guides/02-adding-options.md +0 -103
- package/guides/03-multiple-commands.md +0 -161
- package/guides/04-subcommands.md +0 -206
- package/guides/05-interactive-tui.md +0 -209
- package/guides/06-config-validation.md +0 -256
- package/guides/07-async-cancellation.md +0 -334
- package/guides/08-complete-application.md +0 -507
- package/guides/README.md +0 -78
- package/tsconfig.json +0 -25
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
# Guide 6: Config Validation (Normal)
|
|
2
|
-
|
|
3
|
-
Transform and validate options before execution with `buildConfig`.
|
|
4
|
-
|
|
5
|
-
## What You'll Build
|
|
6
|
-
|
|
7
|
-
A deploy CLI that validates paths, resolves environment configs, and provides helpful error messages:
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
deploy --app ./myapp --env production --replicas 3
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Step 1: Define Options and Config Types
|
|
14
|
-
|
|
15
|
-
Create `src/commands/deploy.ts`:
|
|
16
|
-
|
|
17
|
-
```typescript
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
import {
|
|
20
|
-
Command,
|
|
21
|
-
ConfigValidationError,
|
|
22
|
-
type OptionSchema,
|
|
23
|
-
type OptionValues,
|
|
24
|
-
type CommandResult
|
|
25
|
-
} from "@pablozaiden/terminatui";
|
|
26
|
-
|
|
27
|
-
// Raw CLI options
|
|
28
|
-
const options = {
|
|
29
|
-
app: {
|
|
30
|
-
type: "string",
|
|
31
|
-
description: "Path to application",
|
|
32
|
-
required: true,
|
|
33
|
-
},
|
|
34
|
-
env: {
|
|
35
|
-
type: "string",
|
|
36
|
-
description: "Deployment environment",
|
|
37
|
-
required: true,
|
|
38
|
-
enum: ["development", "staging", "production"],
|
|
39
|
-
},
|
|
40
|
-
replicas: {
|
|
41
|
-
type: "string", // CLI args are strings
|
|
42
|
-
description: "Number of replicas",
|
|
43
|
-
default: "1",
|
|
44
|
-
},
|
|
45
|
-
"dry-run": {
|
|
46
|
-
type: "boolean",
|
|
47
|
-
description: "Preview without deploying",
|
|
48
|
-
default: false,
|
|
49
|
-
},
|
|
50
|
-
} satisfies OptionSchema;
|
|
51
|
-
|
|
52
|
-
// Validated config type
|
|
53
|
-
interface DeployConfig {
|
|
54
|
-
appPath: string; // Resolved absolute path
|
|
55
|
-
appName: string; // Extracted from path
|
|
56
|
-
environment: string;
|
|
57
|
-
replicas: number; // Parsed to number
|
|
58
|
-
dryRun: boolean;
|
|
59
|
-
envConfig: { // Environment-specific settings
|
|
60
|
-
url: string;
|
|
61
|
-
timeout: number;
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
## Step 2: Implement buildConfig
|
|
67
|
-
|
|
68
|
-
```typescript
|
|
69
|
-
// Environment-specific configurations
|
|
70
|
-
const ENV_CONFIGS = {
|
|
71
|
-
development: { url: "http://localhost:3000", timeout: 5000 },
|
|
72
|
-
staging: { url: "https://staging.example.com", timeout: 10000 },
|
|
73
|
-
production: { url: "https://example.com", timeout: 30000 },
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export class DeployCommand extends Command<typeof options, DeployConfig> {
|
|
77
|
-
readonly name = "deploy";
|
|
78
|
-
readonly description = "Deploy an application";
|
|
79
|
-
readonly options = options;
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Transform and validate raw options into DeployConfig.
|
|
83
|
-
* Runs before execute() - errors here show helpful messages.
|
|
84
|
-
*/
|
|
85
|
-
override async buildConfig(opts: OptionValues<typeof options>): Promise<DeployConfig> {
|
|
86
|
-
// 1. Validate app path exists
|
|
87
|
-
const appRaw = opts["app"] as string | undefined;
|
|
88
|
-
if (!appRaw) {
|
|
89
|
-
throw new ConfigValidationError(
|
|
90
|
-
"Missing required option: app",
|
|
91
|
-
"app" // Field to highlight in TUI
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const appPath = path.resolve(appRaw);
|
|
96
|
-
if (!(await Bun.file(appPath).exists())) {
|
|
97
|
-
throw new ConfigValidationError(
|
|
98
|
-
`Application path does not exist: ${appPath}`,
|
|
99
|
-
"app"
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// 2. Extract app name from path
|
|
104
|
-
const appName = path.basename(appPath);
|
|
105
|
-
|
|
106
|
-
// 3. Validate environment
|
|
107
|
-
const environment = opts["env"] as string;
|
|
108
|
-
if (!environment) {
|
|
109
|
-
throw new ConfigValidationError(
|
|
110
|
-
"Missing required option: env",
|
|
111
|
-
"env"
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 4. Parse and validate replicas
|
|
116
|
-
const replicasStr = opts["replicas"] as string ?? "1";
|
|
117
|
-
const replicas = parseInt(replicasStr, 10);
|
|
118
|
-
|
|
119
|
-
if (isNaN(replicas)) {
|
|
120
|
-
throw new ConfigValidationError(
|
|
121
|
-
`Replicas must be a number, got: ${replicasStr}`,
|
|
122
|
-
"replicas"
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (replicas < 1 || replicas > 10) {
|
|
127
|
-
throw new ConfigValidationError(
|
|
128
|
-
"Replicas must be between 1 and 10",
|
|
129
|
-
"replicas"
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 5. Get environment-specific config
|
|
134
|
-
const envConfig = ENV_CONFIGS[environment as keyof typeof ENV_CONFIGS];
|
|
135
|
-
|
|
136
|
-
// 6. Return validated config
|
|
137
|
-
return {
|
|
138
|
-
appPath,
|
|
139
|
-
appName,
|
|
140
|
-
environment,
|
|
141
|
-
replicas,
|
|
142
|
-
dryRun: opts["dry-run"] as boolean ?? false,
|
|
143
|
-
envConfig,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
## Step 3: Implement execute with clean config
|
|
149
|
-
|
|
150
|
-
```typescript
|
|
151
|
-
/**
|
|
152
|
-
* Execute with fully validated DeployConfig.
|
|
153
|
-
* No need to validate here - buildConfig already did it!
|
|
154
|
-
*/
|
|
155
|
-
async execute(config: DeployConfig): Promise<CommandResult> {
|
|
156
|
-
console.log(`Deploying ${config.appName} to ${config.environment}`);
|
|
157
|
-
|
|
158
|
-
if (config.dryRun) {
|
|
159
|
-
console.log("DRY RUN - Would deploy:");
|
|
160
|
-
console.log(` App: ${config.appName}`);
|
|
161
|
-
console.log(` Environment: ${config.environment}`);
|
|
162
|
-
console.log(` Replicas: ${config.replicas}`);
|
|
163
|
-
console.log(` Target: ${config.envConfig.url}`);
|
|
164
|
-
return { success: true, message: "Dry run completed" };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
console.log(`Deploying ${config.appName}...`);
|
|
168
|
-
console.log(` Creating ${config.replicas} replicas...`);
|
|
169
|
-
console.log(` Targeting ${config.envConfig.url}...`);
|
|
170
|
-
|
|
171
|
-
// Simulate deployment
|
|
172
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
173
|
-
|
|
174
|
-
console.log("Deployment successful!");
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
success: true,
|
|
178
|
-
data: {
|
|
179
|
-
app: config.appName,
|
|
180
|
-
environment: config.environment,
|
|
181
|
-
replicas: config.replicas,
|
|
182
|
-
url: config.envConfig.url,
|
|
183
|
-
},
|
|
184
|
-
message: `Deployed ${config.appName} to ${config.environment}`
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
## Step 4: Create the Application
|
|
191
|
-
|
|
192
|
-
Create `src/index.ts`:
|
|
193
|
-
|
|
194
|
-
```typescript
|
|
195
|
-
import { TuiApplication } from "@pablozaiden/terminatui";
|
|
196
|
-
import { DeployCommand } from "./commands/deploy";
|
|
197
|
-
|
|
198
|
-
class DeployCLI extends TuiApplication {
|
|
199
|
-
constructor() {
|
|
200
|
-
super({
|
|
201
|
-
name: "deploy",
|
|
202
|
-
version: "1.0.0",
|
|
203
|
-
commands: [new DeployCommand()],
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
await new DeployCLI().run();
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
## Step 5: Test Validation
|
|
212
|
-
|
|
213
|
-
```bash
|
|
214
|
-
# Missing required option
|
|
215
|
-
bun src/index.ts deploy --env production
|
|
216
|
-
# Error: Missing required option: app
|
|
217
|
-
|
|
218
|
-
# Invalid path
|
|
219
|
-
bun src/index.ts deploy --app ./nonexistent --env staging
|
|
220
|
-
# Error: Application path does not exist: /path/to/nonexistent
|
|
221
|
-
|
|
222
|
-
# Invalid replicas
|
|
223
|
-
bun src/index.ts deploy --app . --env production --replicas abc
|
|
224
|
-
# Error: Replicas must be a number, got: abc
|
|
225
|
-
|
|
226
|
-
# Out of range replicas
|
|
227
|
-
bun src/index.ts deploy --app . --env production --replicas 100
|
|
228
|
-
# Error: Replicas must be between 1 and 10
|
|
229
|
-
|
|
230
|
-
# Dry run (valid)
|
|
231
|
-
bun src/index.ts deploy --app . --env production --replicas 3 --dry-run
|
|
232
|
-
# DRY RUN - Would deploy: ...
|
|
233
|
-
|
|
234
|
-
# Full deploy
|
|
235
|
-
bun src/index.ts deploy --app . --env staging --replicas 2
|
|
236
|
-
# Deploying myapp...
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
## Benefits of buildConfig
|
|
240
|
-
|
|
241
|
-
1. **Separation of concerns** - Validation separate from logic
|
|
242
|
-
2. **Type safety** - `execute()` receives validated `DeployConfig`
|
|
243
|
-
3. **Better errors** - `ConfigValidationError` highlights fields in TUI
|
|
244
|
-
4. **Reusable** - Works for both CLI and TUI modes
|
|
245
|
-
5. **Testable** - Easy to unit test validation logic
|
|
246
|
-
|
|
247
|
-
## What You Learned
|
|
248
|
-
|
|
249
|
-
- Use `buildConfig` to transform and validate options
|
|
250
|
-
- Throw `ConfigValidationError` with field name for TUI highlighting
|
|
251
|
-
- Parse strings to numbers and resolve paths
|
|
252
|
-
- Keep `execute()` clean with pre-validated config
|
|
253
|
-
|
|
254
|
-
## Next Steps
|
|
255
|
-
|
|
256
|
-
→ [Guide 7: Async Commands with Cancellation](07-async-cancellation.md)
|
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
# Guide 7: Async Commands with Cancellation (Complex)
|
|
2
|
-
|
|
3
|
-
Build commands that support cancellation with proper cleanup for long-running operations.
|
|
4
|
-
|
|
5
|
-
## What You'll Build
|
|
6
|
-
|
|
7
|
-
A download manager that:
|
|
8
|
-
- Downloads files asynchronously
|
|
9
|
-
- Shows progress updates
|
|
10
|
-
- Supports cancellation (Ctrl+C in CLI, Esc in TUI)
|
|
11
|
-
- Cleans up partial downloads when cancelled
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
download https://example.com/large-file.zip --output ./downloads/
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## Step 1: Define the Command
|
|
18
|
-
|
|
19
|
-
Create `src/commands/download.ts`:
|
|
20
|
-
|
|
21
|
-
```typescript
|
|
22
|
-
import path from "node:path";
|
|
23
|
-
import {
|
|
24
|
-
Command,
|
|
25
|
-
ConfigValidationError,
|
|
26
|
-
type OptionSchema,
|
|
27
|
-
type OptionValues,
|
|
28
|
-
type CommandResult,
|
|
29
|
-
type CommandExecutionContext
|
|
30
|
-
} from "@pablozaiden/terminatui";
|
|
31
|
-
|
|
32
|
-
const options = {
|
|
33
|
-
url: {
|
|
34
|
-
type: "string",
|
|
35
|
-
description: "URL to download",
|
|
36
|
-
required: true,
|
|
37
|
-
label: "Download URL",
|
|
38
|
-
},
|
|
39
|
-
output: {
|
|
40
|
-
type: "string",
|
|
41
|
-
description: "Output directory",
|
|
42
|
-
default: "./downloads",
|
|
43
|
-
label: "Output Directory",
|
|
44
|
-
},
|
|
45
|
-
"chunk-size": {
|
|
46
|
-
type: "string",
|
|
47
|
-
description: "Download chunk size in KB",
|
|
48
|
-
default: "1024",
|
|
49
|
-
label: "Chunk Size (KB)",
|
|
50
|
-
},
|
|
51
|
-
} satisfies OptionSchema;
|
|
52
|
-
|
|
53
|
-
interface DownloadConfig {
|
|
54
|
-
url: URL;
|
|
55
|
-
outputDir: string;
|
|
56
|
-
fileName: string;
|
|
57
|
-
chunkSize: number;
|
|
58
|
-
}
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Step 2: Implement buildConfig
|
|
62
|
-
|
|
63
|
-
```typescript
|
|
64
|
-
export class DownloadCommand extends Command<typeof options, DownloadConfig> {
|
|
65
|
-
readonly name = "download";
|
|
66
|
-
readonly description = "Download a file from URL";
|
|
67
|
-
readonly options = options;
|
|
68
|
-
readonly displayName = "File Downloader";
|
|
69
|
-
readonly actionLabel = "Download";
|
|
70
|
-
|
|
71
|
-
override buildConfig(opts: OptionValues<typeof options>): DownloadConfig {
|
|
72
|
-
// Validate URL
|
|
73
|
-
const urlStr = opts["url"] as string;
|
|
74
|
-
if (!urlStr) {
|
|
75
|
-
throw new ConfigValidationError("URL is required", "url");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let url: URL;
|
|
79
|
-
try {
|
|
80
|
-
url = new URL(urlStr);
|
|
81
|
-
} catch {
|
|
82
|
-
throw new ConfigValidationError("Invalid URL format", "url");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Validate output directory
|
|
86
|
-
const outputDir = path.resolve(opts["output"] as string ?? "./downloads");
|
|
87
|
-
|
|
88
|
-
// Extract filename from URL
|
|
89
|
-
const fileName = path.basename(url.pathname) || "download";
|
|
90
|
-
|
|
91
|
-
// Parse chunk size
|
|
92
|
-
const chunkSizeStr = opts["chunk-size"] as string ?? "1024";
|
|
93
|
-
const chunkSize = parseInt(chunkSizeStr, 10) * 1024; // Convert KB to bytes
|
|
94
|
-
if (isNaN(chunkSize) || chunkSize <= 0) {
|
|
95
|
-
throw new ConfigValidationError("Chunk size must be a positive number", "chunk-size");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { url, outputDir, fileName, chunkSize };
|
|
99
|
-
}
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## Step 3: Implement Cancellable Download
|
|
103
|
-
|
|
104
|
-
```typescript
|
|
105
|
-
async execute(
|
|
106
|
-
config: DownloadConfig,
|
|
107
|
-
execCtx: CommandExecutionContext
|
|
108
|
-
): Promise<CommandResult> {
|
|
109
|
-
const { url, outputDir, fileName, chunkSize } = config;
|
|
110
|
-
const outputPath = path.join(outputDir, fileName);
|
|
111
|
-
const signal = execCtx.signal;
|
|
112
|
-
|
|
113
|
-
console.log(`Starting download: ${url}`);
|
|
114
|
-
console.log(`Output: ${outputPath}`);
|
|
115
|
-
|
|
116
|
-
// Create output directory
|
|
117
|
-
await Bun.write(path.join(outputDir, ".keep"), "");
|
|
118
|
-
|
|
119
|
-
// Track download state for cleanup
|
|
120
|
-
let downloadedBytes = 0;
|
|
121
|
-
let totalBytes = 0;
|
|
122
|
-
let partialFile: Bun.FileSink | null = null;
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
// Check for cancellation before starting
|
|
126
|
-
if (signal.aborted) {
|
|
127
|
-
|
|
128
|
-
return { success: false, message: "Download cancelled before start" };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Fetch with AbortSignal
|
|
132
|
-
const response = await fetch(url.toString(), { signal });
|
|
133
|
-
|
|
134
|
-
if (!response.ok) {
|
|
135
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
totalBytes = parseInt(response.headers.get("content-length") ?? "0", 10);
|
|
139
|
-
const reader = response.body?.getReader();
|
|
140
|
-
|
|
141
|
-
if (!reader) {
|
|
142
|
-
throw new Error("No response body");
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Open file for writing
|
|
146
|
-
partialFile = Bun.file(outputPath).writer();
|
|
147
|
-
|
|
148
|
-
console.log(`Downloading ${fileName}...`);
|
|
149
|
-
if (totalBytes > 0) {
|
|
150
|
-
console.log(`Total size: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Read chunks with cancellation checks
|
|
154
|
-
while (true) {
|
|
155
|
-
// Check for cancellation between chunks
|
|
156
|
-
if (signal.aborted) {
|
|
157
|
-
|
|
158
|
-
console.warn("Download cancelled by user");
|
|
159
|
-
throw new Error("AbortError");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const { done, value } = await reader.read();
|
|
163
|
-
|
|
164
|
-
if (done) {
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Write chunk
|
|
169
|
-
partialFile.write(value);
|
|
170
|
-
downloadedBytes += value.byteLength;
|
|
171
|
-
|
|
172
|
-
// Log progress
|
|
173
|
-
if (totalBytes > 0) {
|
|
174
|
-
const percent = ((downloadedBytes / totalBytes) * 100).toFixed(1);
|
|
175
|
-
const mbDownloaded = (downloadedBytes / 1024 / 1024).toFixed(2);
|
|
176
|
-
const mbTotal = (totalBytes / 1024 / 1024).toFixed(2);
|
|
177
|
-
Bun.write(Bun.stdout, `\rProgress: ${percent}% (${mbDownloaded}/${mbTotal} MB)`);
|
|
178
|
-
} else {
|
|
179
|
-
const mbDownloaded = (downloadedBytes / 1024 / 1024).toFixed(2);
|
|
180
|
-
Bun.write(Bun.stdout, `\rDownloaded: ${mbDownloaded} MB`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Finalize file
|
|
185
|
-
await partialFile.end();
|
|
186
|
-
console.log("\nDownload complete!");
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
success: true,
|
|
190
|
-
data: {
|
|
191
|
-
file: outputPath,
|
|
192
|
-
size: downloadedBytes,
|
|
193
|
-
url: url.toString(),
|
|
194
|
-
},
|
|
195
|
-
message: `Downloaded ${fileName} (${(downloadedBytes / 1024 / 1024).toFixed(2)} MB)`,
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
} catch (error) {
|
|
199
|
-
// Handle cancellation
|
|
200
|
-
if (signal.aborted || (error as Error).name === "AbortError") {
|
|
201
|
-
|
|
202
|
-
console.log("\nDownload cancelled.");
|
|
203
|
-
|
|
204
|
-
// Cleanup partial file
|
|
205
|
-
await this.cleanup(outputPath, partialFile);
|
|
206
|
-
|
|
207
|
-
return {
|
|
208
|
-
success: false,
|
|
209
|
-
message: "Download cancelled by user",
|
|
210
|
-
data: {
|
|
211
|
-
downloadedBytes,
|
|
212
|
-
cancelled: true,
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Handle other errors
|
|
218
|
-
await this.cleanup(outputPath, partialFile);
|
|
219
|
-
throw error;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
private async cleanup(
|
|
224
|
-
outputPath: string,
|
|
225
|
-
sink: Bun.FileSink | null
|
|
226
|
-
): Promise<void> {
|
|
227
|
-
try {
|
|
228
|
-
// Close file handle
|
|
229
|
-
if (sink) {
|
|
230
|
-
await sink.end();
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Remove partial file
|
|
234
|
-
const file = Bun.file(outputPath);
|
|
235
|
-
if (await file.exists()) {
|
|
236
|
-
await Bun.write(outputPath, ""); // Clear file
|
|
237
|
-
// In production: fs.unlinkSync(outputPath);
|
|
238
|
-
console.log("Cleaned up partial download.");
|
|
239
|
-
}
|
|
240
|
-
} catch (e) {
|
|
241
|
-
// Ignore cleanup errors
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
## Step 4: Create the Application
|
|
248
|
-
|
|
249
|
-
Create `src/index.ts`:
|
|
250
|
-
|
|
251
|
-
```typescript
|
|
252
|
-
import { TuiApplication } from "@pablozaiden/terminatui";
|
|
253
|
-
import { DownloadCommand } from "./commands/download";
|
|
254
|
-
|
|
255
|
-
class DownloadManager extends TuiApplication {
|
|
256
|
-
constructor() {
|
|
257
|
-
super({
|
|
258
|
-
name: "download-manager",
|
|
259
|
-
version: "1.0.0",
|
|
260
|
-
commands: [new DownloadCommand()],
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
await new DownloadManager().run();
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
## Step 6: Test Cancellation
|
|
269
|
-
|
|
270
|
-
```bash
|
|
271
|
-
# Start a large download
|
|
272
|
-
bun src/index.ts download https://speed.hetzner.de/100MB.bin --output ./test-downloads
|
|
273
|
-
|
|
274
|
-
# While downloading, press Ctrl+C
|
|
275
|
-
# Should see: "Download cancelled. Cleaned up partial download."
|
|
276
|
-
|
|
277
|
-
# Run TUI mode
|
|
278
|
-
bun src/index.ts --tui
|
|
279
|
-
|
|
280
|
-
# Start download, then press Esc to cancel
|
|
281
|
-
# Same cancellation behavior with cleanup
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
## Cancellation Patterns
|
|
285
|
-
|
|
286
|
-
### 1. Check Signal Before Long Operations
|
|
287
|
-
|
|
288
|
-
```typescript
|
|
289
|
-
if (signal?.aborted) {
|
|
290
|
-
return { success: false, message: "Cancelled" };
|
|
291
|
-
}
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
### 2. Pass Signal to fetch/APIs
|
|
295
|
-
|
|
296
|
-
```typescript
|
|
297
|
-
await fetch(url, { signal });
|
|
298
|
-
await someAsyncApi({ signal });
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
### 3. Check Between Iterations
|
|
302
|
-
|
|
303
|
-
```typescript
|
|
304
|
-
for (const item of items) {
|
|
305
|
-
if (signal?.aborted) break;
|
|
306
|
-
await processItem(item);
|
|
307
|
-
}
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
### 4. Always Cleanup
|
|
311
|
-
|
|
312
|
-
```typescript
|
|
313
|
-
try {
|
|
314
|
-
// ... cancellable work ...
|
|
315
|
-
} catch (error) {
|
|
316
|
-
if (signal?.aborted) {
|
|
317
|
-
await cleanup();
|
|
318
|
-
return { success: false, message: "Cancelled" };
|
|
319
|
-
}
|
|
320
|
-
throw error;
|
|
321
|
-
}
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
## What You Learned
|
|
325
|
-
|
|
326
|
-
- Accept `CommandExecutionContext` with `AbortSignal`
|
|
327
|
-
- Check `signal.aborted` between operations
|
|
328
|
-
- Pass signal to `fetch` and other async APIs
|
|
329
|
-
- Clean up resources on cancellation
|
|
330
|
-
- Return meaningful results for cancelled operations
|
|
331
|
-
|
|
332
|
-
## Next Steps
|
|
333
|
-
|
|
334
|
-
→ [Guide 8: Building a Complete Application](08-complete-application.md)
|