@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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/package.json +72 -0
- package/src/VersionInfo.ts +12 -0
- package/src/api/arr-api.ts +198 -0
- package/src/api/index.ts +1 -0
- package/src/apps/categories.ts +14 -0
- package/src/apps/index.ts +2 -0
- package/src/apps/registry.ts +868 -0
- package/src/compose/generator.ts +234 -0
- package/src/compose/index.ts +2 -0
- package/src/compose/templates.ts +68 -0
- package/src/config/defaults.ts +37 -0
- package/src/config/index.ts +3 -0
- package/src/config/manager.ts +109 -0
- package/src/config/schema.ts +191 -0
- package/src/docker/client.ts +129 -0
- package/src/docker/index.ts +1 -0
- package/src/index.ts +24 -0
- package/src/structure/manager.ts +86 -0
- package/src/ui/App.ts +95 -0
- package/src/ui/components/ApplicationSelector.ts +256 -0
- package/src/ui/components/FileEditor.ts +91 -0
- package/src/ui/components/PageLayout.ts +104 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/screens/AdvancedSettings.ts +177 -0
- package/src/ui/screens/ApiKeyViewer.ts +223 -0
- package/src/ui/screens/AppConfigurator.ts +549 -0
- package/src/ui/screens/AppManager.ts +271 -0
- package/src/ui/screens/ContainerControl.ts +142 -0
- package/src/ui/screens/MainMenu.ts +161 -0
- package/src/ui/screens/QuickSetup.ts +1110 -0
- package/src/ui/screens/SecretsEditor.ts +256 -0
- package/src/ui/screens/index.ts +4 -0
|
@@ -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
|
+
}
|