@muhammedaksam/easiarr 0.4.3 → 0.5.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/package.json +1 -1
- package/src/api/arr-api.ts +156 -0
- package/src/api/portainer-api.ts +231 -0
- package/src/apps/registry.ts +1 -0
- package/src/config/schema.ts +35 -0
- package/src/index.ts +4 -0
- package/src/ui/components/FooterHint.ts +119 -0
- package/src/ui/components/PageLayout.ts +58 -10
- package/src/ui/screens/AdvancedSettings.ts +4 -1
- package/src/ui/screens/ApiKeyViewer.ts +146 -5
- package/src/ui/screens/AppConfigurator.ts +16 -5
- package/src/ui/screens/AppManager.ts +6 -1
- package/src/ui/screens/ContainerControl.ts +4 -1
- package/src/ui/screens/FullAutoSetup.ts +78 -2
- package/src/ui/screens/MainMenu.ts +19 -3
- package/src/ui/screens/MonitorDashboard.ts +866 -0
- package/src/ui/screens/ProwlarrSetup.ts +5 -1
- package/src/ui/screens/QBittorrentSetup.ts +4 -1
- package/src/ui/screens/QuickSetup.ts +36 -8
- package/src/ui/screens/TRaSHProfileSetup.ts +6 -1
- package/src/utils/password.ts +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
package/src/api/arr-api.ts
CHANGED
|
@@ -254,6 +254,40 @@ export class ArrApiClient {
|
|
|
254
254
|
async deleteRemotePathMapping(id: number): Promise<void> {
|
|
255
255
|
await this.request(`/remotepathmapping/${id}`, { method: "DELETE" })
|
|
256
256
|
}
|
|
257
|
+
|
|
258
|
+
// ==========================================
|
|
259
|
+
// Health Check & Status Methods
|
|
260
|
+
// ==========================================
|
|
261
|
+
|
|
262
|
+
// Get health issues/warnings
|
|
263
|
+
async getHealth(): Promise<HealthResource[]> {
|
|
264
|
+
return this.request<HealthResource[]>("/health")
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Get disk space information for all monitored paths
|
|
268
|
+
async getDiskSpace(): Promise<DiskSpaceResource[]> {
|
|
269
|
+
return this.request<DiskSpaceResource[]>("/diskspace")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Get system status (version, OS, runtime, etc.)
|
|
273
|
+
async getSystemStatus(): Promise<SystemResource> {
|
|
274
|
+
return this.request<SystemResource>("/system/status")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Get all items in the download queue
|
|
278
|
+
async getQueueDetails(includeUnknown = true): Promise<QueueResource[]> {
|
|
279
|
+
const params = new URLSearchParams()
|
|
280
|
+
if (includeUnknown) {
|
|
281
|
+
params.set("includeUnknownMovieItems", "true")
|
|
282
|
+
}
|
|
283
|
+
const query = params.toString()
|
|
284
|
+
return this.request<QueueResource[]>(`/queue/details${query ? `?${query}` : ""}`)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Get queue status summary (counts, errors, warnings)
|
|
288
|
+
async getQueueStatus(): Promise<QueueStatusResource> {
|
|
289
|
+
return this.request<QueueStatusResource>("/queue/status")
|
|
290
|
+
}
|
|
257
291
|
}
|
|
258
292
|
|
|
259
293
|
// Types for Host Config API
|
|
@@ -297,3 +331,125 @@ export interface HostConfig {
|
|
|
297
331
|
backupRetention: number
|
|
298
332
|
trustCgnatIpAddresses: boolean
|
|
299
333
|
}
|
|
334
|
+
|
|
335
|
+
// ==========================================
|
|
336
|
+
// Health Check & Status Types
|
|
337
|
+
// ==========================================
|
|
338
|
+
|
|
339
|
+
// Health check result types
|
|
340
|
+
export type HealthCheckType = "ok" | "notice" | "warning" | "error"
|
|
341
|
+
|
|
342
|
+
export interface HealthResource {
|
|
343
|
+
id?: number
|
|
344
|
+
source: string | null
|
|
345
|
+
type: HealthCheckType
|
|
346
|
+
message: string | null
|
|
347
|
+
wikiUrl: string | null
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Disk space types
|
|
351
|
+
export interface DiskSpaceResource {
|
|
352
|
+
id?: number
|
|
353
|
+
path: string | null
|
|
354
|
+
label: string | null
|
|
355
|
+
freeSpace: number // int64
|
|
356
|
+
totalSpace: number // int64
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// System status types
|
|
360
|
+
export type RuntimeMode = "console" | "service" | "tray"
|
|
361
|
+
export type DatabaseType = "sqLite" | "postgreSQL"
|
|
362
|
+
|
|
363
|
+
export interface SystemResource {
|
|
364
|
+
appName: string | null
|
|
365
|
+
instanceName: string | null
|
|
366
|
+
version: string | null
|
|
367
|
+
buildTime: string | null
|
|
368
|
+
isDebug: boolean
|
|
369
|
+
isProduction: boolean
|
|
370
|
+
isAdmin: boolean
|
|
371
|
+
isUserInteractive: boolean
|
|
372
|
+
startupPath: string | null
|
|
373
|
+
appData: string | null
|
|
374
|
+
osName: string | null
|
|
375
|
+
osVersion: string | null
|
|
376
|
+
isNetCore: boolean
|
|
377
|
+
isLinux: boolean
|
|
378
|
+
isOsx: boolean
|
|
379
|
+
isWindows: boolean
|
|
380
|
+
isDocker: boolean
|
|
381
|
+
mode: RuntimeMode
|
|
382
|
+
branch: string | null
|
|
383
|
+
databaseType: DatabaseType
|
|
384
|
+
databaseVersion: string | null
|
|
385
|
+
authentication: "none" | "basic" | "forms" | "external"
|
|
386
|
+
migrationVersion: number
|
|
387
|
+
urlBase: string | null
|
|
388
|
+
runtimeVersion: string | null
|
|
389
|
+
runtimeName: string | null
|
|
390
|
+
startTime: string | null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Queue types
|
|
394
|
+
export type QueueStatus =
|
|
395
|
+
| "unknown"
|
|
396
|
+
| "queued"
|
|
397
|
+
| "paused"
|
|
398
|
+
| "downloading"
|
|
399
|
+
| "completed"
|
|
400
|
+
| "failed"
|
|
401
|
+
| "warning"
|
|
402
|
+
| "delay"
|
|
403
|
+
| "downloadClientUnavailable"
|
|
404
|
+
| "fallback"
|
|
405
|
+
|
|
406
|
+
export type TrackedDownloadStatus = "ok" | "warning" | "error"
|
|
407
|
+
export type TrackedDownloadState =
|
|
408
|
+
| "downloading"
|
|
409
|
+
| "importBlocked"
|
|
410
|
+
| "importPending"
|
|
411
|
+
| "importing"
|
|
412
|
+
| "imported"
|
|
413
|
+
| "failedPending"
|
|
414
|
+
| "failed"
|
|
415
|
+
| "ignored"
|
|
416
|
+
|
|
417
|
+
export interface QueueStatusMessage {
|
|
418
|
+
title: string | null
|
|
419
|
+
messages: string[] | null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface QueueResource {
|
|
423
|
+
id?: number
|
|
424
|
+
movieId?: number | null // Radarr
|
|
425
|
+
seriesId?: number | null // Sonarr
|
|
426
|
+
artistId?: number | null // Lidarr
|
|
427
|
+
authorId?: number | null // Readarr
|
|
428
|
+
title: string | null
|
|
429
|
+
size: number
|
|
430
|
+
sizeleft?: number
|
|
431
|
+
timeleft?: string | null
|
|
432
|
+
estimatedCompletionTime: string | null
|
|
433
|
+
added: string | null
|
|
434
|
+
status: QueueStatus
|
|
435
|
+
trackedDownloadStatus: TrackedDownloadStatus
|
|
436
|
+
trackedDownloadState: TrackedDownloadState
|
|
437
|
+
statusMessages?: QueueStatusMessage[] | null
|
|
438
|
+
errorMessage: string | null
|
|
439
|
+
downloadId: string | null
|
|
440
|
+
protocol: "unknown" | "usenet" | "torrent"
|
|
441
|
+
downloadClient: string | null
|
|
442
|
+
indexer: string | null
|
|
443
|
+
outputPath: string | null
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export interface QueueStatusResource {
|
|
447
|
+
id?: number
|
|
448
|
+
totalCount: number
|
|
449
|
+
count: number
|
|
450
|
+
unknownCount: number
|
|
451
|
+
errors: boolean
|
|
452
|
+
warnings: boolean
|
|
453
|
+
unknownErrors: boolean
|
|
454
|
+
unknownWarnings: boolean
|
|
455
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portainer API Client
|
|
3
|
+
* Handles Portainer-specific API calls for initialization and management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { debugLog } from "../utils/debug"
|
|
7
|
+
import { ensureMinPasswordLength } from "../utils/password"
|
|
8
|
+
|
|
9
|
+
// Portainer requires minimum 12 character password
|
|
10
|
+
export const PORTAINER_MIN_PASSWORD_LENGTH = 12
|
|
11
|
+
|
|
12
|
+
export interface PortainerUser {
|
|
13
|
+
Id?: number
|
|
14
|
+
Username: string
|
|
15
|
+
Password?: string
|
|
16
|
+
Role?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Result from admin initialization - includes actual password used
|
|
20
|
+
export interface PortainerInitResult {
|
|
21
|
+
user: PortainerUser
|
|
22
|
+
/** The actual password used (may be padded if global was < 12 chars) */
|
|
23
|
+
actualPassword: string
|
|
24
|
+
/** True if password was modified (padded) from the original */
|
|
25
|
+
passwordWasPadded: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PortainerStatus {
|
|
29
|
+
Version: string
|
|
30
|
+
InstanceID: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PortainerSettings {
|
|
34
|
+
AuthenticationMethod: number
|
|
35
|
+
LogoURL: string
|
|
36
|
+
BlackListedLabels: string[]
|
|
37
|
+
InternalAuthSettings: {
|
|
38
|
+
RequiredPasswordLength: number
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Auth response from login
|
|
43
|
+
export interface PortainerAuthResponse {
|
|
44
|
+
jwt: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// API Key creation response
|
|
48
|
+
export interface PortainerApiKeyResponse {
|
|
49
|
+
rawAPIKey: string
|
|
50
|
+
apiKey: {
|
|
51
|
+
id: number
|
|
52
|
+
userId: number
|
|
53
|
+
description: string
|
|
54
|
+
prefix: string
|
|
55
|
+
dateCreated: number
|
|
56
|
+
lastUsed: number
|
|
57
|
+
digest: string
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Portainer API Client
|
|
63
|
+
*/
|
|
64
|
+
export class PortainerApiClient {
|
|
65
|
+
private baseUrl: string
|
|
66
|
+
private jwtToken: string | null = null
|
|
67
|
+
|
|
68
|
+
constructor(host: string, port: number) {
|
|
69
|
+
this.baseUrl = `http://${host}:${port}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
73
|
+
const url = `${this.baseUrl}/api${endpoint}`
|
|
74
|
+
const headers: Record<string, string> = {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
...(options.headers as Record<string, string>),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Add JWT token if authenticated
|
|
80
|
+
if (this.jwtToken) {
|
|
81
|
+
headers["Authorization"] = `Bearer ${this.jwtToken}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
debugLog("PortainerAPI", `${options.method || "GET"} ${url}`)
|
|
85
|
+
if (options.body) {
|
|
86
|
+
debugLog("PortainerAPI", `Request Body: ${options.body}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await fetch(url, { ...options, headers })
|
|
90
|
+
const text = await response.text()
|
|
91
|
+
|
|
92
|
+
debugLog("PortainerAPI", `Response ${response.status} from ${endpoint}`)
|
|
93
|
+
if (text && text.length < 2000) {
|
|
94
|
+
debugLog("PortainerAPI", `Response Body: ${text}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`Portainer API request failed: ${response.status} ${response.statusText} - ${text}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!text) return {} as T
|
|
102
|
+
return JSON.parse(text) as T
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if Portainer needs initial admin user setup.
|
|
107
|
+
* Returns true if no admin user exists yet.
|
|
108
|
+
*/
|
|
109
|
+
async needsInitialization(): Promise<boolean> {
|
|
110
|
+
try {
|
|
111
|
+
const response = await fetch(`${this.baseUrl}/api/users/admin/check`)
|
|
112
|
+
// 404 means no admin exists yet - needs initialization
|
|
113
|
+
// 204 means admin already exists
|
|
114
|
+
return response.status === 404
|
|
115
|
+
} catch (error) {
|
|
116
|
+
debugLog("PortainerAPI", `Check admin error: ${error}`)
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Login to Portainer and store JWT token for subsequent requests.
|
|
123
|
+
*
|
|
124
|
+
* @param username - Admin username
|
|
125
|
+
* @param password - Admin password
|
|
126
|
+
* @returns JWT token
|
|
127
|
+
*/
|
|
128
|
+
async login(username: string, password: string): Promise<string> {
|
|
129
|
+
const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
|
|
130
|
+
|
|
131
|
+
const response = await this.request<PortainerAuthResponse>("/auth", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
username,
|
|
135
|
+
password: safePassword,
|
|
136
|
+
}),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
this.jwtToken = response.jwt
|
|
140
|
+
return response.jwt
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Initialize the admin user for a fresh Portainer installation.
|
|
145
|
+
* Password will be automatically padded if shorter than 12 characters.
|
|
146
|
+
* Automatically logs in after initialization.
|
|
147
|
+
*
|
|
148
|
+
* @param username - Admin username
|
|
149
|
+
* @param password - Admin password (will be padded if needed)
|
|
150
|
+
* @returns Init result with user, actual password, and padding flag - or null if already initialized
|
|
151
|
+
*/
|
|
152
|
+
async initializeAdmin(username: string, password: string): Promise<PortainerInitResult | null> {
|
|
153
|
+
// Check if initialization is needed
|
|
154
|
+
const needsInit = await this.needsInitialization()
|
|
155
|
+
if (!needsInit) {
|
|
156
|
+
debugLog("PortainerAPI", "Admin already initialized, skipping")
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Ensure password meets Portainer's minimum length requirement
|
|
161
|
+
const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
|
|
162
|
+
const wasPadded = safePassword !== password
|
|
163
|
+
|
|
164
|
+
if (wasPadded) {
|
|
165
|
+
debugLog("PortainerAPI", `Password padded from ${password.length} to ${safePassword.length} characters`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const user = await this.request<PortainerUser>("/users/admin/init", {
|
|
169
|
+
method: "POST",
|
|
170
|
+
body: JSON.stringify({
|
|
171
|
+
Username: username,
|
|
172
|
+
Password: safePassword,
|
|
173
|
+
}),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Auto-login after initialization
|
|
177
|
+
await this.login(username, safePassword)
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
user,
|
|
181
|
+
actualPassword: safePassword,
|
|
182
|
+
passwordWasPadded: wasPadded,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Generate a permanent API key for the authenticated user.
|
|
188
|
+
* Must be logged in first (call login() or initializeAdmin()).
|
|
189
|
+
*
|
|
190
|
+
* @param userId - User ID (default: 1 for admin)
|
|
191
|
+
* @param description - Description for the API key
|
|
192
|
+
* @param password - User password for confirmation
|
|
193
|
+
* @returns Raw API key to save to .env as API_KEY_PORTAINER
|
|
194
|
+
*/
|
|
195
|
+
async generateApiKey(password: string, description: string = "easiarr-api-key", userId: number = 1): Promise<string> {
|
|
196
|
+
if (!this.jwtToken) {
|
|
197
|
+
throw new Error("Must be logged in to generate API key. Call login() first.")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
|
|
201
|
+
|
|
202
|
+
const response = await this.request<PortainerApiKeyResponse>(`/users/${userId}/tokens`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
password: safePassword,
|
|
206
|
+
description,
|
|
207
|
+
}),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return response.rawAPIKey
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get Portainer system status
|
|
215
|
+
*/
|
|
216
|
+
async getStatus(): Promise<PortainerStatus> {
|
|
217
|
+
return this.request<PortainerStatus>("/status")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Check if Portainer is reachable
|
|
222
|
+
*/
|
|
223
|
+
async isHealthy(): Promise<boolean> {
|
|
224
|
+
try {
|
|
225
|
+
await this.getStatus()
|
|
226
|
+
return true
|
|
227
|
+
} catch {
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -448,6 +448,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
448
448
|
puid: 0,
|
|
449
449
|
pgid: 0,
|
|
450
450
|
volumes: (root) => [`${root}/config/portainer:/data`, "/var/run/docker.sock:/var/run/docker.sock"],
|
|
451
|
+
minPasswordLength: 12, // Portainer requires minimum 12 character password
|
|
451
452
|
},
|
|
452
453
|
|
|
453
454
|
huntarr: {
|
package/src/config/schema.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface EasiarrConfig {
|
|
|
16
16
|
network?: NetworkConfig
|
|
17
17
|
traefik?: TraefikConfig
|
|
18
18
|
vpn?: VpnConfig
|
|
19
|
+
monitor?: MonitorConfig
|
|
19
20
|
createdAt: string
|
|
20
21
|
updatedAt: string
|
|
21
22
|
}
|
|
@@ -27,6 +28,38 @@ export interface VpnConfig {
|
|
|
27
28
|
provider?: string // For future use (e.g. custom, airvpn, protonvpn)
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// ==========================================
|
|
32
|
+
// Monitoring Configuration
|
|
33
|
+
// ==========================================
|
|
34
|
+
|
|
35
|
+
export type MonitorCheckType = "health" | "diskspace" | "status" | "queue"
|
|
36
|
+
|
|
37
|
+
export interface MonitorOptions {
|
|
38
|
+
health: boolean
|
|
39
|
+
diskspace: boolean
|
|
40
|
+
status: boolean
|
|
41
|
+
queue: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CategoryMonitorConfig {
|
|
45
|
+
category: AppCategory
|
|
46
|
+
enabled: boolean
|
|
47
|
+
checks: MonitorOptions
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AppMonitorConfig {
|
|
51
|
+
appId: AppId
|
|
52
|
+
override: boolean // If true, uses app-specific settings instead of category defaults
|
|
53
|
+
enabled: boolean
|
|
54
|
+
checks: MonitorOptions
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MonitorConfig {
|
|
58
|
+
categories: CategoryMonitorConfig[]
|
|
59
|
+
apps: AppMonitorConfig[] // App-specific overrides
|
|
60
|
+
pollIntervalSeconds: number
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
export interface TraefikConfig {
|
|
31
64
|
enabled: boolean
|
|
32
65
|
domain: string
|
|
@@ -155,6 +188,8 @@ export interface AppDefinition {
|
|
|
155
188
|
prowlarrCategoryIds?: number[]
|
|
156
189
|
/** Architecture compatibility info - omit if supports all */
|
|
157
190
|
arch?: ArchCompatibility
|
|
191
|
+
/** Minimum password length requirement for user creation */
|
|
192
|
+
minPasswordLength?: number
|
|
158
193
|
}
|
|
159
194
|
|
|
160
195
|
export interface RootFolderMeta {
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer Hint Types and Utilities
|
|
3
|
+
* Provides a flexible array-based structure for footer hints
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Base interface for all footer hint types with common styling */
|
|
7
|
+
export interface FooterHintBase {
|
|
8
|
+
/** Foreground color */
|
|
9
|
+
fg?: string
|
|
10
|
+
/** Background color */
|
|
11
|
+
bg?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A simple text message hint (e.g., "Press ? for help.") */
|
|
15
|
+
export interface FooterHintText extends FooterHintBase {
|
|
16
|
+
type: "text"
|
|
17
|
+
value: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A keyboard shortcut hint (e.g., key: "↓", value: "Down") */
|
|
21
|
+
export interface FooterHintKey extends FooterHintBase {
|
|
22
|
+
type: "key"
|
|
23
|
+
key: string
|
|
24
|
+
value: string
|
|
25
|
+
/** Color for the key part (default: bright/highlighted) */
|
|
26
|
+
keyColor?: string
|
|
27
|
+
/** Color for the value part (default: dimmer) */
|
|
28
|
+
valueColor?: string
|
|
29
|
+
/** Background color for the key badge */
|
|
30
|
+
keyBgColor?: string
|
|
31
|
+
/** Whether to show brackets around the key */
|
|
32
|
+
withBrackets?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Separator between hint groups */
|
|
36
|
+
export interface FooterHintSeparator extends FooterHintBase {
|
|
37
|
+
type: "separator"
|
|
38
|
+
char?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Union type for all hint item types */
|
|
42
|
+
export type FooterHintItem = FooterHintText | FooterHintKey | FooterHintSeparator
|
|
43
|
+
|
|
44
|
+
/** Array of hint items */
|
|
45
|
+
export type FooterHint = FooterHintItem[]
|
|
46
|
+
|
|
47
|
+
/** Default separator character */
|
|
48
|
+
const DEFAULT_SEPARATOR = " "
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render footer hints to a plain string
|
|
52
|
+
* Used by PageLayout for backward-compatible rendering
|
|
53
|
+
*/
|
|
54
|
+
export function renderFooterHint(hints: FooterHint): string {
|
|
55
|
+
return hints
|
|
56
|
+
.map((item) => {
|
|
57
|
+
switch (item.type) {
|
|
58
|
+
case "text":
|
|
59
|
+
return item.value
|
|
60
|
+
case "key":
|
|
61
|
+
return `${item.key}: ${item.value}`
|
|
62
|
+
case "separator":
|
|
63
|
+
return item.char ?? DEFAULT_SEPARATOR
|
|
64
|
+
default:
|
|
65
|
+
return ""
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.join(DEFAULT_SEPARATOR)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse legacy string format to FooterHint array
|
|
73
|
+
* Supports both "Key: Action" and "Key Action" formats
|
|
74
|
+
*/
|
|
75
|
+
export function parseFooterHintString(hint: string): FooterHint {
|
|
76
|
+
// Split on double spaces (common separator in existing hints)
|
|
77
|
+
const parts = hint.split(/\s{2,}/)
|
|
78
|
+
|
|
79
|
+
return parts.map((part): FooterHintItem => {
|
|
80
|
+
// Try to parse as "Key: Action" or "Key Action" format
|
|
81
|
+
const colonMatch = part.match(/^([^\s:]+):\s*(.+)$/)
|
|
82
|
+
if (colonMatch) {
|
|
83
|
+
return { type: "key", key: colonMatch[1], value: colonMatch[2] }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const spaceMatch = part.match(/^([^\s]+)\s+(.+)$/)
|
|
87
|
+
if (spaceMatch) {
|
|
88
|
+
return { type: "key", key: spaceMatch[1], value: spaceMatch[2] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fallback to text
|
|
92
|
+
return { type: "text", value: part }
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Helper to create a key hint
|
|
98
|
+
*/
|
|
99
|
+
export function key(
|
|
100
|
+
key: string,
|
|
101
|
+
value: string,
|
|
102
|
+
options?: { keyColor?: string; valueColor?: string; keyBgColor?: string; withBrackets?: boolean }
|
|
103
|
+
): FooterHintKey {
|
|
104
|
+
return { type: "key", key, value, ...options }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Helper to create a text hint
|
|
109
|
+
*/
|
|
110
|
+
export function text(value: string, fg?: string): FooterHintText {
|
|
111
|
+
return { type: "text", value, fg }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Helper to create a separator
|
|
116
|
+
*/
|
|
117
|
+
export function separator(char?: string): FooterHintSeparator {
|
|
118
|
+
return { type: "separator", char }
|
|
119
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core"
|
|
1
|
+
import { BoxRenderable, TextRenderable, TextNodeRenderable, type CliRenderer } from "@opentui/core"
|
|
2
2
|
import { getVersion } from "../../VersionInfo"
|
|
3
|
+
import { type FooterHint } from "./FooterHint"
|
|
3
4
|
|
|
4
5
|
export interface PageLayoutOptions {
|
|
5
6
|
title: string
|
|
6
7
|
stepInfo?: string
|
|
7
|
-
footerHint?: string
|
|
8
|
+
footerHint?: FooterHint | string
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export interface PageLayoutResult {
|
|
@@ -79,15 +80,62 @@ export function createPageLayout(renderer: CliRenderer, options: PageLayoutOptio
|
|
|
79
80
|
backgroundColor: "#1a1a2e",
|
|
80
81
|
})
|
|
81
82
|
|
|
82
|
-
// Hint text
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
// Hint text - handle both array and string formats
|
|
84
|
+
// Always append separator + Ctrl+C Exit when hints are provided
|
|
85
|
+
const hintContainer = new TextRenderable(renderer, {
|
|
86
|
+
id: `${idPrefix}-footer-hint`,
|
|
87
|
+
content: "",
|
|
88
|
+
fg: "#aaaaaa",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (!footerHint) {
|
|
92
|
+
hintContainer.content = "↑↓: Navigate Enter: Select Ctrl+C: Exit"
|
|
93
|
+
} else if (typeof footerHint === "string") {
|
|
94
|
+
hintContainer.content = footerHint + " Ctrl+C: Exit"
|
|
95
|
+
} else {
|
|
96
|
+
// Append separator and global Ctrl+C hint (styled red for demo)
|
|
97
|
+
const hintsWithExit: FooterHint = [
|
|
98
|
+
...footerHint,
|
|
99
|
+
{ type: "separator", char: " | " },
|
|
100
|
+
{ type: "key", key: "Ctrl+C", value: "Exit", keyColor: "#ff6666", valueColor: "#888888" },
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
// Build styled content using TextNodeRenderable
|
|
104
|
+
const DEFAULT_KEY_COLOR = "#8be9fd" // cyan/bright
|
|
105
|
+
const DEFAULT_VALUE_COLOR = "#aaaaaa" // dim
|
|
106
|
+
const DEFAULT_SEP_COLOR = "#555555"
|
|
107
|
+
|
|
108
|
+
// Helper to create a styled text node
|
|
109
|
+
const styledText = (text: string, fg?: string, bg?: string): TextNodeRenderable => {
|
|
110
|
+
const node = new TextNodeRenderable({ fg, bg })
|
|
111
|
+
node.add(text)
|
|
112
|
+
return node
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
hintsWithExit.forEach((item, idx) => {
|
|
116
|
+
if (item.type === "separator") {
|
|
117
|
+
hintContainer.add(styledText(item.char ?? " ", DEFAULT_SEP_COLOR))
|
|
118
|
+
} else if (item.type === "text") {
|
|
119
|
+
hintContainer.add(styledText(item.value, item.fg ?? DEFAULT_VALUE_COLOR))
|
|
120
|
+
// Add spacing after text (except last)
|
|
121
|
+
if (idx < hintsWithExit.length - 1 && hintsWithExit[idx + 1]?.type !== "separator") {
|
|
122
|
+
hintContainer.add(styledText(" "))
|
|
123
|
+
}
|
|
124
|
+
} else if (item.type === "key") {
|
|
125
|
+
const keyDisplay = item.withBrackets ? `[${item.key}]` : item.key
|
|
126
|
+
// Key part (styled)
|
|
127
|
+
hintContainer.add(styledText(keyDisplay, item.keyColor ?? DEFAULT_KEY_COLOR, item.keyBgColor))
|
|
128
|
+
// Colon + Value
|
|
129
|
+
hintContainer.add(styledText(`: ${item.value}`, item.valueColor ?? DEFAULT_VALUE_COLOR))
|
|
130
|
+
// Add spacing after (except last or before separator)
|
|
131
|
+
if (idx < hintsWithExit.length - 1 && hintsWithExit[idx + 1]?.type !== "separator") {
|
|
132
|
+
hintContainer.add(styledText(" "))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
89
135
|
})
|
|
90
|
-
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
footerBox.add(hintContainer)
|
|
91
139
|
|
|
92
140
|
// Version
|
|
93
141
|
footerBox.add(
|
|
@@ -58,7 +58,10 @@ export class AdvancedSettings {
|
|
|
58
58
|
const { container: page, content } = createPageLayout(this.renderer, {
|
|
59
59
|
title: "Advanced Settings",
|
|
60
60
|
stepInfo: "Direct File Editing",
|
|
61
|
-
footerHint:
|
|
61
|
+
footerHint: [
|
|
62
|
+
{ type: "key", key: "Enter", value: "Select" },
|
|
63
|
+
{ type: "key", key: "Esc", value: "Back" },
|
|
64
|
+
],
|
|
62
65
|
})
|
|
63
66
|
|
|
64
67
|
const menu = new SelectRenderable(this.renderer, {
|