@muhammedaksam/easiarr 0.1.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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * App Manager Screen
3
+ * Add, remove, or configure apps
4
+ */
5
+
6
+ import type { RenderContext, CliRenderer, KeyEvent } from "@opentui/core"
7
+ import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents, RGBA } from "@opentui/core"
8
+ import type { App } from "../App"
9
+ import type { AppId, EasiarrConfig } from "../../config/schema"
10
+ import { saveConfig } from "../../config"
11
+ import { saveCompose } from "../../compose"
12
+ import { createPageLayout } from "../components/PageLayout"
13
+ import { ensureDirectoryStructure } from "../../structure/manager"
14
+ import { ApplicationSelector } from "../components/ApplicationSelector"
15
+ import { getApp } from "../../apps/registry"
16
+ import { SecretsEditor } from "./SecretsEditor"
17
+
18
+ export class AppManager {
19
+ private renderer: RenderContext
20
+ private container: BoxRenderable
21
+ private app: App
22
+ private config: EasiarrConfig
23
+ private selector: ApplicationSelector | null = null
24
+ private navMenu: SelectRenderable | null = null
25
+ private keyHandler: ((key: KeyEvent) => void) | null = null
26
+ private activeZone: "selector" | "nav" = "selector"
27
+ private previouslyEnabledApps: Set<AppId>
28
+ private page: BoxRenderable | null = null
29
+
30
+ constructor(renderer: RenderContext, container: BoxRenderable, app: App, config: EasiarrConfig) {
31
+ this.renderer = renderer
32
+ this.container = container
33
+ this.app = app
34
+ this.config = config
35
+ // Track which apps were enabled before user starts editing
36
+ this.previouslyEnabledApps = new Set(config.apps.filter((a) => a.enabled).map((a) => a.id))
37
+
38
+ this.render()
39
+ }
40
+
41
+ private render(): void {
42
+ // Clean up previous listeners
43
+ if (this.keyHandler && (this.renderer as CliRenderer).keyInput) {
44
+ ;(this.renderer as CliRenderer).keyInput.off("keypress", this.keyHandler)
45
+ this.keyHandler = null
46
+ }
47
+
48
+ // Clear container
49
+ const children = this.container.getChildren()
50
+ for (const child of children) {
51
+ this.container.remove(child.id)
52
+ }
53
+
54
+ const { container: page, content } = createPageLayout(this.renderer as CliRenderer, {
55
+ title: "Manage Apps",
56
+ stepInfo: "Toggle apps linked to your configuration",
57
+ footerHint: "←→ Tab Enter Toggle s Save q Back",
58
+ })
59
+ this.page = page
60
+
61
+ // Selected Apps Set for the selector
62
+ // Selected Apps Set for the selector
63
+ // We create a temporary Set to track changes, then commit to config on "Save".
64
+ // Changes are modified in memory immediately to match QuickSetup behavior.
65
+
66
+ const enabledApps = new Set(this.config.apps.filter((a) => a.enabled).map((a) => a.id))
67
+
68
+ this.selector = new ApplicationSelector(this.renderer as CliRenderer, {
69
+ selectedApps: enabledApps,
70
+ width: "100%",
71
+ flexGrow: 1, // list takes available space
72
+ onToggle: (appId, enabled) => {
73
+ this.toggleApp(appId, enabled)
74
+ },
75
+ })
76
+ content.add(this.selector)
77
+
78
+ // Separator
79
+ content.add(new TextRenderable(this.renderer, { content: " " }))
80
+
81
+ // Nav Menu (Save / Back)
82
+ this.navMenu = new SelectRenderable(this.renderer, {
83
+ id: "app-manager-nav",
84
+ width: "100%",
85
+ height: 4,
86
+ options: [
87
+ { name: "💾 Save & Apply", description: "Write config and regenerate docker-compose.yml" },
88
+ { name: "❌ Discard / Back", description: "Return to Main Menu" },
89
+ ],
90
+ })
91
+
92
+ this.navMenu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
93
+ if (index === 0) {
94
+ await this.save()
95
+ } else {
96
+ this.app.navigateTo("main")
97
+ }
98
+ })
99
+
100
+ content.add(this.navMenu)
101
+
102
+ this.container.add(page)
103
+
104
+ // Initial Focus
105
+ this.selector.focus()
106
+ this.activeZone = "selector"
107
+
108
+ // Key Handler
109
+ this.keyHandler = (key: KeyEvent) => {
110
+ if (key.name === "q") {
111
+ this.app.navigateTo("main")
112
+ return
113
+ }
114
+
115
+ if (key.name === "s" && key.ctrl) {
116
+ // Ctrl+S to save
117
+ this.save()
118
+ return
119
+ }
120
+
121
+ if (key.name === "tab") {
122
+ // Switch zones
123
+ if (this.activeZone === "selector") {
124
+ this.activeZone = "nav"
125
+ this.selector?.blur()
126
+ this.navMenu?.focus()
127
+ } else {
128
+ this.activeZone = "selector"
129
+ this.navMenu?.blur()
130
+ this.selector?.focus()
131
+ }
132
+ return
133
+ }
134
+
135
+ // Delegate to selector if active
136
+ if (this.activeZone === "selector" && this.selector) {
137
+ const handled = this.selector.handleKey(key)
138
+ if (handled) return
139
+ }
140
+ }
141
+ ;(this.renderer as CliRenderer).keyInput.on("keypress", this.keyHandler)
142
+ }
143
+
144
+ private toggleApp(id: AppId, enabled: boolean): void {
145
+ const existingIndex = this.config.apps.findIndex((a) => a.id === id)
146
+ if (existingIndex >= 0) {
147
+ this.config.apps[existingIndex].enabled = enabled
148
+ } else {
149
+ if (enabled) {
150
+ this.config.apps.push({ id, enabled: true })
151
+ }
152
+ }
153
+
154
+ // Auto-enable Traefik config when traefik app is enabled
155
+ if (id === "traefik") {
156
+ if (enabled && !this.config.traefik?.enabled) {
157
+ this.config.traefik = {
158
+ enabled: true,
159
+ domain: "${CLOUDFLARE_DNS_ZONE}",
160
+ entrypoint: "websecure",
161
+ middlewares: [],
162
+ }
163
+ } else if (!enabled && this.config.traefik?.enabled) {
164
+ this.config.traefik.enabled = false
165
+ }
166
+ }
167
+
168
+ // Auto-enable VPN config when gluetun app is enabled
169
+ if (id === "gluetun") {
170
+ if (enabled && !this.config.vpn) {
171
+ this.config.vpn = { mode: "mini" }
172
+ } else if (!enabled && this.config.vpn) {
173
+ this.config.vpn.mode = "none"
174
+ }
175
+ }
176
+ }
177
+
178
+ private async save() {
179
+ await saveConfig(this.config)
180
+ await ensureDirectoryStructure(this.config)
181
+ await saveCompose(this.config)
182
+
183
+ // Check for newly-enabled apps that have secrets
184
+ const currentlyEnabled = new Set(this.config.apps.filter((a) => a.enabled).map((a) => a.id))
185
+ const newlyEnabled = [...currentlyEnabled].filter((id) => !this.previouslyEnabledApps.has(id))
186
+ const appsWithSecrets = newlyEnabled.filter((id) => {
187
+ const appDef = getApp(id)
188
+ return appDef?.secrets && appDef.secrets.length > 0
189
+ })
190
+
191
+ if (appsWithSecrets.length > 0) {
192
+ this.showSecretsPrompt(appsWithSecrets)
193
+ } else {
194
+ this.app.navigateTo("main")
195
+ }
196
+ }
197
+
198
+ private showSecretsPrompt(appsWithSecrets: AppId[]) {
199
+ // Hide the main page
200
+ if (this.page) this.page.visible = false
201
+ if (this.keyHandler) {
202
+ ;(this.renderer as CliRenderer).keyInput.off("keypress", this.keyHandler)
203
+ this.keyHandler = null
204
+ }
205
+
206
+ // Create a prompt overlay
207
+ const overlay = new BoxRenderable(this.renderer, {
208
+ id: "secrets-prompt-overlay",
209
+ width: "100%",
210
+ height: "100%",
211
+ backgroundColor: RGBA.fromHex("#111111"),
212
+ zIndex: 200,
213
+ flexDirection: "column",
214
+ padding: 2,
215
+ })
216
+
217
+ const appNames = appsWithSecrets.map((id) => getApp(id)?.name || id).join(", ")
218
+ overlay.add(
219
+ new TextRenderable(this.renderer, {
220
+ content: "🔑 New Apps Require Configuration",
221
+ fg: "#f1fa8c",
222
+ marginBottom: 1,
223
+ })
224
+ )
225
+ overlay.add(
226
+ new TextRenderable(this.renderer, {
227
+ content: `The following apps need secrets configured: ${appNames}`,
228
+ fg: "#aaaaaa",
229
+ marginBottom: 2,
230
+ })
231
+ )
232
+
233
+ const menu = new SelectRenderable(this.renderer, {
234
+ id: "secrets-prompt-menu",
235
+ width: "100%",
236
+ height: 4,
237
+ options: [
238
+ { name: "✓ Configure Secrets Now", description: "Open the Secrets Editor" },
239
+ { name: "✗ Skip for Now", description: "Return to Main Menu" },
240
+ ],
241
+ })
242
+
243
+ menu.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
244
+ this.container.remove("secrets-prompt-overlay")
245
+ if (index === 0) {
246
+ // Show SecretsEditor
247
+ const editor = new SecretsEditor(this.renderer as CliRenderer, {
248
+ id: "secrets-editor-overlay",
249
+ width: "100%",
250
+ height: "100%",
251
+ config: this.config,
252
+ onSave: () => {
253
+ this.container.remove("secrets-editor-overlay")
254
+ this.app.navigateTo("main")
255
+ },
256
+ onCancel: () => {
257
+ this.container.remove("secrets-editor-overlay")
258
+ this.app.navigateTo("main")
259
+ },
260
+ })
261
+ this.container.add(editor)
262
+ } else {
263
+ this.app.navigateTo("main")
264
+ }
265
+ })
266
+
267
+ overlay.add(menu)
268
+ this.container.add(overlay)
269
+ menu.focus()
270
+ }
271
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Container Control Screen
3
+ * Start, stop, restart containers
4
+ */
5
+
6
+ import type { RenderContext, CliRenderer } from "@opentui/core"
7
+ import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents } from "@opentui/core"
8
+ import type { App } from "../App"
9
+ import type { EasiarrConfig } from "../../config/schema"
10
+ import {
11
+ composeUp,
12
+ composeDown,
13
+ composeStop,
14
+ composeRestart,
15
+ getContainerStatuses,
16
+ isDockerAvailable,
17
+ } from "../../docker"
18
+ import { createPageLayout } from "../components/PageLayout"
19
+
20
+ export class ContainerControl {
21
+ private renderer: RenderContext
22
+ private container: BoxRenderable
23
+ private app: App
24
+ private config: EasiarrConfig
25
+
26
+ constructor(renderer: RenderContext, container: BoxRenderable, app: App, config: EasiarrConfig) {
27
+ this.renderer = renderer
28
+ this.container = container
29
+ this.app = app
30
+ this.config = config
31
+
32
+ this.render()
33
+ }
34
+
35
+ private async render(): Promise<void> {
36
+ const statuses = await getContainerStatuses()
37
+ const runningCount = statuses.filter((s) => s.status === "running").length
38
+ const totalCount = statuses.length
39
+
40
+ // Status in header info?
41
+ const statusText = `Status: ${runningCount}/${totalCount} Running`
42
+
43
+ // Check Docker availability first to possibly change status
44
+ const dockerOk = await isDockerAvailable()
45
+ const finalStepInfo = dockerOk ? statusText : "Internal Error: Docker Unavailable"
46
+
47
+ const { container: page, content } = createPageLayout(this.renderer as CliRenderer, {
48
+ title: "Container Control",
49
+ stepInfo: finalStepInfo,
50
+ footerHint: "Enter Select/Action q Back",
51
+ })
52
+
53
+ if (!dockerOk) {
54
+ content.add(
55
+ new TextRenderable(this.renderer, {
56
+ id: "docker-error",
57
+ content: "⚠ Docker is not available! Please check your Docker daemon.",
58
+ fg: "#ff6666",
59
+ })
60
+ )
61
+ }
62
+
63
+ // Spacer
64
+ content.add(
65
+ new TextRenderable(this.renderer, {
66
+ id: "spacer",
67
+ content: "",
68
+ })
69
+ )
70
+
71
+ // Show container list
72
+ if (statuses.length > 0) {
73
+ for (const status of statuses.slice(0, 12)) {
74
+ // Show a few more since we have space
75
+ const icon = status.status === "running" ? "🟢" : "🔴"
76
+ content.add(
77
+ new TextRenderable(this.renderer, {
78
+ id: `status-${status.name}`,
79
+ content: `${icon} ${status.name}`,
80
+ fg: status.status === "running" ? "#00cc66" : "#666666",
81
+ })
82
+ )
83
+ }
84
+ } else if (dockerOk) {
85
+ content.add(
86
+ new TextRenderable(this.renderer, {
87
+ id: "no-containers",
88
+ content: "No containers found. Run 'Start All' first.",
89
+ fg: "#888888",
90
+ })
91
+ )
92
+ }
93
+
94
+ const menu = new SelectRenderable(this.renderer, {
95
+ id: "container-menu",
96
+ width: "100%",
97
+ height: 8, // Fixed height for actions at bottom
98
+ options: [
99
+ { name: "▶ Start All", description: "docker compose up -d" },
100
+ { name: "⏹ Stop All", description: "docker compose stop" },
101
+ { name: "🔄 Restart All", description: "docker compose restart" },
102
+ { name: "⬇ Down (Remove)", description: "docker compose down" },
103
+ { name: "🔃 Refresh Status", description: "Update container list" },
104
+ { name: "◀ Back to Main Menu", description: "" },
105
+ ],
106
+ })
107
+
108
+ menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
109
+ switch (index) {
110
+ case 0:
111
+ await composeUp()
112
+ break
113
+ case 1:
114
+ await composeStop()
115
+ break
116
+ case 2:
117
+ await composeRestart()
118
+ break
119
+ case 3:
120
+ await composeDown()
121
+ break
122
+ case 4:
123
+ // Refresh
124
+ break
125
+ case 5:
126
+ this.app.navigateTo("main")
127
+ return
128
+ }
129
+ // Refresh view - clear all children
130
+ const children = this.container.getChildren()
131
+ for (const child of children) {
132
+ this.container.remove(child.id)
133
+ }
134
+ this.render()
135
+ })
136
+
137
+ content.add(menu)
138
+ menu.focus()
139
+
140
+ this.container.add(page)
141
+ }
142
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Main Menu Screen
3
+ * Central navigation hub for Easiarr
4
+ */
5
+
6
+ import type { RenderContext, CliRenderer } from "@opentui/core"
7
+ import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents } from "@opentui/core"
8
+ import type { App } from "../App"
9
+ import type { EasiarrConfig } from "../../config/schema"
10
+ import { createPageLayout } from "../components/PageLayout"
11
+ import { saveCompose } from "../../compose"
12
+ import { ApiKeyViewer } from "./ApiKeyViewer"
13
+ import { AppConfigurator } from "./AppConfigurator"
14
+
15
+ export class MainMenu {
16
+ private renderer: RenderContext
17
+ private container: BoxRenderable
18
+ private app: App
19
+ private config: EasiarrConfig
20
+ private menu!: SelectRenderable
21
+ private page!: BoxRenderable
22
+
23
+ constructor(renderer: RenderContext, container: BoxRenderable, app: App, config: EasiarrConfig) {
24
+ this.renderer = renderer
25
+ this.container = container
26
+ this.app = app
27
+ this.config = config
28
+
29
+ this.render()
30
+ }
31
+
32
+ private render(): void {
33
+ const { container: page, content } = createPageLayout(this.renderer as CliRenderer, {
34
+ title: "Main Menu",
35
+ stepInfo: "Docker Compose Generator for *arr Ecosystem",
36
+ footerHint: "Enter Select Ctrl+C Exit",
37
+ })
38
+ this.page = page
39
+
40
+ // Config info
41
+ const configBox = new BoxRenderable(this.renderer, {
42
+ width: "100%",
43
+ flexDirection: "column",
44
+ marginBottom: 1,
45
+ })
46
+
47
+ configBox.add(
48
+ new TextRenderable(this.renderer, {
49
+ id: "config-info-header",
50
+ content: "Configuration Overview:",
51
+ fg: "#4a9eff",
52
+ })
53
+ )
54
+
55
+ configBox.add(new BoxRenderable(this.renderer, { width: 1, height: 1 })) // Spacer
56
+
57
+ configBox.add(
58
+ new TextRenderable(this.renderer, {
59
+ id: "config-info",
60
+ content: ` 📁 Root: ${this.config.rootDir}`,
61
+ fg: "#aaaaaa",
62
+ })
63
+ )
64
+
65
+ configBox.add(
66
+ new TextRenderable(this.renderer, {
67
+ id: "apps-info",
68
+ content: ` Apps: ${this.config.apps.filter((a) => a.enabled).length} configured`,
69
+ fg: "#aaaaaa",
70
+ })
71
+ )
72
+
73
+ content.add(configBox)
74
+
75
+ content.add(new TextRenderable(this.renderer, { id: "spacer2", content: " " }))
76
+
77
+ // Menu
78
+ this.menu = new SelectRenderable(this.renderer, {
79
+ id: "main-menu-select",
80
+ width: "100%",
81
+ height: 10,
82
+ options: [
83
+ {
84
+ name: "📦 Manage Apps",
85
+ description: "Add, remove, or configure apps",
86
+ },
87
+ {
88
+ name: "🐳 Container Control",
89
+ description: "Start, stop, restart containers",
90
+ },
91
+ {
92
+ name: "⚙️ Advanced Settings",
93
+ description: "Customize ports, volumes, env",
94
+ },
95
+ {
96
+ name: "🔑 Extract API Keys",
97
+ description: "Find API keys from running containers",
98
+ },
99
+ {
100
+ name: "⚙️ Configure Apps",
101
+ description: "Set root folders and download clients via API",
102
+ },
103
+ {
104
+ name: "🔄 Regenerate Compose",
105
+ description: "Rebuild docker-compose.yml",
106
+ },
107
+ { name: "❌ Exit", description: "Close easiarr" },
108
+ ],
109
+ })
110
+
111
+ this.menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
112
+ switch (index) {
113
+ case 0:
114
+ this.app.navigateTo("appManager")
115
+ break
116
+ case 1:
117
+ this.app.navigateTo("containerControl")
118
+ break
119
+ case 2:
120
+ this.app.navigateTo("advancedSettings")
121
+ break
122
+ case 3: {
123
+ // API Key Extractor
124
+ this.menu.blur()
125
+ this.page.visible = false
126
+ const viewer = new ApiKeyViewer(this.renderer as CliRenderer, this.config, () => {
127
+ // On Back
128
+ this.page.visible = true
129
+ this.menu.focus()
130
+ })
131
+ this.container.add(viewer)
132
+ break
133
+ }
134
+ case 4: {
135
+ // Configure Apps
136
+ this.menu.blur()
137
+ this.page.visible = false
138
+ const configurator = new AppConfigurator(this.renderer as CliRenderer, this.config, () => {
139
+ this.page.visible = true
140
+ this.menu.focus()
141
+ })
142
+ this.container.add(configurator)
143
+ break
144
+ }
145
+ case 5: {
146
+ // Regenerate compose
147
+ await saveCompose(this.config)
148
+ break
149
+ }
150
+ case 6:
151
+ process.exit(0)
152
+ break
153
+ }
154
+ })
155
+
156
+ content.add(this.menu)
157
+ this.menu.focus()
158
+
159
+ this.container.add(page)
160
+ }
161
+ }