@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.
- package/dist/index.js +29750 -0
- package/package.json +15 -15
- package/src/VersionInfo.ts +0 -12
- package/src/api/arr-api.ts +0 -521
- package/src/api/auto-setup-types.ts +0 -64
- package/src/api/base-api.ts +0 -216
- package/src/api/bazarr-api.ts +0 -297
- package/src/api/cloudflare-api.ts +0 -493
- package/src/api/custom-format-api.ts +0 -224
- package/src/api/grafana-api.ts +0 -230
- package/src/api/heimdall-api.ts +0 -141
- package/src/api/homarr-api.ts +0 -209
- package/src/api/huntarr-api.ts +0 -457
- package/src/api/index.ts +0 -9
- package/src/api/jellyfin-api.ts +0 -389
- package/src/api/jellyseerr-api.ts +0 -559
- package/src/api/maintainerr-api.ts +0 -461
- package/src/api/naming-config.ts +0 -101
- package/src/api/overseerr-api.ts +0 -392
- package/src/api/plex-api.ts +0 -308
- package/src/api/portainer-api.ts +0 -374
- package/src/api/profilarr-api.ts +0 -391
- package/src/api/prowlarr-api.ts +0 -488
- package/src/api/qbittorrent-api.ts +0 -300
- package/src/api/quality-profile-api.ts +0 -272
- package/src/api/tautulli-api.ts +0 -244
- package/src/api/uptime-kuma-api.ts +0 -343
- package/src/apps/categories.ts +0 -14
- package/src/apps/index.ts +0 -2
- package/src/apps/registry.ts +0 -1275
- package/src/compose/caddy-config.ts +0 -130
- package/src/compose/generator.ts +0 -287
- package/src/compose/index.ts +0 -7
- package/src/compose/templates.ts +0 -78
- package/src/compose/traefik-config.ts +0 -187
- package/src/config/bookmarks-generator.ts +0 -160
- package/src/config/defaults.ts +0 -37
- package/src/config/homepage-config.ts +0 -381
- package/src/config/index.ts +0 -6
- package/src/config/manager.ts +0 -150
- package/src/config/recyclarr-config.ts +0 -181
- package/src/config/schema.ts +0 -310
- package/src/config/slskd-config.ts +0 -86
- package/src/config/soularr-config.ts +0 -105
- package/src/config/trash-quality-definitions.ts +0 -213
- package/src/data/lidarr-custom-formats.ts +0 -127
- package/src/data/trash-profiles.ts +0 -333
- package/src/docker/client.ts +0 -312
- package/src/docker/index.ts +0 -18
- package/src/index.ts +0 -77
- package/src/setup/actions/arr-common.ts +0 -307
- package/src/setup/actions/dashboards.ts +0 -79
- package/src/setup/actions/download-clients.ts +0 -54
- package/src/setup/actions/jellyseerr.ts +0 -331
- package/src/setup/actions/media-servers.ts +0 -68
- package/src/setup/actions/monitoring.ts +0 -120
- package/src/setup/actions/prowlarr.ts +0 -137
- package/src/setup/actions/utilities.ts +0 -490
- package/src/setup/index.ts +0 -78
- package/src/setup/types.ts +0 -144
- package/src/structure/manager.ts +0 -129
- package/src/ui/App.ts +0 -111
- package/src/ui/components/ApplicationSelector.ts +0 -275
- package/src/ui/components/CredentialsForm.ts +0 -180
- package/src/ui/components/DownloadClientForm.ts +0 -114
- package/src/ui/components/FileEditor.ts +0 -91
- package/src/ui/components/FooterHint.ts +0 -119
- package/src/ui/components/PageLayout.ts +0 -160
- package/src/ui/components/UpdateNotification.ts +0 -102
- package/src/ui/index.ts +0 -7
- package/src/ui/screens/AdvancedSettings.ts +0 -188
- package/src/ui/screens/ApiKeyViewer.ts +0 -511
- package/src/ui/screens/AppConfigurator.ts +0 -543
- package/src/ui/screens/AppManager.ts +0 -284
- package/src/ui/screens/ContainerControl.ts +0 -581
- package/src/ui/screens/LogsViewer.ts +0 -478
- package/src/ui/screens/MainMenu.ts +0 -299
- package/src/ui/screens/MonitorDashboard.ts +0 -881
- package/src/ui/screens/SecretsEditor.ts +0 -216
- package/src/ui/screens/SettingsScreen.ts +0 -633
- package/src/ui/screens/index.ts +0 -22
- package/src/ui/screens/setup/BaseAppSetupScreen.ts +0 -375
- package/src/ui/screens/setup/CloudflaredSetup.ts +0 -460
- package/src/ui/screens/setup/FullAutoSetup.ts +0 -766
- package/src/ui/screens/setup/HomepageSetup.ts +0 -165
- package/src/ui/screens/setup/JellyfinSetup.ts +0 -328
- package/src/ui/screens/setup/JellyseerrSetup.ts +0 -242
- package/src/ui/screens/setup/ProwlarrSetup.ts +0 -500
- package/src/ui/screens/setup/QBittorrentSetup.ts +0 -221
- package/src/ui/screens/setup/QuickSetup.ts +0 -1169
- package/src/ui/screens/setup/RecyclarrSetup.ts +0 -270
- package/src/ui/screens/setup/TRaSHProfileSetup.ts +0 -381
- package/src/utils/arch.ts +0 -78
- package/src/utils/browser.ts +0 -27
- package/src/utils/categories.ts +0 -61
- package/src/utils/debug.ts +0 -84
- package/src/utils/env.ts +0 -138
- package/src/utils/index.ts +0 -8
- package/src/utils/logs.ts +0 -119
- package/src/utils/migrations/1765626338_rename_env_variables.ts +0 -102
- package/src/utils/migrations/1765707135_rename_easiarr_status.ts +0 -91
- package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +0 -45
- package/src/utils/migrations/1769515650_migrate_to_xdg.ts +0 -114
- package/src/utils/migrations.ts +0 -165
- package/src/utils/password.ts +0 -42
- package/src/utils/paths.ts +0 -33
- package/src/utils/unraid.ts +0 -103
- package/src/utils/update-checker.ts +0 -210
- package/src/utils/url-utils.ts +0 -38
package/src/structure/manager.ts
DELETED
|
@@ -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
|
-
}
|