@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.4.3",
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",
@@ -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
+ }
@@ -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: {
@@ -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
@@ -21,6 +21,10 @@ async function main() {
21
21
  consoleOptions: {
22
22
  startInDebugMode: false,
23
23
  },
24
+ exitOnCtrlC: true,
25
+ onDestroy: () => {
26
+ process.exit(0)
27
+ },
24
28
  })
25
29
 
26
30
  const app = new App(renderer)
@@ -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
- const hintText = footerHint || "↑↓ Navigate Enter Select q Quit"
84
- footerBox.add(
85
- new TextRenderable(renderer, {
86
- id: `${idPrefix}-footer-hint`,
87
- content: hintText,
88
- fg: "#aaaaaa",
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: "Enter Select Esc Back",
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, {