@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,104 @@
1
+ import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core"
2
+ import { getVersion } from "../../VersionInfo"
3
+
4
+ export interface PageLayoutOptions {
5
+ title: string
6
+ stepInfo?: string
7
+ footerHint?: string
8
+ }
9
+
10
+ export interface PageLayoutResult {
11
+ container: BoxRenderable
12
+ content: BoxRenderable
13
+ }
14
+
15
+ export function createPageLayout(renderer: CliRenderer, options: PageLayoutOptions): PageLayoutResult {
16
+ const { title, stepInfo, footerHint } = options
17
+ const idPrefix = title
18
+ .replace(/[^a-zA-Z]/g, "")
19
+ .slice(0, 10)
20
+ .toLowerCase()
21
+
22
+ // Create main container
23
+ const container = new BoxRenderable(renderer, {
24
+ id: `${idPrefix}-page-layout`,
25
+ width: "100%",
26
+ height: "100%",
27
+ flexDirection: "column",
28
+ backgroundColor: "#1a1a2e",
29
+ })
30
+
31
+ // Header
32
+ const headerText = stepInfo ? `easiarr | ${stepInfo}` : "easiarr"
33
+
34
+ const headerBox = new BoxRenderable(renderer, {
35
+ id: `${idPrefix}-header-box`,
36
+ width: "100%",
37
+ height: 3,
38
+ borderStyle: "single",
39
+ borderColor: "#4a9eff",
40
+ title: headerText,
41
+ titleAlignment: "left",
42
+ paddingLeft: 1,
43
+ paddingRight: 1,
44
+ backgroundColor: "#1a1a2e",
45
+ })
46
+
47
+ headerBox.add(
48
+ new TextRenderable(renderer, {
49
+ id: `${idPrefix}-page-title`,
50
+ content: title,
51
+ fg: "#ffffff",
52
+ })
53
+ )
54
+
55
+ container.add(headerBox)
56
+
57
+ // Content area (flex grow)
58
+ const content = new BoxRenderable(renderer, {
59
+ id: `${idPrefix}-content`,
60
+ flexGrow: 1,
61
+ width: "100%",
62
+ padding: 1,
63
+ flexDirection: "column",
64
+ backgroundColor: "#1a1a2e",
65
+ })
66
+
67
+ container.add(content)
68
+
69
+ // Footer box with border
70
+ const footerBox = new BoxRenderable(renderer, {
71
+ id: `${idPrefix}-footer-box`,
72
+ borderStyle: "single",
73
+ borderColor: "#4a9eff",
74
+ paddingLeft: 1,
75
+ paddingRight: 1,
76
+ width: "100%",
77
+ flexDirection: "row",
78
+ justifyContent: "space-between",
79
+ backgroundColor: "#1a1a2e",
80
+ })
81
+
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",
89
+ })
90
+ )
91
+
92
+ // Version
93
+ footerBox.add(
94
+ new TextRenderable(renderer, {
95
+ id: `${idPrefix}-version`,
96
+ content: getVersion(),
97
+ fg: "#555555",
98
+ })
99
+ )
100
+
101
+ container.add(footerBox)
102
+
103
+ return { container, content }
104
+ }
@@ -0,0 +1 @@
1
+ export * from "./App"
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Advanced Settings Screen
3
+ * Edit configuration files directly
4
+ */
5
+
6
+ import {
7
+ BoxRenderable,
8
+ SelectRenderable,
9
+ SelectRenderableEvents,
10
+ CliRenderer,
11
+ RenderContext,
12
+ KeyEvent,
13
+ } from "@opentui/core"
14
+ import { App } from "../App"
15
+ import { EasiarrConfig } from "../../config/schema"
16
+ import { createPageLayout } from "../components/PageLayout"
17
+ import { FileEditor } from "../components/FileEditor"
18
+ import { readFile, writeFile } from "node:fs/promises"
19
+ import { getConfigPath, getComposePath } from "../../config/manager"
20
+ import { existsSync } from "node:fs"
21
+
22
+ export class AdvancedSettings {
23
+ private renderer: CliRenderer
24
+ private container: BoxRenderable
25
+ private app: App
26
+ private config: EasiarrConfig
27
+ private keyHandler: ((k: KeyEvent) => void) | null = null
28
+ private activeEditor: FileEditor | null = null
29
+
30
+ constructor(renderer: CliRenderer | RenderContext, container: BoxRenderable, app: App, config: EasiarrConfig) {
31
+ this.renderer = renderer as CliRenderer
32
+ this.container = container
33
+ this.app = app
34
+ this.config = config
35
+
36
+ this.renderMenu()
37
+ }
38
+
39
+ private clear() {
40
+ if (this.keyHandler) {
41
+ this.renderer.keyInput.off("keypress", this.keyHandler)
42
+ this.keyHandler = null
43
+ }
44
+ if (this.activeEditor) {
45
+ this.activeEditor.destroy()
46
+ this.activeEditor = null
47
+ }
48
+ const children = this.container.getChildren()
49
+ for (const child of children) {
50
+ this.container.remove(child.id)
51
+ }
52
+ }
53
+
54
+ private renderMenu(): void {
55
+ this.clear()
56
+
57
+ const { container: page, content } = createPageLayout(this.renderer, {
58
+ title: "Advanced Settings",
59
+ stepInfo: "Direct File Editing",
60
+ footerHint: "Enter Select Esc Back",
61
+ })
62
+
63
+ const menu = new SelectRenderable(this.renderer, {
64
+ id: "advanced-menu",
65
+ width: "100%",
66
+ flexGrow: 1,
67
+ options: [
68
+ {
69
+ name: "📄 Edit config.json",
70
+ description: "Raw application configuration",
71
+ },
72
+ {
73
+ name: "🔑 Edit .env Secrets",
74
+ description: "Environment variables and secrets",
75
+ },
76
+ {
77
+ name: "🐳 View docker-compose.yml",
78
+ description: "Generated Docker composition (Read-only recommended)",
79
+ },
80
+ {
81
+ name: "◀ Back",
82
+ description: "Return to Main Menu",
83
+ },
84
+ ],
85
+ })
86
+
87
+ menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
88
+ switch (index) {
89
+ case 0:
90
+ await this.editFile("config.json", getConfigPath(), async (content) => {
91
+ // Validate JSON?
92
+ try {
93
+ const newConfig = JSON.parse(content)
94
+ await writeFile(getConfigPath(), content, "utf-8")
95
+ this.app.saveAndReload(newConfig)
96
+ } catch {
97
+ // Show error? For now just log/ignore or loop
98
+ // Ideally show an error dialog.
99
+ }
100
+ })
101
+ break
102
+ case 1: {
103
+ const envPath = getComposePath().replace("docker-compose.yml", ".env")
104
+ await this.editFile(".env", envPath, async (content) => {
105
+ await writeFile(envPath, content, "utf-8")
106
+ this.renderMenu()
107
+ })
108
+ break
109
+ }
110
+ case 2:
111
+ await this.editFile("docker-compose.yml", getComposePath(), async (content) => {
112
+ await writeFile(getComposePath(), content, "utf-8")
113
+ this.renderMenu()
114
+ })
115
+ break
116
+ case 3:
117
+ this.app.navigateTo("main")
118
+ break
119
+ }
120
+ })
121
+
122
+ content.add(menu)
123
+ menu.focus()
124
+
125
+ this.keyHandler = (k: KeyEvent) => {
126
+ if (k.name === "escape") {
127
+ this.app.navigateTo("main")
128
+ }
129
+ }
130
+ this.renderer.keyInput.on("keypress", this.keyHandler)
131
+
132
+ this.container.add(page)
133
+ }
134
+
135
+ private async editFile(name: string, path: string, onSave: (content: string) => Promise<void>) {
136
+ this.clear()
137
+
138
+ let initialContent = ""
139
+ try {
140
+ if (existsSync(path)) {
141
+ initialContent = await readFile(path, "utf-8")
142
+ }
143
+ } catch {
144
+ initialContent = "// Failed to read file or file does not exist"
145
+ }
146
+
147
+ const editor = new FileEditor(this.renderer, {
148
+ id: `editor-${name}`,
149
+ width: "100%",
150
+ height: "100%",
151
+ filename: name,
152
+ initialContent,
153
+ onSave: async (content) => {
154
+ await onSave(content)
155
+ // onSave callback might navigate away (reload), but if not:
156
+ if (name !== "config.json") {
157
+ this.renderMenu()
158
+ }
159
+ },
160
+ onCancel: () => {
161
+ this.renderMenu()
162
+ },
163
+ })
164
+
165
+ // FileEditor handles its own keys?
166
+ // We cleared our keyHandler in this.clear().
167
+ // FileEditor constructor attaches listeners?
168
+ // Check FileEditor implementation again:
169
+ // It attaches to this.textarea.on("keypress").
170
+ // If that works, fine.
171
+
172
+ // Wait, FileEditor focus?
173
+ this.container.add(editor)
174
+ this.activeEditor = editor
175
+ setTimeout(() => editor.focus(), 10)
176
+ }
177
+ }
@@ -0,0 +1,223 @@
1
+ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import { writeFile, readFile } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { createPageLayout } from "../components/PageLayout"
6
+ import { EasiarrConfig } from "../../config/schema"
7
+ import { getApp } from "../../apps/registry"
8
+ import { getComposePath } from "../../config/manager"
9
+
10
+ export class ApiKeyViewer extends BoxRenderable {
11
+ private config: EasiarrConfig
12
+ private keys: Array<{ appId: string; app: string; key: string; status: "found" | "missing" | "error" }> = []
13
+ private keyHandler!: (key: KeyEvent) => void
14
+ private cliRenderer: CliRenderer
15
+ private statusText: TextRenderable | null = null
16
+
17
+ constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
18
+ super(renderer, {
19
+ id: "api-key-viewer",
20
+ width: "100%",
21
+ height: "100%",
22
+ backgroundColor: "#111111", // Dark bg
23
+ zIndex: 200, // Above main menu
24
+ })
25
+ this.cliRenderer = renderer
26
+ this.config = config
27
+
28
+ this.scanKeys()
29
+ this.renderPage(onBack)
30
+ }
31
+
32
+ private scanKeys() {
33
+ this.keys = []
34
+
35
+ for (const appConfig of this.config.apps) {
36
+ if (!appConfig.enabled) continue
37
+
38
+ const appDef = getApp(appConfig.id)
39
+ if (!appDef || !appDef.apiKeyMeta) continue
40
+
41
+ try {
42
+ // Resolve config path
43
+ // Volumes are: ["${root}/config/radarr:/config", ...]
44
+ // We assume index 0 is the config volume
45
+ const volumes = appDef.volumes(this.config.rootDir)
46
+ if (volumes.length === 0) continue
47
+
48
+ const parts = volumes[0].split(":")
49
+ const hostPath = parts[0]
50
+
51
+ const configFilePath = join(hostPath, appDef.apiKeyMeta.configFile)
52
+
53
+ if (existsSync(configFilePath)) {
54
+ const content = readFileSync(configFilePath, "utf-8")
55
+
56
+ if (appDef.apiKeyMeta.parser === "regex") {
57
+ const regex = new RegExp(appDef.apiKeyMeta.selector)
58
+ const match = regex.exec(content)
59
+ if (match && match[1]) {
60
+ this.keys.push({ appId: appDef.id, app: appDef.name, key: match[1], status: "found" })
61
+ } else {
62
+ this.keys.push({ appId: appDef.id, app: appDef.name, key: "Not found in file", status: "error" })
63
+ }
64
+ } else if (appDef.apiKeyMeta.parser === "json") {
65
+ const json = JSON.parse(content)
66
+ // Support dot notation like "main.apiKey"
67
+ const value = appDef.apiKeyMeta.selector.split(".").reduce((obj, key) => obj?.[key], json)
68
+ if (value && typeof value === "string") {
69
+ this.keys.push({ appId: appDef.id, app: appDef.name, key: value, status: "found" })
70
+ } else {
71
+ this.keys.push({ appId: appDef.id, app: appDef.name, key: "Key not found in JSON", status: "error" })
72
+ }
73
+ }
74
+ } else {
75
+ this.keys.push({
76
+ appId: appDef.id,
77
+ app: appDef.name,
78
+ key: "Config file not found (Run app first)",
79
+ status: "missing",
80
+ })
81
+ }
82
+ } catch {
83
+ this.keys.push({ appId: appDef.id, app: appDef.name, key: "Error reading file", status: "error" })
84
+ }
85
+ }
86
+ }
87
+
88
+ private renderPage(onBack: () => void) {
89
+ const foundKeys = this.keys.filter((k) => k.status === "found")
90
+ const hasFoundKeys = foundKeys.length > 0
91
+
92
+ const { container, content } = createPageLayout(this.cliRenderer, {
93
+ title: "API Key Extractor",
94
+ stepInfo: "Found Keys",
95
+ footerHint: hasFoundKeys ? "S Save to .env Esc/Enter Return" : "Esc/Enter: Return",
96
+ })
97
+ this.add(container)
98
+
99
+ if (this.keys.length === 0) {
100
+ content.add(
101
+ new TextRenderable(this.cliRenderer, {
102
+ content: "No enabled apps have extractable API keys.",
103
+ fg: "#aaaaaa",
104
+ })
105
+ )
106
+ } else {
107
+ // Header
108
+ const header = new BoxRenderable(this.cliRenderer, {
109
+ width: "100%",
110
+ height: 1,
111
+ flexDirection: "row",
112
+ marginBottom: 1,
113
+ })
114
+ header.add(
115
+ new TextRenderable(this.cliRenderer, { content: "Application".padEnd(20), fg: "#ffffff", attributes: 1 })
116
+ )
117
+ header.add(new TextRenderable(this.cliRenderer, { content: "API Key", fg: "#ffffff", attributes: 1 }))
118
+ content.add(header)
119
+
120
+ // Rows
121
+ this.keys.forEach((k) => {
122
+ const row = new BoxRenderable(this.cliRenderer, {
123
+ width: "100%",
124
+ height: 1,
125
+ flexDirection: "row",
126
+ marginBottom: 0,
127
+ })
128
+
129
+ // App Name
130
+ row.add(
131
+ new TextRenderable(this.cliRenderer, {
132
+ content: k.app.padEnd(20),
133
+ fg: k.status === "found" ? "#50fa7b" : "#ff5555",
134
+ })
135
+ )
136
+
137
+ // Key
138
+ row.add(
139
+ new TextRenderable(this.cliRenderer, {
140
+ content: k.key,
141
+ fg: k.status === "found" ? "#f1fa8c" : "#6272a4",
142
+ })
143
+ )
144
+ content.add(row)
145
+ })
146
+
147
+ // Status text for feedback
148
+ content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
149
+ this.statusText = new TextRenderable(this.cliRenderer, {
150
+ id: "api-key-status",
151
+ content: "",
152
+ fg: "#50fa7b",
153
+ })
154
+ content.add(this.statusText)
155
+ }
156
+
157
+ // Key Handler
158
+ this.keyHandler = (key: KeyEvent) => {
159
+ if (key.name === "escape" || key.name === "enter") {
160
+ this.destroy()
161
+ onBack()
162
+ } else if (key.name === "s" && hasFoundKeys) {
163
+ this.saveToEnv()
164
+ }
165
+ }
166
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
167
+ }
168
+
169
+ public destroy() {
170
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
171
+ if (this.parent) {
172
+ if (this.id) {
173
+ try {
174
+ this.parent.remove(this.id)
175
+ } catch {
176
+ /* ignore */
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ private async saveToEnv() {
183
+ const foundKeys = this.keys.filter((k) => k.status === "found")
184
+ if (foundKeys.length === 0) return
185
+
186
+ try {
187
+ const envPath = getComposePath().replace("docker-compose.yml", ".env")
188
+
189
+ // Read existing .env if present
190
+ const currentEnv: Record<string, string> = {}
191
+ if (existsSync(envPath)) {
192
+ const content = await readFile(envPath, "utf-8")
193
+ content.split("\n").forEach((line) => {
194
+ const [key, ...val] = line.split("=")
195
+ if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
196
+ })
197
+ }
198
+
199
+ // Add API keys with format API_KEY_SONARR, API_KEY_RADARR, etc.
200
+ for (const k of foundKeys) {
201
+ const envKey = `API_KEY_${k.appId.toUpperCase()}`
202
+ currentEnv[envKey] = k.key
203
+ }
204
+
205
+ // Reconstruct .env content
206
+ const envContent = Object.entries(currentEnv)
207
+ .map(([k, v]) => `${k}=${v}`)
208
+ .join("\n")
209
+
210
+ await writeFile(envPath, envContent, "utf-8")
211
+
212
+ // Update status
213
+ if (this.statusText) {
214
+ this.statusText.content = `✓ Saved ${foundKeys.length} API key(s) to .env`
215
+ }
216
+ } catch (e) {
217
+ if (this.statusText) {
218
+ this.statusText.content = `✗ Error saving to .env: ${e}`
219
+ this.statusText.fg = "#ff5555"
220
+ }
221
+ }
222
+ }
223
+ }