@muhammedaksam/easiarr 0.1.4 → 0.1.6
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/package.json +1 -1
- package/src/apps/registry.ts +13 -6
- package/src/config/schema.ts +8 -2
- package/src/ui/screens/ApiKeyViewer.ts +189 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
package/src/apps/registry.ts
CHANGED
|
@@ -113,9 +113,9 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
113
113
|
dependsOn: ["sonarr", "radarr"],
|
|
114
114
|
trashGuide: "docs/Bazarr/",
|
|
115
115
|
apiKeyMeta: {
|
|
116
|
-
configFile: "config/config.
|
|
117
|
-
parser: "
|
|
118
|
-
selector: "apikey
|
|
116
|
+
configFile: "config/config.yaml",
|
|
117
|
+
parser: "yaml",
|
|
118
|
+
selector: "auth.apikey",
|
|
119
119
|
},
|
|
120
120
|
},
|
|
121
121
|
|
|
@@ -131,8 +131,15 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
131
131
|
volumes: (root) => [`${root}/config/mylar3:/config`, `${root}/data:/data`],
|
|
132
132
|
apiKeyMeta: {
|
|
133
133
|
configFile: "mylar/config.ini",
|
|
134
|
-
parser: "
|
|
135
|
-
|
|
134
|
+
parser: "ini",
|
|
135
|
+
section: "API",
|
|
136
|
+
selector: "api_key",
|
|
137
|
+
enabledKey: "api_enabled",
|
|
138
|
+
generateIfMissing: true,
|
|
139
|
+
},
|
|
140
|
+
rootFolder: {
|
|
141
|
+
path: "/data/media/comics",
|
|
142
|
+
apiVersion: "v1",
|
|
136
143
|
},
|
|
137
144
|
},
|
|
138
145
|
|
|
@@ -142,7 +149,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
142
149
|
description: "Adult media collection manager",
|
|
143
150
|
category: "servarr",
|
|
144
151
|
defaultPort: 6969,
|
|
145
|
-
image: "hotio/whisparr:nightly",
|
|
152
|
+
image: "ghcr.io/hotio/whisparr:nightly",
|
|
146
153
|
puid: 13015,
|
|
147
154
|
pgid: 13000,
|
|
148
155
|
volumes: (root) => [`${root}/config/whisparr:/config`, `${root}/data:/data`],
|
package/src/config/schema.ts
CHANGED
|
@@ -164,12 +164,18 @@ export interface RootFolderMeta {
|
|
|
164
164
|
export interface ApiKeyMeta {
|
|
165
165
|
configFile: string // Relative to config volume root
|
|
166
166
|
parser: ApiKeyParserType
|
|
167
|
-
selector: string // Regex group 1, or XML tag, or INI key,
|
|
167
|
+
selector: string // Regex group 1, or XML tag, or INI key, JSON/YAML dot path
|
|
168
|
+
/** INI section name (for parser: "ini") */
|
|
169
|
+
section?: string
|
|
170
|
+
/** INI key that controls if API is enabled (for parser: "ini") */
|
|
171
|
+
enabledKey?: string
|
|
172
|
+
/** Generate API key if missing or None (for apps like Mylar3) */
|
|
173
|
+
generateIfMissing?: boolean
|
|
168
174
|
description?: string
|
|
169
175
|
transform?: (value: string) => string
|
|
170
176
|
}
|
|
171
177
|
|
|
172
|
-
export type ApiKeyParserType = "xml" | "ini" | "json" | "regex"
|
|
178
|
+
export type ApiKeyParserType = "xml" | "ini" | "json" | "yaml" | "regex"
|
|
173
179
|
|
|
174
180
|
export interface AppSecret {
|
|
175
181
|
name: string
|
|
@@ -1,15 +1,112 @@
|
|
|
1
1
|
import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { writeFile, readFile } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
|
+
import { randomBytes } from "node:crypto"
|
|
6
|
+
import { parse as parseYaml } from "yaml"
|
|
5
7
|
import { createPageLayout } from "../components/PageLayout"
|
|
6
|
-
import { EasiarrConfig } from "../../config/schema"
|
|
8
|
+
import { EasiarrConfig, AppDefinition } from "../../config/schema"
|
|
7
9
|
import { getApp } from "../../apps/registry"
|
|
8
10
|
import { getComposePath } from "../../config/manager"
|
|
9
11
|
|
|
12
|
+
/** Generate a random 32-character hex API key */
|
|
13
|
+
function generateApiKey(): string {
|
|
14
|
+
return randomBytes(16).toString("hex")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Get nested value from object using dot notation */
|
|
18
|
+
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
19
|
+
return path.split(".").reduce<unknown>((o, k) => (o as Record<string, unknown>)?.[k], obj)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parse INI file and get value from section.key */
|
|
23
|
+
function parseIniValue(content: string, section: string, key: string): string | null {
|
|
24
|
+
const lines = content.split("\n")
|
|
25
|
+
let inSection = false
|
|
26
|
+
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
const trimmed = line.trim()
|
|
29
|
+
|
|
30
|
+
// Check section header
|
|
31
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
32
|
+
const sectionName = trimmed.slice(1, -1)
|
|
33
|
+
inSection = sectionName.toLowerCase() === section.toLowerCase()
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Parse key=value in current section
|
|
38
|
+
if (inSection && trimmed.includes("=")) {
|
|
39
|
+
const [k, ...valueParts] = trimmed.split("=")
|
|
40
|
+
if (k.trim().toLowerCase() === key.toLowerCase()) {
|
|
41
|
+
let value = valueParts.join("=").trim()
|
|
42
|
+
// Remove quotes if present
|
|
43
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
44
|
+
value = value.slice(1, -1)
|
|
45
|
+
}
|
|
46
|
+
return value
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Update INI file with new values for section */
|
|
54
|
+
function updateIniValue(content: string, section: string, updates: Record<string, string>): string {
|
|
55
|
+
const lines = content.split("\n")
|
|
56
|
+
const result: string[] = []
|
|
57
|
+
let inSection = false
|
|
58
|
+
const updatedKeys = new Set<string>()
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const trimmed = line.trim()
|
|
62
|
+
|
|
63
|
+
// Check section header
|
|
64
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
65
|
+
// Before leaving current section, add any missing keys
|
|
66
|
+
if (inSection) {
|
|
67
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
68
|
+
if (!updatedKeys.has(k.toLowerCase())) {
|
|
69
|
+
result.push(`${k} = ${v}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const sectionName = trimmed.slice(1, -1)
|
|
74
|
+
inSection = sectionName.toLowerCase() === section.toLowerCase()
|
|
75
|
+
result.push(line)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Update key=value in current section
|
|
80
|
+
if (inSection && trimmed.includes("=")) {
|
|
81
|
+
const [k] = trimmed.split("=")
|
|
82
|
+
const keyName = k.trim()
|
|
83
|
+
const keyLower = keyName.toLowerCase()
|
|
84
|
+
|
|
85
|
+
let handled = false
|
|
86
|
+
for (const [updateKey, updateValue] of Object.entries(updates)) {
|
|
87
|
+
if (updateKey.toLowerCase() === keyLower) {
|
|
88
|
+
result.push(`${keyName} = ${updateValue}`)
|
|
89
|
+
updatedKeys.add(keyLower)
|
|
90
|
+
handled = true
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!handled) {
|
|
95
|
+
result.push(line)
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
result.push(line)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return result.join("\n")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type KeyStatus = "found" | "missing" | "error" | "generated"
|
|
106
|
+
|
|
10
107
|
export class ApiKeyViewer extends BoxRenderable {
|
|
11
108
|
private config: EasiarrConfig
|
|
12
|
-
private keys: Array<{ appId: string; app: string; key: string; status:
|
|
109
|
+
private keys: Array<{ appId: string; app: string; key: string; status: KeyStatus }> = []
|
|
13
110
|
private keyHandler!: (key: KeyEvent) => void
|
|
14
111
|
private cliRenderer: CliRenderer
|
|
15
112
|
private statusText: TextRenderable | null = null
|
|
@@ -19,8 +116,8 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
19
116
|
id: "api-key-viewer",
|
|
20
117
|
width: "100%",
|
|
21
118
|
height: "100%",
|
|
22
|
-
backgroundColor: "#111111",
|
|
23
|
-
zIndex: 200,
|
|
119
|
+
backgroundColor: "#111111",
|
|
120
|
+
zIndex: 200,
|
|
24
121
|
})
|
|
25
122
|
this.cliRenderer = renderer
|
|
26
123
|
this.config = config
|
|
@@ -39,38 +136,17 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
39
136
|
if (!appDef || !appDef.apiKeyMeta) continue
|
|
40
137
|
|
|
41
138
|
try {
|
|
42
|
-
// Resolve config path
|
|
43
|
-
// Volumes are: ["${root}/config/radarr:/config", ...]
|
|
44
|
-
// We assume index 0 is the config volume
|
|
45
139
|
const volumes = appDef.volumes(this.config.rootDir)
|
|
46
140
|
if (volumes.length === 0) continue
|
|
47
141
|
|
|
48
142
|
const parts = volumes[0].split(":")
|
|
49
143
|
const hostPath = parts[0]
|
|
50
|
-
|
|
51
144
|
const configFilePath = join(hostPath, appDef.apiKeyMeta.configFile)
|
|
52
145
|
|
|
53
146
|
if (existsSync(configFilePath)) {
|
|
54
147
|
const content = readFileSync(configFilePath, "utf-8")
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
}
|
|
148
|
+
const result = this.extractApiKey(appDef, content, configFilePath)
|
|
149
|
+
this.keys.push({ appId: appDef.id, app: appDef.name, ...result })
|
|
74
150
|
} else {
|
|
75
151
|
this.keys.push({
|
|
76
152
|
appId: appDef.id,
|
|
@@ -79,14 +155,82 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
79
155
|
status: "missing",
|
|
80
156
|
})
|
|
81
157
|
}
|
|
82
|
-
} catch {
|
|
83
|
-
this.keys.push({ appId: appDef.id, app: appDef.name, key:
|
|
158
|
+
} catch (e) {
|
|
159
|
+
this.keys.push({ appId: appDef.id, app: appDef.name, key: `Error: ${e}`, status: "error" })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private extractApiKey(
|
|
165
|
+
appDef: AppDefinition,
|
|
166
|
+
content: string,
|
|
167
|
+
configFilePath: string
|
|
168
|
+
): { key: string; status: "found" | "error" | "generated" } {
|
|
169
|
+
const meta = appDef.apiKeyMeta!
|
|
170
|
+
|
|
171
|
+
switch (meta.parser) {
|
|
172
|
+
case "regex": {
|
|
173
|
+
const regex = new RegExp(meta.selector)
|
|
174
|
+
const match = regex.exec(content)
|
|
175
|
+
if (match && match[1]) {
|
|
176
|
+
return { key: match[1], status: "found" }
|
|
177
|
+
}
|
|
178
|
+
return { key: "Not found in file", status: "error" }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case "json": {
|
|
182
|
+
const json = JSON.parse(content)
|
|
183
|
+
const value = getNestedValue(json, meta.selector)
|
|
184
|
+
if (value && typeof value === "string") {
|
|
185
|
+
return { key: value, status: "found" }
|
|
186
|
+
}
|
|
187
|
+
return { key: "Key not found in JSON", status: "error" }
|
|
84
188
|
}
|
|
189
|
+
|
|
190
|
+
case "yaml": {
|
|
191
|
+
const yaml = parseYaml(content) as Record<string, unknown>
|
|
192
|
+
const value = getNestedValue(yaml, meta.selector)
|
|
193
|
+
if (value && typeof value === "string") {
|
|
194
|
+
return { key: value, status: "found" }
|
|
195
|
+
}
|
|
196
|
+
return { key: "Key not found in YAML", status: "error" }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
case "ini": {
|
|
200
|
+
const section = meta.section || "General"
|
|
201
|
+
const value = parseIniValue(content, section, meta.selector)
|
|
202
|
+
|
|
203
|
+
// Check if API is enabled and if we need to generate
|
|
204
|
+
if (meta.enabledKey) {
|
|
205
|
+
const enabled = parseIniValue(content, section, meta.enabledKey)
|
|
206
|
+
const isDisabled = !enabled || enabled.toLowerCase() === "false" || enabled === "0"
|
|
207
|
+
const needsGeneration = !value || value.toLowerCase() === "none" || value === ""
|
|
208
|
+
|
|
209
|
+
if (meta.generateIfMissing && (isDisabled || needsGeneration)) {
|
|
210
|
+
const newKey = generateApiKey()
|
|
211
|
+
const updates: Record<string, string> = { [meta.selector]: newKey }
|
|
212
|
+
if (meta.enabledKey) {
|
|
213
|
+
updates[meta.enabledKey] = "True"
|
|
214
|
+
}
|
|
215
|
+
const newContent = updateIniValue(content, section, updates)
|
|
216
|
+
writeFileSync(configFilePath, newContent, "utf-8")
|
|
217
|
+
return { key: newKey, status: "generated" }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (value && value.toLowerCase() !== "none" && value !== "") {
|
|
222
|
+
return { key: value, status: "found" }
|
|
223
|
+
}
|
|
224
|
+
return { key: "API key not configured", status: "error" }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
default:
|
|
228
|
+
return { key: `Unknown parser: ${meta.parser}`, status: "error" }
|
|
85
229
|
}
|
|
86
230
|
}
|
|
87
231
|
|
|
88
232
|
private renderPage(onBack: () => void) {
|
|
89
|
-
const foundKeys = this.keys.filter((k) => k.status === "found")
|
|
233
|
+
const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
|
|
90
234
|
const hasFoundKeys = foundKeys.length > 0
|
|
91
235
|
|
|
92
236
|
const { container, content } = createPageLayout(this.cliRenderer, {
|
|
@@ -126,19 +270,28 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
126
270
|
marginBottom: 0,
|
|
127
271
|
})
|
|
128
272
|
|
|
273
|
+
// Status color
|
|
274
|
+
let color = "#6272a4"
|
|
275
|
+
if (k.status === "found") color = "#50fa7b"
|
|
276
|
+
else if (k.status === "generated") color = "#8be9fd"
|
|
277
|
+
else if (k.status === "error") color = "#ff5555"
|
|
278
|
+
|
|
129
279
|
// App Name
|
|
130
280
|
row.add(
|
|
131
281
|
new TextRenderable(this.cliRenderer, {
|
|
132
282
|
content: k.app.padEnd(20),
|
|
133
|
-
fg:
|
|
283
|
+
fg: color,
|
|
134
284
|
})
|
|
135
285
|
)
|
|
136
286
|
|
|
137
|
-
// Key
|
|
287
|
+
// Key with status indicator
|
|
288
|
+
let keyDisplay = k.key
|
|
289
|
+
if (k.status === "generated") keyDisplay = `${k.key} (generated)`
|
|
290
|
+
|
|
138
291
|
row.add(
|
|
139
292
|
new TextRenderable(this.cliRenderer, {
|
|
140
|
-
content:
|
|
141
|
-
fg: k.status === "found" ? "#f1fa8c" : "#6272a4",
|
|
293
|
+
content: keyDisplay,
|
|
294
|
+
fg: k.status === "found" || k.status === "generated" ? "#f1fa8c" : "#6272a4",
|
|
142
295
|
})
|
|
143
296
|
)
|
|
144
297
|
content.add(row)
|
|
@@ -180,7 +333,7 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
180
333
|
}
|
|
181
334
|
|
|
182
335
|
private async saveToEnv() {
|
|
183
|
-
const foundKeys = this.keys.filter((k) => k.status === "found")
|
|
336
|
+
const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
|
|
184
337
|
if (foundKeys.length === 0) return
|
|
185
338
|
|
|
186
339
|
try {
|