@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,256 @@
1
+ import {
2
+ BoxRenderable,
3
+ InputRenderable,
4
+ TextRenderable,
5
+ CliRenderer,
6
+ RenderContext,
7
+ BoxOptions,
8
+ RGBA,
9
+ KeyEvent,
10
+ } from "@opentui/core"
11
+ import { EasiarrConfig, AppSecret } from "../../config/schema"
12
+ import { getApp } from "../../apps/registry"
13
+ import { getComposePath } from "../../config/manager"
14
+ import { readFile, writeFile, mkdir } from "node:fs/promises"
15
+ import { dirname } from "node:path"
16
+ import { existsSync } from "node:fs"
17
+
18
+ export interface SecretsEditorOptions extends BoxOptions {
19
+ config: EasiarrConfig
20
+ onSave: () => void
21
+ onCancel: () => void
22
+ extraEnv?: Record<string, { value: string; description: string }>
23
+ }
24
+
25
+ export class SecretsEditor extends BoxRenderable {
26
+ private inputs: Map<string, InputRenderable> = new Map()
27
+ private secrets: Map<string, AppSecret> = new Map()
28
+ private currentFocusIndex = 0
29
+ private inputKeys: string[] = []
30
+ private config: EasiarrConfig
31
+ private onSave: () => void
32
+ private onCancel: () => void
33
+ private extraEnv: Record<string, { value: string; description: string }> = {}
34
+ private envValues: Record<string, string> = {}
35
+ private renderer: CliRenderer
36
+ private keyHandler: ((k: KeyEvent) => void) | null = null
37
+
38
+ constructor(renderer: CliRenderer | RenderContext, options: SecretsEditorOptions) {
39
+ super(renderer, {
40
+ ...options,
41
+ border: true,
42
+ borderStyle: "double",
43
+ title: "Secrets Manager (.env)",
44
+ titleAlignment: "center",
45
+ backgroundColor: RGBA.fromHex("#1a1a1a"), // Dark background
46
+ })
47
+
48
+ this.renderer = renderer as CliRenderer
49
+ this.config = options.config
50
+ this.onSave = options.onSave
51
+ this.onCancel = options.onCancel
52
+ this.extraEnv = options.extraEnv || {}
53
+
54
+ this.initSecrets()
55
+ }
56
+
57
+ private async initSecrets() {
58
+ // 0. Add Extra Env (System Config)
59
+ for (const [key, info] of Object.entries(this.extraEnv)) {
60
+ this.secrets.set(key, {
61
+ name: key,
62
+ description: info.description,
63
+ default: info.value,
64
+ required: true,
65
+ })
66
+ }
67
+
68
+ // 1. Collect Secrets from Enabled Apps
69
+ for (const appConfig of this.config.apps) {
70
+ if (!appConfig.enabled) continue
71
+ const appDef = getApp(appConfig.id)
72
+ if (appDef && appDef.secrets) {
73
+ for (const secret of appDef.secrets) {
74
+ if (!this.secrets.has(secret.name)) {
75
+ this.secrets.set(secret.name, secret)
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ if (this.secrets.size === 0) {
82
+ this.add(
83
+ new TextRenderable(this.renderer as CliRenderer, {
84
+ content: "No secrets required for selected apps.",
85
+ left: 2,
86
+ top: 2,
87
+ })
88
+ )
89
+ // Add Exit hint
90
+ this.add(
91
+ new TextRenderable(this.renderer as CliRenderer, {
92
+ content: "Press ESC to return",
93
+ bottom: 1,
94
+ left: 2,
95
+ })
96
+ )
97
+ return
98
+ }
99
+
100
+ // 2. Load existing .env
101
+ await this.loadEnv()
102
+
103
+ // 3. Render Inputs
104
+ // Use a scrolling container if needed, but for now just stacking boxes
105
+ const container = new BoxRenderable(this.renderer as CliRenderer, {
106
+ width: "100%",
107
+ flexDirection: "column",
108
+ padding: 1,
109
+ })
110
+ this.add(container)
111
+
112
+ this.secrets.forEach((secret, key) => {
113
+ const row = new BoxRenderable(this.renderer as CliRenderer, {
114
+ width: "100%",
115
+ height: 1,
116
+ flexDirection: "row",
117
+ marginBottom: 1,
118
+ })
119
+
120
+ // Label
121
+ const label = new TextRenderable(this.renderer as CliRenderer, {
122
+ content: `${secret.name}${secret.required ? "*" : ""}:`.padEnd(30),
123
+ width: 30,
124
+ fg: secret.required ? RGBA.fromHex("#ff5555") : RGBA.fromHex("#aaaaaa"),
125
+ })
126
+ row.add(label)
127
+
128
+ // Input
129
+ const input = new InputRenderable(this.renderer as CliRenderer, {
130
+ id: `input-${key}`,
131
+ width: 60, // Wider for paths
132
+ placeholder: secret.description,
133
+ backgroundColor: RGBA.fromHex("#333333"),
134
+ focusedBackgroundColor: RGBA.fromHex("#444444"),
135
+ textColor: RGBA.fromHex("#ffffff"),
136
+ })
137
+
138
+ // Prefer envValue > default > empty
139
+ input.value = this.envValues[key] || secret.default || ""
140
+
141
+ row.add(input)
142
+ container.add(row)
143
+
144
+ this.inputs.set(key, input)
145
+ this.inputKeys.push(key)
146
+ })
147
+
148
+ // Instructions
149
+ this.add(
150
+ new TextRenderable(this.renderer as CliRenderer, {
151
+ content: "TAB: Next Field | CTRL+S: Save | ESC: Cancel",
152
+ bottom: 0,
153
+ left: 2,
154
+ width: "100%",
155
+ })
156
+ )
157
+
158
+ // Global Key Handling (intercept Tab, Enter, Esc, Ctrl+S)
159
+ this.keyHandler = (k: KeyEvent) => {
160
+ // Allow inputs to handle typing, but intercept navigation/actions
161
+ if (k.name === "tab") {
162
+ // k.preventDefault() // Prevent Input from adding tab char?
163
+ this.focusNext()
164
+ } else if (k.name === "s" && k.ctrl) {
165
+ // k.preventDefault()
166
+ this.save()
167
+ } else if (k.name === "escape") {
168
+ this.onCancel()
169
+ }
170
+ }
171
+ this.renderer.keyInput.on("keypress", this.keyHandler)
172
+
173
+ // Focus first
174
+ if (this.inputKeys.length > 0) {
175
+ this.inputs.get(this.inputKeys[0])?.focus()
176
+ }
177
+ }
178
+
179
+ private async loadEnv() {
180
+ const envPath = getComposePath().replace("docker-compose.yml", ".env")
181
+ if (existsSync(envPath)) {
182
+ try {
183
+ const content = await readFile(envPath, "utf-8")
184
+ content.split("\n").forEach((line) => {
185
+ const parts = line.split("=")
186
+ if (parts.length >= 2) {
187
+ const key = parts[0].trim()
188
+ const value = parts.slice(1).join("=").trim()
189
+ // Remove potential quotes
190
+ this.envValues[key] = value.replace(/^["'](.*?)["']$/, "$1")
191
+ }
192
+ })
193
+ } catch (e) {
194
+ console.error("Failed to read .env", e)
195
+ }
196
+ }
197
+ }
198
+
199
+ private async save() {
200
+ const envPath = getComposePath().replace("docker-compose.yml", ".env")
201
+
202
+ // Read existing .env to preserve other values
203
+ const currentEnv: Record<string, string> = {}
204
+ if (existsSync(envPath)) {
205
+ try {
206
+ const content = await readFile(envPath, "utf-8")
207
+ content.split("\n").forEach((line) => {
208
+ const [key, ...val] = line.split("=")
209
+ if (key && val.length > 0) {
210
+ currentEnv[key.trim()] = val
211
+ .join("=")
212
+ .trim()
213
+ .replace(/^["'](.*?)["']$/, "$1")
214
+ }
215
+ })
216
+ } catch {
217
+ // Ignore read errors
218
+ }
219
+ }
220
+
221
+ // Update with new values from inputs
222
+ this.inputs.forEach((input, key) => {
223
+ currentEnv[key] = input.value
224
+ })
225
+
226
+ // Ensure directory exists
227
+ try {
228
+ await mkdir(dirname(envPath), { recursive: true })
229
+ } catch {
230
+ // Ignore if exists
231
+ }
232
+
233
+ // Write back
234
+ const envContent = Object.entries(currentEnv)
235
+ .map(([k, v]) => `${k}=${v}`)
236
+ .join("\n")
237
+
238
+ await writeFile(envPath, envContent, "utf-8")
239
+
240
+ this.onSave()
241
+ }
242
+
243
+ override destroy(): void {
244
+ if (this.keyHandler) {
245
+ this.renderer.keyInput.off("keypress", this.keyHandler)
246
+ this.keyHandler = null
247
+ }
248
+ super.destroy()
249
+ }
250
+
251
+ private focusNext() {
252
+ this.currentFocusIndex = (this.currentFocusIndex + 1) % this.inputKeys.length
253
+ const key = this.inputKeys[this.currentFocusIndex]
254
+ this.inputs.get(key)?.focus()
255
+ }
256
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./MainMenu"
2
+ export * from "./QuickSetup"
3
+ export * from "./AppManager"
4
+ export * from "./ContainerControl"