@muhammedaksam/easiarr 1.4.0 → 1.6.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.
Files changed (109) hide show
  1. package/dist/index.js +29750 -0
  2. package/package.json +15 -15
  3. package/src/VersionInfo.ts +0 -12
  4. package/src/api/arr-api.ts +0 -521
  5. package/src/api/auto-setup-types.ts +0 -64
  6. package/src/api/base-api.ts +0 -216
  7. package/src/api/bazarr-api.ts +0 -297
  8. package/src/api/cloudflare-api.ts +0 -493
  9. package/src/api/custom-format-api.ts +0 -224
  10. package/src/api/grafana-api.ts +0 -230
  11. package/src/api/heimdall-api.ts +0 -141
  12. package/src/api/homarr-api.ts +0 -209
  13. package/src/api/huntarr-api.ts +0 -457
  14. package/src/api/index.ts +0 -9
  15. package/src/api/jellyfin-api.ts +0 -389
  16. package/src/api/jellyseerr-api.ts +0 -559
  17. package/src/api/maintainerr-api.ts +0 -461
  18. package/src/api/naming-config.ts +0 -101
  19. package/src/api/overseerr-api.ts +0 -392
  20. package/src/api/plex-api.ts +0 -308
  21. package/src/api/portainer-api.ts +0 -374
  22. package/src/api/profilarr-api.ts +0 -391
  23. package/src/api/prowlarr-api.ts +0 -488
  24. package/src/api/qbittorrent-api.ts +0 -300
  25. package/src/api/quality-profile-api.ts +0 -272
  26. package/src/api/tautulli-api.ts +0 -244
  27. package/src/api/uptime-kuma-api.ts +0 -343
  28. package/src/apps/categories.ts +0 -14
  29. package/src/apps/index.ts +0 -2
  30. package/src/apps/registry.ts +0 -1275
  31. package/src/compose/caddy-config.ts +0 -130
  32. package/src/compose/generator.ts +0 -287
  33. package/src/compose/index.ts +0 -7
  34. package/src/compose/templates.ts +0 -78
  35. package/src/compose/traefik-config.ts +0 -187
  36. package/src/config/bookmarks-generator.ts +0 -160
  37. package/src/config/defaults.ts +0 -37
  38. package/src/config/homepage-config.ts +0 -381
  39. package/src/config/index.ts +0 -6
  40. package/src/config/manager.ts +0 -150
  41. package/src/config/recyclarr-config.ts +0 -181
  42. package/src/config/schema.ts +0 -310
  43. package/src/config/slskd-config.ts +0 -86
  44. package/src/config/soularr-config.ts +0 -105
  45. package/src/config/trash-quality-definitions.ts +0 -213
  46. package/src/data/lidarr-custom-formats.ts +0 -127
  47. package/src/data/trash-profiles.ts +0 -333
  48. package/src/docker/client.ts +0 -312
  49. package/src/docker/index.ts +0 -18
  50. package/src/index.ts +0 -77
  51. package/src/setup/actions/arr-common.ts +0 -307
  52. package/src/setup/actions/dashboards.ts +0 -79
  53. package/src/setup/actions/download-clients.ts +0 -54
  54. package/src/setup/actions/jellyseerr.ts +0 -331
  55. package/src/setup/actions/media-servers.ts +0 -68
  56. package/src/setup/actions/monitoring.ts +0 -120
  57. package/src/setup/actions/prowlarr.ts +0 -137
  58. package/src/setup/actions/utilities.ts +0 -490
  59. package/src/setup/index.ts +0 -78
  60. package/src/setup/types.ts +0 -144
  61. package/src/structure/manager.ts +0 -129
  62. package/src/ui/App.ts +0 -111
  63. package/src/ui/components/ApplicationSelector.ts +0 -275
  64. package/src/ui/components/CredentialsForm.ts +0 -180
  65. package/src/ui/components/DownloadClientForm.ts +0 -114
  66. package/src/ui/components/FileEditor.ts +0 -91
  67. package/src/ui/components/FooterHint.ts +0 -119
  68. package/src/ui/components/PageLayout.ts +0 -160
  69. package/src/ui/components/UpdateNotification.ts +0 -102
  70. package/src/ui/index.ts +0 -7
  71. package/src/ui/screens/AdvancedSettings.ts +0 -188
  72. package/src/ui/screens/ApiKeyViewer.ts +0 -511
  73. package/src/ui/screens/AppConfigurator.ts +0 -543
  74. package/src/ui/screens/AppManager.ts +0 -284
  75. package/src/ui/screens/ContainerControl.ts +0 -581
  76. package/src/ui/screens/LogsViewer.ts +0 -478
  77. package/src/ui/screens/MainMenu.ts +0 -299
  78. package/src/ui/screens/MonitorDashboard.ts +0 -881
  79. package/src/ui/screens/SecretsEditor.ts +0 -216
  80. package/src/ui/screens/SettingsScreen.ts +0 -633
  81. package/src/ui/screens/index.ts +0 -22
  82. package/src/ui/screens/setup/BaseAppSetupScreen.ts +0 -375
  83. package/src/ui/screens/setup/CloudflaredSetup.ts +0 -460
  84. package/src/ui/screens/setup/FullAutoSetup.ts +0 -766
  85. package/src/ui/screens/setup/HomepageSetup.ts +0 -165
  86. package/src/ui/screens/setup/JellyfinSetup.ts +0 -328
  87. package/src/ui/screens/setup/JellyseerrSetup.ts +0 -242
  88. package/src/ui/screens/setup/ProwlarrSetup.ts +0 -500
  89. package/src/ui/screens/setup/QBittorrentSetup.ts +0 -221
  90. package/src/ui/screens/setup/QuickSetup.ts +0 -1169
  91. package/src/ui/screens/setup/RecyclarrSetup.ts +0 -270
  92. package/src/ui/screens/setup/TRaSHProfileSetup.ts +0 -381
  93. package/src/utils/arch.ts +0 -78
  94. package/src/utils/browser.ts +0 -27
  95. package/src/utils/categories.ts +0 -61
  96. package/src/utils/debug.ts +0 -84
  97. package/src/utils/env.ts +0 -138
  98. package/src/utils/index.ts +0 -8
  99. package/src/utils/logs.ts +0 -119
  100. package/src/utils/migrations/1765626338_rename_env_variables.ts +0 -102
  101. package/src/utils/migrations/1765707135_rename_easiarr_status.ts +0 -91
  102. package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +0 -45
  103. package/src/utils/migrations/1769515650_migrate_to_xdg.ts +0 -114
  104. package/src/utils/migrations.ts +0 -165
  105. package/src/utils/password.ts +0 -42
  106. package/src/utils/paths.ts +0 -33
  107. package/src/utils/unraid.ts +0 -103
  108. package/src/utils/update-checker.ts +0 -210
  109. package/src/utils/url-utils.ts +0 -38
@@ -1,129 +0,0 @@
1
- import { mkdir } from "node:fs/promises"
2
- import { join } from "node:path"
3
-
4
- import type { AppId, EasiarrConfig } from "~/config/schema"
5
- import { getApp } from "~/apps/registry"
6
-
7
- const BASE_DIRS = ["torrents", "usenet", "media"]
8
-
9
- // Map apps to their content type folders
10
- const CONTENT_TYPE_MAP: Partial<Record<AppId, string>> = {
11
- radarr: "movies",
12
- sonarr: "tv",
13
- lidarr: "music",
14
- readarr: "books",
15
- mylar3: "comics",
16
- whisparr: "adult",
17
- }
18
-
19
- export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<void> {
20
- try {
21
- const dataRoot = join(config.rootDir, "data")
22
- const configRoot = join(config.rootDir, "config")
23
-
24
- // Create base directories
25
- await mkdir(dataRoot, { recursive: true })
26
- await mkdir(configRoot, { recursive: true })
27
-
28
- // 1. Create Base Directories (torrents, usenet, media)
29
- for (const dir of BASE_DIRS) {
30
- await mkdir(join(dataRoot, dir), { recursive: true })
31
- }
32
-
33
- // 2. Create Content Subdirectories based on enabled apps
34
- const enabledApps = new Set(config.apps.filter((a) => a.enabled).map((a) => a.id))
35
-
36
- for (const [appId, contentType] of Object.entries(CONTENT_TYPE_MAP)) {
37
- if (enabledApps.has(appId as AppId)) {
38
- // TRaSH Structure:
39
- // - Torrents: flat categories (data/torrents/movies, etc.)
40
- // - Usenet: categories inside complete (data/usenet/complete/movies, etc.)
41
- // - Media: flat library folders (data/media/movies, etc.)
42
- await mkdir(join(dataRoot, "torrents", contentType), { recursive: true })
43
- await mkdir(join(dataRoot, "usenet", "complete", contentType), { recursive: true })
44
- await mkdir(join(dataRoot, "media", contentType), { recursive: true })
45
- }
46
- }
47
-
48
- // 3. Special cases & MediaStack Extras
49
-
50
- // Always create 'photos' in media (Personal photos)
51
- await mkdir(join(dataRoot, "media", "photos"), { recursive: true })
52
-
53
- // TRaSH: Usenet has incomplete/complete structure, torrents do NOT
54
- await mkdir(join(dataRoot, "usenet", "incomplete"), { recursive: true })
55
- await mkdir(join(dataRoot, "usenet", "complete"), { recursive: true })
56
-
57
- // Manual download categories for both torrent and usenet
58
- for (const base of ["torrents", "usenet"]) {
59
- await mkdir(join(dataRoot, base, "console"), { recursive: true })
60
- await mkdir(join(dataRoot, base, "software"), { recursive: true })
61
- // Create 'watch' folder for manual .torrent/.nzb drops
62
- await mkdir(join(dataRoot, base, "watch"), { recursive: true })
63
- }
64
-
65
- if (enabledApps.has("prowlarr")) {
66
- // Prowlarr uncategorized downloads
67
- await mkdir(join(dataRoot, "torrents", "prowlarr"), { recursive: true })
68
- await mkdir(join(dataRoot, "usenet", "prowlarr"), { recursive: true })
69
- }
70
-
71
- if (enabledApps.has("filebot")) {
72
- await mkdir(join(dataRoot, "filebot", "input"), { recursive: true })
73
- await mkdir(join(dataRoot, "filebot", "output"), { recursive: true })
74
- }
75
-
76
- if (enabledApps.has("audiobookshelf")) {
77
- // Audiobookshelf usually resides in media/audiobooks and media/podcasts
78
- await mkdir(join(dataRoot, "media", "audiobooks"), { recursive: true })
79
- await mkdir(join(dataRoot, "media", "podcasts"), { recursive: true })
80
- }
81
-
82
- // 4. Create config directories for enabled apps
83
- // Dynamically check each app's volume definitions to see if it needs a config dir
84
- for (const appConfig of config.apps) {
85
- if (appConfig.enabled) {
86
- const appDef = getApp(appConfig.id)
87
- if (appDef) {
88
- // Check if app has volume that maps to config dir
89
- const volumes = appDef.volumes("$ROOT")
90
- const hasConfigVolume = volumes.some((v) => v.includes("/config/"))
91
- if (hasConfigVolume) {
92
- await mkdir(join(configRoot, appConfig.id), { recursive: true })
93
- }
94
- }
95
- }
96
- }
97
-
98
- // Special: Traefik needs letsencrypt subdirectory
99
- if (enabledApps.has("traefik")) {
100
- await mkdir(join(configRoot, "traefik", "letsencrypt"), { recursive: true })
101
- }
102
-
103
- // 5. Create log directories if logMount is enabled
104
- if (config.logMount) {
105
- const logsRoot = join(config.rootDir, "logs")
106
- await mkdir(logsRoot, { recursive: true })
107
-
108
- // Create log directory for each enabled app that has logVolume defined
109
- for (const appConfig of config.apps) {
110
- if (appConfig.enabled) {
111
- const appDef = getApp(appConfig.id)
112
- if (appDef?.logVolume) {
113
- await mkdir(join(logsRoot, appConfig.id), { recursive: true })
114
- }
115
- }
116
- }
117
- }
118
- } catch (error: unknown) {
119
- const err = error as { code?: string; message: string }
120
- if (err.code === "EACCES") {
121
- console.error(`\n⚠ Warning: Permission denied when creating directories at ${config.rootDir}`)
122
- console.error(" Please create the folders manually or check directory permissions.")
123
- console.error(` Error: ${err.message}\n`)
124
- // Do not throw, allow setup to continue
125
- } else {
126
- throw error
127
- }
128
- }
129
- }
package/src/ui/App.ts DELETED
@@ -1,111 +0,0 @@
1
- /**
2
- * Main Application Controller
3
- * Manages navigation between screens
4
- */
5
-
6
- import type { CliRenderer, KeyEvent } from "@opentui/core"
7
-
8
- import { BoxRenderable } from "@opentui/core"
9
-
10
- import type { EasiarrConfig } from "~/config/schema"
11
- import { configExists, loadConfig, saveConfig } from "~/config"
12
- import { UpdateNotification } from "~/ui/components/UpdateNotification"
13
- import { AdvancedSettings } from "~/ui/screens/AdvancedSettings"
14
- import { AppManager } from "~/ui/screens/AppManager"
15
- import { ContainerControl } from "~/ui/screens/ContainerControl"
16
- import { MainMenu } from "~/ui/screens/MainMenu"
17
- import { QuickSetup } from "~/ui/screens/setup/QuickSetup"
18
- import { checkForUpdates } from "~/utils/update-checker"
19
-
20
- export type Screen = "main" | "quickSetup" | "appManager" | "containerControl" | "advancedSettings"
21
-
22
- export class App {
23
- private renderer: CliRenderer
24
- private config: EasiarrConfig | null = null
25
- private currentScreen: Screen = "main"
26
- private screenContainer: BoxRenderable
27
-
28
- constructor(renderer: CliRenderer) {
29
- this.renderer = renderer
30
- this.screenContainer = new BoxRenderable(renderer, {
31
- id: "screen-container",
32
- width: "100%",
33
- height: "100%",
34
- })
35
- renderer.root.add(this.screenContainer)
36
- }
37
-
38
- async start(): Promise<void> {
39
- // Load existing config or show quick setup
40
- if (await configExists()) {
41
- this.config = await loadConfig()
42
- }
43
-
44
- if (!this.config) {
45
- this.navigateTo("quickSetup")
46
- } else {
47
- // Check for updates before showing main menu
48
- const updateInfo = await checkForUpdates()
49
-
50
- if (updateInfo.updateAvailable) {
51
- // Show update notification
52
- const notification = new UpdateNotification(this.renderer, updateInfo, () => {
53
- // After dismissing, show main menu
54
- this.navigateTo("main")
55
- })
56
- this.renderer.root.add(notification)
57
- } else {
58
- this.navigateTo("main")
59
- }
60
- }
61
-
62
- // Handle exit
63
- this.renderer.keyInput.on("keypress", (key: KeyEvent) => {
64
- if (key.ctrl && key.name === "c") {
65
- process.exit(0)
66
- }
67
- })
68
- }
69
-
70
- navigateTo(screen: Screen): void {
71
- this.currentScreen = screen
72
-
73
- // Clear all children from container
74
- const children = this.screenContainer.getChildren()
75
- for (const child of children) {
76
- this.screenContainer.remove(child.id)
77
- }
78
-
79
- switch (screen) {
80
- case "main":
81
- new MainMenu(this.renderer, this.screenContainer, this, this.config!)
82
- break
83
- case "quickSetup":
84
- new QuickSetup(this.renderer, this.screenContainer, this)
85
- break
86
- case "appManager":
87
- new AppManager(this.renderer, this.screenContainer, this, this.config!)
88
- break
89
- case "containerControl":
90
- new ContainerControl(this.renderer, this.screenContainer, this, this.config!)
91
- break
92
- case "advancedSettings":
93
- new AdvancedSettings(this.renderer, this.screenContainer, this, this.config!)
94
- break
95
- }
96
- }
97
-
98
- async saveAndReload(config: EasiarrConfig): Promise<void> {
99
- await saveConfig(config)
100
- this.config = config
101
- this.navigateTo("main")
102
- }
103
-
104
- setConfig(config: EasiarrConfig): void {
105
- this.config = config
106
- }
107
-
108
- getConfig(): EasiarrConfig | null {
109
- return this.config
110
- }
111
- }
@@ -1,275 +0,0 @@
1
- import {
2
- BoxOptions,
3
- BoxRenderable,
4
- CliRenderer,
5
- KeyEvent,
6
- RenderContext,
7
- SelectRenderable,
8
- SelectRenderableEvents,
9
- TabSelectRenderable,
10
- TextRenderable,
11
- } from "@opentui/core"
12
-
13
- import { getAppsByCategory, getArchWarning } from "~/apps"
14
- import { CATEGORY_ORDER } from "~/apps/categories"
15
- import { AppId } from "~/config/schema"
16
-
17
- export interface ApplicationSelectorOptions extends BoxOptions {
18
- selectedApps: Set<AppId>
19
- onToggle?: (appId: AppId, enabled: boolean) => void
20
- }
21
-
22
- export class ApplicationSelector extends BoxRenderable {
23
- private selectedApps: Set<AppId>
24
- private onToggle?: (appId: AppId, enabled: boolean) => void
25
- private currentCategoryIndex: number = 0
26
- private tabs: TabSelectRenderable
27
- private appList: SelectRenderable
28
- private warningBox: BoxRenderable
29
- private _renderer: CliRenderer | RenderContext
30
-
31
- // Track internal focus state
32
- private activeComponent: "tabs" | "list" = "tabs"
33
-
34
- constructor(renderer: CliRenderer | RenderContext, options: ApplicationSelectorOptions) {
35
- super(renderer, {
36
- ...options,
37
- flexDirection: "column",
38
- })
39
-
40
- this._renderer = renderer
41
- this.selectedApps = options.selectedApps
42
- this.onToggle = options.onToggle
43
-
44
- // 1. Conflict Warnings Area (Dynamic)
45
- this.warningBox = new BoxRenderable(renderer, {
46
- width: "100%",
47
- flexDirection: "column",
48
- marginBottom: 1,
49
- })
50
- this.add(this.warningBox)
51
- this.updateWarnings()
52
-
53
- // 2. Category Tabs
54
- const tabOptions = this.getTabOptions()
55
- this.tabs = new TabSelectRenderable(renderer, {
56
- width: "100%" /** Explicit width needed for tabs usually */,
57
- options: tabOptions,
58
- tabWidth: 12,
59
- showUnderline: false,
60
- showDescription: false,
61
- selectedBackgroundColor: "#4a9eff",
62
- textColor: "#555555",
63
- })
64
-
65
- // Event listener replaced by manual key handling for instant switching
66
- /*
67
- this.tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index) => {
68
- if (this.currentCategoryIndex !== index) {
69
- this.currentCategoryIndex = index
70
- this.updateAppList()
71
- this.activeComponent = "tabs"
72
- this.tabs.focus()
73
- }
74
- })
75
- */
76
-
77
- this.add(this.tabs)
78
-
79
- // Spacer
80
- this.add(new TextRenderable(renderer, { content: " " }))
81
-
82
- // 3. App List
83
- this.appList = new SelectRenderable(renderer, {
84
- width: "100%",
85
- flexGrow: 1,
86
- backgroundColor: "#151525",
87
- focusedBackgroundColor: "#252545",
88
- selectedBackgroundColor: "#3a4a6e",
89
- showScrollIndicator: true,
90
- options: [], // populated via updateAppList
91
- })
92
-
93
- this.appList.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
94
- const category = CATEGORY_ORDER[this.currentCategoryIndex]
95
- const apps = getAppsByCategory()[category.id] || []
96
- const app = apps[index]
97
-
98
- if (app) {
99
- const isEnabled = !this.selectedApps.has(app.id)
100
- if (isEnabled) {
101
- this.selectedApps.add(app.id)
102
- } else {
103
- this.selectedApps.delete(app.id)
104
- }
105
-
106
- if (this.onToggle) {
107
- this.onToggle(app.id, isEnabled)
108
- }
109
-
110
- // Refresh list to show checkmark
111
- this.updateAppList()
112
-
113
- // Refresh tabs to show counts
114
- this.tabs.options = this.getTabOptions()
115
- // Refresh warnings
116
- this.updateWarnings()
117
- }
118
- })
119
-
120
- this.add(this.appList)
121
-
122
- // Initial population
123
- this.updateAppList()
124
- }
125
-
126
- private getTabOptions() {
127
- return CATEGORY_ORDER.map((cat) => {
128
- const categoryApps = getAppsByCategory()[cat.id] || []
129
- const selectedCount = categoryApps.filter((a) => this.selectedApps.has(a.id)).length
130
- const countStr = selectedCount > 0 ? `(${selectedCount})` : ""
131
- return {
132
- name: `${cat.short}${countStr}`,
133
- value: cat.id,
134
- description: "", // Required by TabSelectOption
135
- }
136
- })
137
- }
138
-
139
- private updateAppList() {
140
- const category = CATEGORY_ORDER[this.currentCategoryIndex]
141
- const apps = getAppsByCategory()[category.id] || []
142
-
143
- const options = apps.map((app) => {
144
- const archWarning = getArchWarning(app)
145
- const checkmark = this.selectedApps.has(app.id) ? "[✓]" : "[ ]"
146
- const warnIcon = archWarning ? " ⚠️" : ""
147
- return {
148
- name: `${checkmark} ${app.name}${warnIcon}`,
149
- description: archWarning
150
- ? `⚠️ ${archWarning}`
151
- : `Port ${app.defaultPort} - ${app.description}`,
152
- }
153
- })
154
-
155
- this.appList.options = options
156
- }
157
-
158
- private updateWarnings() {
159
- // Clear existing
160
- const children = this.warningBox.getChildren()
161
- children.forEach((c) => this.warningBox.remove(c.id))
162
-
163
- const warnings = this.getConflictWarnings()
164
-
165
- warnings.forEach((w, i) => {
166
- this.warningBox.add(
167
- new TextRenderable(this._renderer, {
168
- id: `warning-${i}`,
169
- content: `⚠ ${w}`,
170
- fg: "#ffaa00",
171
- })
172
- )
173
- })
174
- }
175
-
176
- private getConflictWarnings(): string[] {
177
- const warnings: string[] = []
178
- const check = (list: string[], msg: string) => {
179
- const found = list.filter((id) => this.selectedApps.has(id as AppId))
180
- if (found.length > 1) warnings.push(`${msg}: ${found.join(", ")}`)
181
- }
182
-
183
- check(["homarr", "heimdall", "homepage"], "Multiple dashboards")
184
- check(["plex", "jellyfin"], "Multiple media servers")
185
- check(["overseerr", "jellyseerr"], "Multiple request managers")
186
- check(["prowlarr", "jackett"], "Multiple indexers")
187
-
188
- // Architecture warnings for selected apps
189
- const allApps = Object.values(getAppsByCategory()).flat()
190
- for (const app of allApps) {
191
- if (this.selectedApps.has(app.id)) {
192
- const archWarn = getArchWarning(app)
193
- if (archWarn) {
194
- warnings.push(archWarn)
195
- }
196
- }
197
- }
198
-
199
- return warnings
200
- }
201
-
202
- // --- Focus Management ---
203
-
204
- focus() {
205
- if (this.activeComponent === "tabs") {
206
- // Don't focus tabs to avoid double key handling
207
- this.appList.blur()
208
- } else {
209
- this.appList.focus()
210
- }
211
- }
212
-
213
- handleKey(key: KeyEvent): boolean {
214
- // Return true if handled
215
- // Navigation between internal components
216
- if (this.activeComponent === "tabs") {
217
- if (key.name === "down") {
218
- this.activeComponent = "list"
219
- this.tabs.blur()
220
- this.appList.focus()
221
- return true
222
- }
223
- if (key.name === "left") {
224
- this.switchCategory(-1)
225
- return true
226
- }
227
- if (key.name === "right") {
228
- this.switchCategory(1)
229
- return true
230
- }
231
- } else {
232
- // List active
233
- if (key.name === "up" && this.appList.selectedIndex === 0) {
234
- this.activeComponent = "tabs"
235
- this.appList.blur()
236
- return true
237
- }
238
-
239
- // Allow switching category from list too?
240
- if (key.name === "left" || key.name === "[") {
241
- this.switchCategory(-1)
242
- return true
243
- }
244
- if (key.name === "right" || key.name === "]") {
245
- this.switchCategory(1)
246
- return true
247
- }
248
- }
249
-
250
- return false
251
- }
252
-
253
- private toggleFocus() {
254
- if (this.activeComponent === "tabs") {
255
- this.activeComponent = "list"
256
- this.appList.focus()
257
- } else {
258
- this.activeComponent = "tabs"
259
- this.appList.blur()
260
- }
261
- }
262
-
263
- private switchCategory(delta: number) {
264
- let newIndex = this.currentCategoryIndex + delta
265
- if (newIndex < 0) newIndex = 0
266
- if (newIndex >= CATEGORY_ORDER.length) newIndex = CATEGORY_ORDER.length - 1
267
-
268
- if (newIndex !== this.currentCategoryIndex) {
269
- this.currentCategoryIndex = newIndex
270
- // Sync Tabs
271
- this.tabs.setSelectedIndex(newIndex)
272
- this.updateAppList()
273
- }
274
- }
275
- }
@@ -1,180 +0,0 @@
1
- /**
2
- * CredentialsForm Component
3
- * Reusable form for username/password/email input with tab navigation
4
- */
5
-
6
- import type { CliRenderer, KeyEvent } from "@opentui/core"
7
-
8
- import { BoxRenderable, InputRenderable, TextRenderable } from "@opentui/core"
9
-
10
- export interface CredentialsFormOptions {
11
- title?: string
12
- initialUsername?: string
13
- initialPassword?: string
14
- initialEmail?: string
15
- showEmail?: boolean
16
- showOverride?: boolean
17
- initialOverride?: boolean
18
- }
19
-
20
- export interface CredentialsFormResult {
21
- username: string
22
- password: string
23
- email: string
24
- override: boolean
25
- }
26
-
27
- export class CredentialsForm extends BoxRenderable {
28
- private renderer: CliRenderer
29
- private keyHandler!: (key: KeyEvent) => void
30
- private userInput!: InputRenderable
31
- private passInput!: InputRenderable
32
- private emailInput?: InputRenderable
33
- private overrideText?: TextRenderable
34
- private focusedInput: InputRenderable | null = null
35
- private override: boolean
36
-
37
- private onSubmit: (result: CredentialsFormResult) => void
38
- private onCancel: () => void
39
-
40
- constructor(
41
- renderer: CliRenderer,
42
- options: CredentialsFormOptions,
43
- onSubmit: (result: CredentialsFormResult) => void,
44
- onCancel: () => void
45
- ) {
46
- super(renderer, {
47
- flexDirection: "column",
48
- gap: 1,
49
- })
50
-
51
- this.renderer = renderer
52
- this.onSubmit = onSubmit
53
- this.onCancel = onCancel
54
- this.override = options.initialOverride ?? false
55
-
56
- this.buildForm(options)
57
- this.setupKeyHandler(options)
58
- }
59
-
60
- private buildForm(options: CredentialsFormOptions) {
61
- if (options.title) {
62
- this.add(
63
- new TextRenderable(this.renderer, {
64
- content: `${options.title}\n`,
65
- fg: "#4a9eff",
66
- })
67
- )
68
- }
69
-
70
- // Username input
71
- this.add(new TextRenderable(this.renderer, { content: "Username:", fg: "#aaaaaa" }))
72
- this.userInput = new InputRenderable(this.renderer, {
73
- id: "credentials-user-input",
74
- width: 30,
75
- placeholder: "admin",
76
- value: options.initialUsername ?? "",
77
- focusedBackgroundColor: "#1a1a1a",
78
- })
79
- this.add(this.userInput)
80
- this.add(new BoxRenderable(this.renderer, { width: 1, height: 1 }))
81
-
82
- // Password input
83
- this.add(new TextRenderable(this.renderer, { content: "Password:", fg: "#aaaaaa" }))
84
- this.passInput = new InputRenderable(this.renderer, {
85
- id: "credentials-pass-input",
86
- width: 30,
87
- placeholder: "Enter password",
88
- value: options.initialPassword ?? "",
89
- focusedBackgroundColor: "#1a1a1a",
90
- })
91
- this.add(this.passInput)
92
- this.add(new BoxRenderable(this.renderer, { width: 1, height: 1 }))
93
-
94
- // Email input (optional)
95
- if (options.showEmail !== false) {
96
- this.add(new TextRenderable(this.renderer, { content: "Email (optional):", fg: "#aaaaaa" }))
97
- this.emailInput = new InputRenderable(this.renderer, {
98
- id: "credentials-email-input",
99
- width: 40,
100
- placeholder: "you@example.com",
101
- value: options.initialEmail ?? "",
102
- focusedBackgroundColor: "#1a1a1a",
103
- })
104
- this.add(this.emailInput)
105
- this.add(new BoxRenderable(this.renderer, { width: 1, height: 1 }))
106
- }
107
-
108
- // Override toggle (optional)
109
- if (options.showOverride) {
110
- this.overrideText = new TextRenderable(this.renderer, {
111
- id: "override-toggle",
112
- content: `[O] Override existing: ${this.override ? "Yes" : "No"}`,
113
- fg: this.override ? "#50fa7b" : "#6272a4",
114
- })
115
- this.add(this.overrideText)
116
- }
117
-
118
- // Focus first input
119
- this.userInput.focus()
120
- this.focusedInput = this.userInput
121
- }
122
-
123
- private setupKeyHandler(options: CredentialsFormOptions) {
124
- this.keyHandler = (key: KeyEvent) => {
125
- const inputIsFocused = this.focusedInput !== null
126
-
127
- if (key.name === "o" && !inputIsFocused && options.showOverride) {
128
- this.override = !this.override
129
- if (this.overrideText) {
130
- this.overrideText.content = `[O] Override existing: ${this.override ? "Yes" : "No"}`
131
- this.overrideText.fg = this.override ? "#50fa7b" : "#6272a4"
132
- }
133
- } else if (key.name === "tab") {
134
- this.cycleInputFocus()
135
- } else if (key.name === "escape") {
136
- this.cleanup()
137
- this.onCancel()
138
- } else if (key.name === "return") {
139
- this.cleanup()
140
- this.onSubmit({
141
- username: this.userInput.value || "admin",
142
- password: this.passInput.value,
143
- email: this.emailInput?.value ?? "",
144
- override: this.override,
145
- })
146
- }
147
- }
148
- this.renderer.keyInput.on("keypress", this.keyHandler)
149
- }
150
-
151
- private cycleInputFocus() {
152
- if (this.focusedInput === this.userInput) {
153
- this.userInput.blur()
154
- this.passInput.focus()
155
- this.focusedInput = this.passInput
156
- } else if (this.focusedInput === this.passInput) {
157
- this.passInput.blur()
158
- if (this.emailInput) {
159
- this.emailInput.focus()
160
- this.focusedInput = this.emailInput
161
- } else {
162
- this.focusedInput = null
163
- }
164
- } else if (this.focusedInput === this.emailInput) {
165
- this.emailInput?.blur()
166
- this.focusedInput = null
167
- } else {
168
- this.userInput.focus()
169
- this.focusedInput = this.userInput
170
- }
171
- }
172
-
173
- cleanup() {
174
- this.renderer.keyInput.off("keypress", this.keyHandler)
175
- this.userInput.blur()
176
- this.passInput.blur()
177
- this.emailInput?.blur()
178
- this.focusedInput = null
179
- }
180
- }