@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,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Configurator Screen
|
|
3
|
+
* Configures *arr apps via API - sets root folders and download clients
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
BoxRenderable,
|
|
8
|
+
CliRenderer,
|
|
9
|
+
TextRenderable,
|
|
10
|
+
InputRenderable,
|
|
11
|
+
InputRenderableEvents,
|
|
12
|
+
KeyEvent,
|
|
13
|
+
} from "@opentui/core"
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
15
|
+
import { writeFile, readFile } from "node:fs/promises"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
import { createPageLayout } from "../components/PageLayout"
|
|
18
|
+
import { EasiarrConfig, AppId } from "../../config/schema"
|
|
19
|
+
import { getApp } from "../../apps/registry"
|
|
20
|
+
import { ArrApiClient, createQBittorrentConfig, createSABnzbdConfig } from "../../api/arr-api"
|
|
21
|
+
import { getComposePath } from "../../config/manager"
|
|
22
|
+
|
|
23
|
+
interface ConfigResult {
|
|
24
|
+
appId: AppId
|
|
25
|
+
appName: string
|
|
26
|
+
status: "pending" | "configuring" | "success" | "error" | "skipped"
|
|
27
|
+
message?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Step = "configure" | "qbittorrent" | "sabnzbd" | "done"
|
|
31
|
+
|
|
32
|
+
export class AppConfigurator extends BoxRenderable {
|
|
33
|
+
private config: EasiarrConfig
|
|
34
|
+
private cliRenderer: CliRenderer
|
|
35
|
+
private keyHandler!: (key: KeyEvent) => void
|
|
36
|
+
private results: ConfigResult[] = []
|
|
37
|
+
private currentStep: Step = "configure"
|
|
38
|
+
private contentBox!: BoxRenderable
|
|
39
|
+
private pageContainer!: BoxRenderable
|
|
40
|
+
|
|
41
|
+
// Download client credentials
|
|
42
|
+
private qbHost = "qbittorrent"
|
|
43
|
+
private qbPort = 8080
|
|
44
|
+
private qbUser = "admin"
|
|
45
|
+
private qbPass = ""
|
|
46
|
+
private sabHost = "sabnzbd"
|
|
47
|
+
private sabPort = 8080
|
|
48
|
+
private sabApiKey = ""
|
|
49
|
+
|
|
50
|
+
// Check which download clients are enabled
|
|
51
|
+
private hasQBittorrent = false
|
|
52
|
+
private hasSABnzbd = false
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
renderer: CliRenderer,
|
|
56
|
+
config: EasiarrConfig,
|
|
57
|
+
private onBack: () => void
|
|
58
|
+
) {
|
|
59
|
+
super(renderer, {
|
|
60
|
+
id: "app-configurator",
|
|
61
|
+
width: "100%",
|
|
62
|
+
height: "100%",
|
|
63
|
+
backgroundColor: "#111111",
|
|
64
|
+
zIndex: 200,
|
|
65
|
+
})
|
|
66
|
+
this.cliRenderer = renderer
|
|
67
|
+
this.config = config
|
|
68
|
+
|
|
69
|
+
// Check enabled download clients
|
|
70
|
+
this.hasQBittorrent = config.apps.some((a) => a.id === "qbittorrent" && a.enabled)
|
|
71
|
+
this.hasSABnzbd = config.apps.some((a) => a.id === "sabnzbd" && a.enabled)
|
|
72
|
+
|
|
73
|
+
// Load saved credentials from .env
|
|
74
|
+
this.loadSavedCredentials()
|
|
75
|
+
|
|
76
|
+
this.runConfiguration()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private loadSavedCredentials() {
|
|
80
|
+
try {
|
|
81
|
+
const envPath = getComposePath().replace("docker-compose.yml", ".env")
|
|
82
|
+
if (!existsSync(envPath)) return
|
|
83
|
+
|
|
84
|
+
const content = readFileSync(envPath, "utf-8")
|
|
85
|
+
content.split("\n").forEach((line) => {
|
|
86
|
+
const [key, ...val] = line.split("=")
|
|
87
|
+
if (key && val.length > 0) {
|
|
88
|
+
const value = val.join("=").trim()
|
|
89
|
+
if (key.trim() === "QBITTORRENT_PASSWORD") {
|
|
90
|
+
this.qbPass = value
|
|
91
|
+
} else if (key.trim() === "SABNZBD_API_KEY") {
|
|
92
|
+
this.sabApiKey = value
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore errors
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async runConfiguration() {
|
|
102
|
+
// Initialize results for apps that have rootFolder
|
|
103
|
+
for (const appConfig of this.config.apps) {
|
|
104
|
+
if (!appConfig.enabled) continue
|
|
105
|
+
const appDef = getApp(appConfig.id)
|
|
106
|
+
if (!appDef?.rootFolder) continue
|
|
107
|
+
|
|
108
|
+
this.results.push({
|
|
109
|
+
appId: appConfig.id,
|
|
110
|
+
appName: appDef.name,
|
|
111
|
+
status: "pending",
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.renderConfigProgress()
|
|
116
|
+
|
|
117
|
+
// Configure each app
|
|
118
|
+
for (let i = 0; i < this.results.length; i++) {
|
|
119
|
+
const result = this.results[i]
|
|
120
|
+
result.status = "configuring"
|
|
121
|
+
this.updateDisplay()
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await this.configureApp(result.appId)
|
|
125
|
+
result.status = "success"
|
|
126
|
+
result.message = "Root folder configured"
|
|
127
|
+
} catch (e) {
|
|
128
|
+
result.status = "error"
|
|
129
|
+
result.message = e instanceof Error ? e.message : String(e)
|
|
130
|
+
}
|
|
131
|
+
this.updateDisplay()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// After root folders, prompt for download clients if needed
|
|
135
|
+
if (this.hasQBittorrent || this.hasSABnzbd) {
|
|
136
|
+
if (this.hasQBittorrent) {
|
|
137
|
+
this.currentStep = "qbittorrent"
|
|
138
|
+
this.renderQBittorrentPrompt()
|
|
139
|
+
} else if (this.hasSABnzbd) {
|
|
140
|
+
this.currentStep = "sabnzbd"
|
|
141
|
+
this.renderSABnzbdPrompt()
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
this.currentStep = "done"
|
|
145
|
+
this.renderDone()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async configureApp(appId: AppId): Promise<void> {
|
|
150
|
+
const appDef = getApp(appId)
|
|
151
|
+
if (!appDef?.rootFolder || !appDef.apiKeyMeta) {
|
|
152
|
+
throw new Error("Missing configuration")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Get API key from config file
|
|
156
|
+
const apiKey = this.extractApiKey(appId)
|
|
157
|
+
if (!apiKey) {
|
|
158
|
+
throw new Error("API key not found - start container first")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Wait for app to be healthy
|
|
162
|
+
const port = this.config.apps.find((a) => a.id === appId)?.port || appDef.defaultPort
|
|
163
|
+
const client = new ArrApiClient("localhost", port, apiKey, appDef.rootFolder.apiVersion)
|
|
164
|
+
|
|
165
|
+
// Retry health check a few times
|
|
166
|
+
let healthy = false
|
|
167
|
+
for (let i = 0; i < 3; i++) {
|
|
168
|
+
healthy = await client.isHealthy()
|
|
169
|
+
if (healthy) break
|
|
170
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!healthy) {
|
|
174
|
+
throw new Error("App not responding - start containers first")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check if root folder already exists
|
|
178
|
+
const existingFolders = await client.getRootFolders()
|
|
179
|
+
const alreadyExists = existingFolders.some((f) => f.path === appDef.rootFolder!.path)
|
|
180
|
+
|
|
181
|
+
if (alreadyExists) {
|
|
182
|
+
throw new Error("Already configured")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Add root folder
|
|
186
|
+
await client.addRootFolder(appDef.rootFolder.path)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private extractApiKey(appId: AppId): string | null {
|
|
190
|
+
const appDef = getApp(appId)
|
|
191
|
+
if (!appDef?.apiKeyMeta) return null
|
|
192
|
+
|
|
193
|
+
const volumes = appDef.volumes(this.config.rootDir)
|
|
194
|
+
if (volumes.length === 0) return null
|
|
195
|
+
|
|
196
|
+
const parts = volumes[0].split(":")
|
|
197
|
+
const hostPath = parts[0]
|
|
198
|
+
const configFilePath = join(hostPath, appDef.apiKeyMeta.configFile)
|
|
199
|
+
|
|
200
|
+
if (!existsSync(configFilePath)) return null
|
|
201
|
+
|
|
202
|
+
const content = readFileSync(configFilePath, "utf-8")
|
|
203
|
+
|
|
204
|
+
if (appDef.apiKeyMeta.parser === "regex") {
|
|
205
|
+
const regex = new RegExp(appDef.apiKeyMeta.selector)
|
|
206
|
+
const match = regex.exec(content)
|
|
207
|
+
return match?.[1] || null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private renderConfigProgress() {
|
|
214
|
+
this.clear()
|
|
215
|
+
|
|
216
|
+
const { container, content } = createPageLayout(this.cliRenderer, {
|
|
217
|
+
title: "Configure Apps",
|
|
218
|
+
stepInfo: "Setting up root folders",
|
|
219
|
+
footerHint: "Please wait...",
|
|
220
|
+
})
|
|
221
|
+
this.pageContainer = container
|
|
222
|
+
this.contentBox = content
|
|
223
|
+
this.add(container)
|
|
224
|
+
|
|
225
|
+
this.updateDisplay()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private updateDisplay() {
|
|
229
|
+
// Clear content and rebuild - remove all children from contentBox
|
|
230
|
+
const contentChildren = [...this.contentBox.getChildren()]
|
|
231
|
+
for (const child of contentChildren) {
|
|
232
|
+
if (child.id) {
|
|
233
|
+
try {
|
|
234
|
+
this.contentBox.remove(child.id)
|
|
235
|
+
} catch {
|
|
236
|
+
/* ignore */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Header
|
|
242
|
+
this.contentBox.add(
|
|
243
|
+
new TextRenderable(this.cliRenderer, {
|
|
244
|
+
content: "Configuring *arr applications...\n",
|
|
245
|
+
fg: "#4a9eff",
|
|
246
|
+
})
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
// Results
|
|
250
|
+
for (const result of this.results) {
|
|
251
|
+
const icon =
|
|
252
|
+
result.status === "pending"
|
|
253
|
+
? "⏳"
|
|
254
|
+
: result.status === "configuring"
|
|
255
|
+
? "🔄"
|
|
256
|
+
: result.status === "success"
|
|
257
|
+
? "✓"
|
|
258
|
+
: result.status === "skipped"
|
|
259
|
+
? "⏭"
|
|
260
|
+
: "✗"
|
|
261
|
+
|
|
262
|
+
const color =
|
|
263
|
+
result.status === "success"
|
|
264
|
+
? "#50fa7b"
|
|
265
|
+
: result.status === "error"
|
|
266
|
+
? "#ff5555"
|
|
267
|
+
: result.status === "skipped"
|
|
268
|
+
? "#6272a4"
|
|
269
|
+
: "#f1fa8c"
|
|
270
|
+
|
|
271
|
+
const message = result.message ? ` - ${result.message}` : ""
|
|
272
|
+
|
|
273
|
+
this.contentBox.add(
|
|
274
|
+
new TextRenderable(this.cliRenderer, {
|
|
275
|
+
content: `${icon} ${result.appName.padEnd(15)} ${message}`,
|
|
276
|
+
fg: color,
|
|
277
|
+
})
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private renderQBittorrentPrompt() {
|
|
283
|
+
this.clear()
|
|
284
|
+
|
|
285
|
+
const { container, content } = createPageLayout(this.cliRenderer, {
|
|
286
|
+
title: "Configure Apps",
|
|
287
|
+
stepInfo: "qBittorrent Credentials",
|
|
288
|
+
footerHint: "Enter credentials from qBittorrent WebUI Esc Skip",
|
|
289
|
+
})
|
|
290
|
+
this.pageContainer = container
|
|
291
|
+
this.add(container)
|
|
292
|
+
|
|
293
|
+
content.add(
|
|
294
|
+
new TextRenderable(this.cliRenderer, {
|
|
295
|
+
content: "Enter qBittorrent credentials (from Settings → WebUI):\n",
|
|
296
|
+
fg: "#4a9eff",
|
|
297
|
+
})
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
// Password input - pre-fill with saved value
|
|
301
|
+
content.add(new TextRenderable(this.cliRenderer, { content: "Password:", fg: "#aaaaaa" }))
|
|
302
|
+
const passInput = new InputRenderable(this.cliRenderer, {
|
|
303
|
+
id: "qb-pass-input",
|
|
304
|
+
width: 30,
|
|
305
|
+
placeholder: "WebUI Password",
|
|
306
|
+
value: this.qbPass,
|
|
307
|
+
focusedBackgroundColor: "#1a1a1a",
|
|
308
|
+
})
|
|
309
|
+
content.add(passInput)
|
|
310
|
+
|
|
311
|
+
passInput.focus()
|
|
312
|
+
|
|
313
|
+
// Handle Enter via SUBMIT event
|
|
314
|
+
passInput.on(InputRenderableEvents.ENTER, () => {
|
|
315
|
+
this.qbPass = passInput.value
|
|
316
|
+
if (this.keyHandler) this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
317
|
+
passInput.blur()
|
|
318
|
+
this.addDownloadClients("qbittorrent")
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
// Handle Escape via keypress
|
|
322
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
323
|
+
if (key.name === "escape") {
|
|
324
|
+
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
325
|
+
passInput.blur()
|
|
326
|
+
if (this.hasSABnzbd) {
|
|
327
|
+
this.currentStep = "sabnzbd"
|
|
328
|
+
this.renderSABnzbdPrompt()
|
|
329
|
+
} else {
|
|
330
|
+
this.currentStep = "done"
|
|
331
|
+
this.renderDone()
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private renderSABnzbdPrompt() {
|
|
339
|
+
this.clear()
|
|
340
|
+
|
|
341
|
+
const { container, content } = createPageLayout(this.cliRenderer, {
|
|
342
|
+
title: "Configure Apps",
|
|
343
|
+
stepInfo: "SABnzbd Credentials",
|
|
344
|
+
footerHint: "Enter API key from SABnzbd Config → General Esc Skip",
|
|
345
|
+
})
|
|
346
|
+
this.pageContainer = container
|
|
347
|
+
this.add(container)
|
|
348
|
+
|
|
349
|
+
content.add(
|
|
350
|
+
new TextRenderable(this.cliRenderer, {
|
|
351
|
+
content: "Enter SABnzbd API Key (from Config → General → API Key):\n",
|
|
352
|
+
fg: "#4a9eff",
|
|
353
|
+
})
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
// API key input - pre-fill with saved value
|
|
357
|
+
content.add(new TextRenderable(this.cliRenderer, { content: "API Key:", fg: "#aaaaaa" }))
|
|
358
|
+
const keyInput = new InputRenderable(this.cliRenderer, {
|
|
359
|
+
id: "sab-key-input",
|
|
360
|
+
width: 40,
|
|
361
|
+
placeholder: "SABnzbd API Key",
|
|
362
|
+
value: this.sabApiKey,
|
|
363
|
+
focusedBackgroundColor: "#1a1a1a",
|
|
364
|
+
})
|
|
365
|
+
content.add(keyInput)
|
|
366
|
+
|
|
367
|
+
keyInput.focus()
|
|
368
|
+
|
|
369
|
+
// Handle Enter via SUBMIT event
|
|
370
|
+
keyInput.on(InputRenderableEvents.ENTER, () => {
|
|
371
|
+
this.sabApiKey = keyInput.value
|
|
372
|
+
if (this.keyHandler) this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
373
|
+
keyInput.blur()
|
|
374
|
+
this.addDownloadClients("sabnzbd")
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// Handle Escape via keypress
|
|
378
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
379
|
+
if (key.name === "escape") {
|
|
380
|
+
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
381
|
+
keyInput.blur()
|
|
382
|
+
this.currentStep = "done"
|
|
383
|
+
this.renderDone()
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private async addDownloadClients(type: "qbittorrent" | "sabnzbd") {
|
|
390
|
+
// Add download client to all *arr apps
|
|
391
|
+
const servarrApps = this.config.apps.filter((a) => {
|
|
392
|
+
const def = getApp(a.id)
|
|
393
|
+
return a.enabled && def?.rootFolder
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
for (const appConfig of servarrApps) {
|
|
397
|
+
const appDef = getApp(appConfig.id)
|
|
398
|
+
if (!appDef?.rootFolder || !appDef.apiKeyMeta) continue
|
|
399
|
+
|
|
400
|
+
const apiKey = this.extractApiKey(appConfig.id)
|
|
401
|
+
if (!apiKey) continue
|
|
402
|
+
|
|
403
|
+
const port = appConfig.port || appDef.defaultPort
|
|
404
|
+
const client = new ArrApiClient("localhost", port, apiKey, appDef.rootFolder.apiVersion)
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
if (type === "qbittorrent") {
|
|
408
|
+
const config = createQBittorrentConfig(this.qbHost, this.qbPort, this.qbUser, this.qbPass, appConfig.id)
|
|
409
|
+
await client.addDownloadClient(config)
|
|
410
|
+
} else {
|
|
411
|
+
const config = createSABnzbdConfig(this.sabHost, this.sabPort, this.sabApiKey, appConfig.id)
|
|
412
|
+
await client.addDownloadClient(config)
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
// Ignore errors - client may already exist
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Save credentials to .env
|
|
420
|
+
await this.saveCredentialsToEnv(type)
|
|
421
|
+
|
|
422
|
+
// Move to next step
|
|
423
|
+
if (type === "qbittorrent" && this.hasSABnzbd) {
|
|
424
|
+
this.currentStep = "sabnzbd"
|
|
425
|
+
this.renderSABnzbdPrompt()
|
|
426
|
+
} else {
|
|
427
|
+
this.currentStep = "done"
|
|
428
|
+
this.renderDone()
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async saveCredentialsToEnv(type: "qbittorrent" | "sabnzbd") {
|
|
433
|
+
try {
|
|
434
|
+
const envPath = getComposePath().replace("docker-compose.yml", ".env")
|
|
435
|
+
|
|
436
|
+
// Read existing .env if present
|
|
437
|
+
const currentEnv: Record<string, string> = {}
|
|
438
|
+
if (existsSync(envPath)) {
|
|
439
|
+
const content = await readFile(envPath, "utf-8")
|
|
440
|
+
content.split("\n").forEach((line) => {
|
|
441
|
+
const [key, ...val] = line.split("=")
|
|
442
|
+
if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Add credentials
|
|
447
|
+
if (type === "qbittorrent" && this.qbPass) {
|
|
448
|
+
currentEnv["QBITTORRENT_PASSWORD"] = this.qbPass
|
|
449
|
+
} else if (type === "sabnzbd" && this.sabApiKey) {
|
|
450
|
+
currentEnv["SABNZBD_API_KEY"] = this.sabApiKey
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Reconstruct .env content
|
|
454
|
+
const envContent = Object.entries(currentEnv)
|
|
455
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
456
|
+
.join("\n")
|
|
457
|
+
|
|
458
|
+
await writeFile(envPath, envContent, "utf-8")
|
|
459
|
+
} catch {
|
|
460
|
+
// Ignore errors - not critical
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private renderDone() {
|
|
465
|
+
this.clear()
|
|
466
|
+
|
|
467
|
+
const { container, content } = createPageLayout(this.cliRenderer, {
|
|
468
|
+
title: "Configure Apps",
|
|
469
|
+
stepInfo: "Complete",
|
|
470
|
+
footerHint: "Press any key to return",
|
|
471
|
+
})
|
|
472
|
+
this.add(container)
|
|
473
|
+
|
|
474
|
+
const successCount = this.results.filter((r) => r.status === "success").length
|
|
475
|
+
const errorCount = this.results.filter((r) => r.status === "error").length
|
|
476
|
+
|
|
477
|
+
content.add(
|
|
478
|
+
new TextRenderable(this.cliRenderer, {
|
|
479
|
+
content: "Configuration complete!\n",
|
|
480
|
+
fg: "#50fa7b",
|
|
481
|
+
})
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
content.add(
|
|
485
|
+
new TextRenderable(this.cliRenderer, {
|
|
486
|
+
content: `✓ ${successCount} app(s) configured`,
|
|
487
|
+
fg: "#50fa7b",
|
|
488
|
+
})
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if (errorCount > 0) {
|
|
492
|
+
content.add(
|
|
493
|
+
new TextRenderable(this.cliRenderer, {
|
|
494
|
+
content: `✗ ${errorCount} app(s) had errors (see above)`,
|
|
495
|
+
fg: "#ff5555",
|
|
496
|
+
})
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
|
|
501
|
+
|
|
502
|
+
// Show results summary
|
|
503
|
+
for (const result of this.results) {
|
|
504
|
+
const icon = result.status === "success" ? "✓" : result.status === "skipped" ? "⏭" : "✗"
|
|
505
|
+
const color = result.status === "success" ? "#50fa7b" : result.status === "skipped" ? "#6272a4" : "#ff5555"
|
|
506
|
+
const message = result.message ? ` - ${result.message}` : ""
|
|
507
|
+
|
|
508
|
+
content.add(
|
|
509
|
+
new TextRenderable(this.cliRenderer, {
|
|
510
|
+
content: `${icon} ${result.appName}${message}`,
|
|
511
|
+
fg: color,
|
|
512
|
+
})
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
this.keyHandler = () => {
|
|
517
|
+
this.destroy()
|
|
518
|
+
this.onBack()
|
|
519
|
+
}
|
|
520
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private clear() {
|
|
524
|
+
// Remove all children
|
|
525
|
+
const children = [...this.getChildren()]
|
|
526
|
+
for (const child of children) {
|
|
527
|
+
if (child.id) {
|
|
528
|
+
try {
|
|
529
|
+
this.remove(child.id)
|
|
530
|
+
} catch {
|
|
531
|
+
/* ignore */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
public destroy() {
|
|
538
|
+
if (this.keyHandler) {
|
|
539
|
+
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
540
|
+
}
|
|
541
|
+
if (this.parent && this.id) {
|
|
542
|
+
try {
|
|
543
|
+
this.parent.remove(this.id)
|
|
544
|
+
} catch {
|
|
545
|
+
/* ignore */
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|