@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,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
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -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
|
+
}
|