@muhammedaksam/easiarr 1.4.0 → 1.6.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/dist/index.js +29750 -0
- package/package.json +15 -15
- package/src/VersionInfo.ts +0 -12
- package/src/api/arr-api.ts +0 -521
- package/src/api/auto-setup-types.ts +0 -64
- package/src/api/base-api.ts +0 -216
- package/src/api/bazarr-api.ts +0 -297
- package/src/api/cloudflare-api.ts +0 -493
- package/src/api/custom-format-api.ts +0 -224
- package/src/api/grafana-api.ts +0 -230
- package/src/api/heimdall-api.ts +0 -141
- package/src/api/homarr-api.ts +0 -209
- package/src/api/huntarr-api.ts +0 -457
- package/src/api/index.ts +0 -9
- package/src/api/jellyfin-api.ts +0 -389
- package/src/api/jellyseerr-api.ts +0 -559
- package/src/api/maintainerr-api.ts +0 -461
- package/src/api/naming-config.ts +0 -101
- package/src/api/overseerr-api.ts +0 -392
- package/src/api/plex-api.ts +0 -308
- package/src/api/portainer-api.ts +0 -374
- package/src/api/profilarr-api.ts +0 -391
- package/src/api/prowlarr-api.ts +0 -488
- package/src/api/qbittorrent-api.ts +0 -300
- package/src/api/quality-profile-api.ts +0 -272
- package/src/api/tautulli-api.ts +0 -244
- package/src/api/uptime-kuma-api.ts +0 -343
- package/src/apps/categories.ts +0 -14
- package/src/apps/index.ts +0 -2
- package/src/apps/registry.ts +0 -1275
- package/src/compose/caddy-config.ts +0 -130
- package/src/compose/generator.ts +0 -287
- package/src/compose/index.ts +0 -7
- package/src/compose/templates.ts +0 -78
- package/src/compose/traefik-config.ts +0 -187
- package/src/config/bookmarks-generator.ts +0 -160
- package/src/config/defaults.ts +0 -37
- package/src/config/homepage-config.ts +0 -381
- package/src/config/index.ts +0 -6
- package/src/config/manager.ts +0 -150
- package/src/config/recyclarr-config.ts +0 -181
- package/src/config/schema.ts +0 -310
- package/src/config/slskd-config.ts +0 -86
- package/src/config/soularr-config.ts +0 -105
- package/src/config/trash-quality-definitions.ts +0 -213
- package/src/data/lidarr-custom-formats.ts +0 -127
- package/src/data/trash-profiles.ts +0 -333
- package/src/docker/client.ts +0 -312
- package/src/docker/index.ts +0 -18
- package/src/index.ts +0 -77
- package/src/setup/actions/arr-common.ts +0 -307
- package/src/setup/actions/dashboards.ts +0 -79
- package/src/setup/actions/download-clients.ts +0 -54
- package/src/setup/actions/jellyseerr.ts +0 -331
- package/src/setup/actions/media-servers.ts +0 -68
- package/src/setup/actions/monitoring.ts +0 -120
- package/src/setup/actions/prowlarr.ts +0 -137
- package/src/setup/actions/utilities.ts +0 -490
- package/src/setup/index.ts +0 -78
- package/src/setup/types.ts +0 -144
- package/src/structure/manager.ts +0 -129
- package/src/ui/App.ts +0 -111
- package/src/ui/components/ApplicationSelector.ts +0 -275
- package/src/ui/components/CredentialsForm.ts +0 -180
- package/src/ui/components/DownloadClientForm.ts +0 -114
- package/src/ui/components/FileEditor.ts +0 -91
- package/src/ui/components/FooterHint.ts +0 -119
- package/src/ui/components/PageLayout.ts +0 -160
- package/src/ui/components/UpdateNotification.ts +0 -102
- package/src/ui/index.ts +0 -7
- package/src/ui/screens/AdvancedSettings.ts +0 -188
- package/src/ui/screens/ApiKeyViewer.ts +0 -511
- package/src/ui/screens/AppConfigurator.ts +0 -543
- package/src/ui/screens/AppManager.ts +0 -284
- package/src/ui/screens/ContainerControl.ts +0 -581
- package/src/ui/screens/LogsViewer.ts +0 -478
- package/src/ui/screens/MainMenu.ts +0 -299
- package/src/ui/screens/MonitorDashboard.ts +0 -881
- package/src/ui/screens/SecretsEditor.ts +0 -216
- package/src/ui/screens/SettingsScreen.ts +0 -633
- package/src/ui/screens/index.ts +0 -22
- package/src/ui/screens/setup/BaseAppSetupScreen.ts +0 -375
- package/src/ui/screens/setup/CloudflaredSetup.ts +0 -460
- package/src/ui/screens/setup/FullAutoSetup.ts +0 -766
- package/src/ui/screens/setup/HomepageSetup.ts +0 -165
- package/src/ui/screens/setup/JellyfinSetup.ts +0 -328
- package/src/ui/screens/setup/JellyseerrSetup.ts +0 -242
- package/src/ui/screens/setup/ProwlarrSetup.ts +0 -500
- package/src/ui/screens/setup/QBittorrentSetup.ts +0 -221
- package/src/ui/screens/setup/QuickSetup.ts +0 -1169
- package/src/ui/screens/setup/RecyclarrSetup.ts +0 -270
- package/src/ui/screens/setup/TRaSHProfileSetup.ts +0 -381
- package/src/utils/arch.ts +0 -78
- package/src/utils/browser.ts +0 -27
- package/src/utils/categories.ts +0 -61
- package/src/utils/debug.ts +0 -84
- package/src/utils/env.ts +0 -138
- package/src/utils/index.ts +0 -8
- package/src/utils/logs.ts +0 -119
- package/src/utils/migrations/1765626338_rename_env_variables.ts +0 -102
- package/src/utils/migrations/1765707135_rename_easiarr_status.ts +0 -91
- package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +0 -45
- package/src/utils/migrations/1769515650_migrate_to_xdg.ts +0 -114
- package/src/utils/migrations.ts +0 -165
- package/src/utils/password.ts +0 -42
- package/src/utils/paths.ts +0 -33
- package/src/utils/unraid.ts +0 -103
- package/src/utils/update-checker.ts +0 -210
- package/src/utils/url-utils.ts +0 -38
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Advanced Settings Screen
|
|
3
|
-
* Edit configuration files directly
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { existsSync } from "node:fs"
|
|
7
|
-
import { readFile, writeFile } from "node:fs/promises"
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
BoxRenderable,
|
|
11
|
-
CliRenderer,
|
|
12
|
-
KeyEvent,
|
|
13
|
-
RenderContext,
|
|
14
|
-
SelectRenderable,
|
|
15
|
-
SelectRenderableEvents,
|
|
16
|
-
} from "@opentui/core"
|
|
17
|
-
|
|
18
|
-
import { getComposePath, getConfigPath } from "~/config/manager"
|
|
19
|
-
import { EasiarrConfig } from "~/config/schema"
|
|
20
|
-
import { App } from "~/ui/App"
|
|
21
|
-
import { FileEditor } from "~/ui/components/FileEditor"
|
|
22
|
-
import { createPageLayout } from "~/ui/components/PageLayout"
|
|
23
|
-
import { getEnvPath } from "~/utils/env"
|
|
24
|
-
|
|
25
|
-
export class AdvancedSettings {
|
|
26
|
-
private renderer: CliRenderer
|
|
27
|
-
private container: BoxRenderable
|
|
28
|
-
private app: App
|
|
29
|
-
private config: EasiarrConfig
|
|
30
|
-
private keyHandler: ((k: KeyEvent) => void) | null = null
|
|
31
|
-
private activeEditor: FileEditor | null = null
|
|
32
|
-
|
|
33
|
-
constructor(
|
|
34
|
-
renderer: CliRenderer | RenderContext,
|
|
35
|
-
container: BoxRenderable,
|
|
36
|
-
app: App,
|
|
37
|
-
config: EasiarrConfig
|
|
38
|
-
) {
|
|
39
|
-
this.renderer = renderer as CliRenderer
|
|
40
|
-
this.container = container
|
|
41
|
-
this.app = app
|
|
42
|
-
this.config = config
|
|
43
|
-
|
|
44
|
-
this.renderMenu()
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
private clear() {
|
|
48
|
-
if (this.keyHandler) {
|
|
49
|
-
this.renderer.keyInput.off("keypress", this.keyHandler)
|
|
50
|
-
this.keyHandler = null
|
|
51
|
-
}
|
|
52
|
-
if (this.activeEditor) {
|
|
53
|
-
this.activeEditor.destroy()
|
|
54
|
-
this.activeEditor = null
|
|
55
|
-
}
|
|
56
|
-
const children = this.container.getChildren()
|
|
57
|
-
for (const child of children) {
|
|
58
|
-
this.container.remove(child.id)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
private renderMenu(): void {
|
|
63
|
-
this.clear()
|
|
64
|
-
|
|
65
|
-
const { container: page, content } = createPageLayout(this.renderer, {
|
|
66
|
-
title: "Advanced Settings",
|
|
67
|
-
stepInfo: "Direct File Editing",
|
|
68
|
-
footerHint: [
|
|
69
|
-
{ type: "key", key: "Enter", value: "Select" },
|
|
70
|
-
{ type: "key", key: "Esc", value: "Back" },
|
|
71
|
-
],
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
const menu = new SelectRenderable(this.renderer, {
|
|
75
|
-
id: "advanced-menu",
|
|
76
|
-
width: "100%",
|
|
77
|
-
flexGrow: 1,
|
|
78
|
-
options: [
|
|
79
|
-
{
|
|
80
|
-
name: "📄 Edit config.json",
|
|
81
|
-
description: "Raw application configuration",
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
name: "🔑 Edit .env Secrets",
|
|
85
|
-
description: "Environment variables and secrets",
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
name: "🐳 View docker-compose.yml",
|
|
89
|
-
description: "Generated Docker composition (Read-only recommended)",
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
name: "◀ Back",
|
|
93
|
-
description: "Return to Main Menu",
|
|
94
|
-
},
|
|
95
|
-
],
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
|
|
99
|
-
switch (index) {
|
|
100
|
-
case 0:
|
|
101
|
-
await this.editFile("config.json", getConfigPath(), async (content) => {
|
|
102
|
-
// Validate JSON?
|
|
103
|
-
try {
|
|
104
|
-
const newConfig = JSON.parse(content)
|
|
105
|
-
await writeFile(getConfigPath(), content, "utf-8")
|
|
106
|
-
this.app.saveAndReload(newConfig)
|
|
107
|
-
} catch {
|
|
108
|
-
// Show error? For now just log/ignore or loop
|
|
109
|
-
// Ideally show an error dialog.
|
|
110
|
-
}
|
|
111
|
-
})
|
|
112
|
-
break
|
|
113
|
-
case 1: {
|
|
114
|
-
const envPath = getEnvPath()
|
|
115
|
-
await this.editFile(".env", envPath, async (content) => {
|
|
116
|
-
await writeFile(envPath, content, "utf-8")
|
|
117
|
-
this.renderMenu()
|
|
118
|
-
})
|
|
119
|
-
break
|
|
120
|
-
}
|
|
121
|
-
case 2:
|
|
122
|
-
await this.editFile("docker-compose.yml", getComposePath(), async (content) => {
|
|
123
|
-
await writeFile(getComposePath(), content, "utf-8")
|
|
124
|
-
this.renderMenu()
|
|
125
|
-
})
|
|
126
|
-
break
|
|
127
|
-
case 3:
|
|
128
|
-
this.app.navigateTo("main")
|
|
129
|
-
break
|
|
130
|
-
}
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
content.add(menu)
|
|
134
|
-
menu.focus()
|
|
135
|
-
|
|
136
|
-
this.keyHandler = (k: KeyEvent) => {
|
|
137
|
-
if (k.name === "escape") {
|
|
138
|
-
this.app.navigateTo("main")
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
this.renderer.keyInput.on("keypress", this.keyHandler)
|
|
142
|
-
|
|
143
|
-
this.container.add(page)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private async editFile(name: string, path: string, onSave: (content: string) => Promise<void>) {
|
|
147
|
-
this.clear()
|
|
148
|
-
|
|
149
|
-
let initialContent = ""
|
|
150
|
-
try {
|
|
151
|
-
if (existsSync(path)) {
|
|
152
|
-
initialContent = await readFile(path, "utf-8")
|
|
153
|
-
}
|
|
154
|
-
} catch {
|
|
155
|
-
initialContent = "// Failed to read file or file does not exist"
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const editor = new FileEditor(this.renderer, {
|
|
159
|
-
id: `editor-${name}`,
|
|
160
|
-
width: "100%",
|
|
161
|
-
height: "100%",
|
|
162
|
-
filename: name,
|
|
163
|
-
initialContent,
|
|
164
|
-
onSave: async (content) => {
|
|
165
|
-
await onSave(content)
|
|
166
|
-
// onSave callback might navigate away (reload), but if not:
|
|
167
|
-
if (name !== "config.json") {
|
|
168
|
-
this.renderMenu()
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
onCancel: () => {
|
|
172
|
-
this.renderMenu()
|
|
173
|
-
},
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
// FileEditor handles its own keys?
|
|
177
|
-
// We cleared our keyHandler in this.clear().
|
|
178
|
-
// FileEditor constructor attaches listeners?
|
|
179
|
-
// Check FileEditor implementation again:
|
|
180
|
-
// It attaches to this.textarea.on("keypress").
|
|
181
|
-
// If that works, fine.
|
|
182
|
-
|
|
183
|
-
// Wait, FileEditor focus?
|
|
184
|
-
this.container.add(editor)
|
|
185
|
-
this.activeEditor = editor
|
|
186
|
-
setTimeout(() => editor.focus(), 10)
|
|
187
|
-
}
|
|
188
|
-
}
|
|
@@ -1,511 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto"
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
-
import { join } from "node:path"
|
|
4
|
-
|
|
5
|
-
import { BoxRenderable, CliRenderer, KeyEvent, TextRenderable } from "@opentui/core"
|
|
6
|
-
import { parse as parseYaml } from "yaml"
|
|
7
|
-
|
|
8
|
-
import { PortainerApiClient } from "~/api/portainer-api"
|
|
9
|
-
import { getApp } from "~/apps/registry"
|
|
10
|
-
import { AppDefinition, EasiarrConfig } from "~/config/schema"
|
|
11
|
-
import { createPageLayout } from "~/ui/components/PageLayout"
|
|
12
|
-
import { readEnvSync, updateEnv } from "~/utils/env"
|
|
13
|
-
|
|
14
|
-
/** Generate a random 32-character hex API key */
|
|
15
|
-
function generateApiKey(): string {
|
|
16
|
-
return randomBytes(16).toString("hex")
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Get nested value from object using dot notation */
|
|
20
|
-
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
21
|
-
return path.split(".").reduce<unknown>((o, k) => (o as Record<string, unknown>)?.[k], obj)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Parse INI file and get value from section.key */
|
|
25
|
-
function parseIniValue(content: string, section: string, key: string): string | null {
|
|
26
|
-
const lines = content.split("\n")
|
|
27
|
-
let inSection = false
|
|
28
|
-
|
|
29
|
-
for (const line of lines) {
|
|
30
|
-
const trimmed = line.trim()
|
|
31
|
-
|
|
32
|
-
// Check section header
|
|
33
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
34
|
-
const sectionName = trimmed.slice(1, -1)
|
|
35
|
-
inSection = sectionName.toLowerCase() === section.toLowerCase()
|
|
36
|
-
continue
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Parse key=value in current section
|
|
40
|
-
if (inSection && trimmed.includes("=")) {
|
|
41
|
-
const [k, ...valueParts] = trimmed.split("=")
|
|
42
|
-
if (k.trim().toLowerCase() === key.toLowerCase()) {
|
|
43
|
-
let value = valueParts.join("=").trim()
|
|
44
|
-
// Remove quotes if present
|
|
45
|
-
if (
|
|
46
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
47
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
48
|
-
) {
|
|
49
|
-
value = value.slice(1, -1)
|
|
50
|
-
}
|
|
51
|
-
return value
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return null
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Update INI file with new values for section */
|
|
59
|
-
function updateIniValue(content: string, section: string, updates: Record<string, string>): string {
|
|
60
|
-
const lines = content.split("\n")
|
|
61
|
-
const result: string[] = []
|
|
62
|
-
let inSection = false
|
|
63
|
-
const updatedKeys = new Set<string>()
|
|
64
|
-
|
|
65
|
-
for (const line of lines) {
|
|
66
|
-
const trimmed = line.trim()
|
|
67
|
-
|
|
68
|
-
// Check section header
|
|
69
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
70
|
-
// Before leaving current section, add any missing keys
|
|
71
|
-
if (inSection) {
|
|
72
|
-
for (const [k, v] of Object.entries(updates)) {
|
|
73
|
-
if (!updatedKeys.has(k.toLowerCase())) {
|
|
74
|
-
result.push(`${k} = ${v}`)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const sectionName = trimmed.slice(1, -1)
|
|
79
|
-
inSection = sectionName.toLowerCase() === section.toLowerCase()
|
|
80
|
-
result.push(line)
|
|
81
|
-
continue
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Update key=value in current section
|
|
85
|
-
if (inSection && trimmed.includes("=")) {
|
|
86
|
-
const [k] = trimmed.split("=")
|
|
87
|
-
const keyName = k.trim()
|
|
88
|
-
const keyLower = keyName.toLowerCase()
|
|
89
|
-
|
|
90
|
-
let handled = false
|
|
91
|
-
for (const [updateKey, updateValue] of Object.entries(updates)) {
|
|
92
|
-
if (updateKey.toLowerCase() === keyLower) {
|
|
93
|
-
result.push(`${keyName} = ${updateValue}`)
|
|
94
|
-
updatedKeys.add(keyLower)
|
|
95
|
-
handled = true
|
|
96
|
-
break
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (!handled) {
|
|
100
|
-
result.push(line)
|
|
101
|
-
}
|
|
102
|
-
} else {
|
|
103
|
-
result.push(line)
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return result.join("\n")
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
type KeyStatus = "found" | "missing" | "error" | "generated"
|
|
111
|
-
|
|
112
|
-
interface PortainerCredentials {
|
|
113
|
-
apiKey: string
|
|
114
|
-
password?: string // Only set if padded (different from global)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export class ApiKeyViewer extends BoxRenderable {
|
|
118
|
-
private config: EasiarrConfig
|
|
119
|
-
private keys: Array<{ appId: string; app: string; key: string; status: KeyStatus }> = []
|
|
120
|
-
private keyHandler!: (key: KeyEvent) => void
|
|
121
|
-
private cliRenderer: CliRenderer
|
|
122
|
-
private statusText: TextRenderable | null = null
|
|
123
|
-
private portainerCredentials: PortainerCredentials | null = null
|
|
124
|
-
|
|
125
|
-
constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
|
|
126
|
-
super(renderer, {
|
|
127
|
-
id: "api-key-viewer",
|
|
128
|
-
width: "100%",
|
|
129
|
-
height: "100%",
|
|
130
|
-
backgroundColor: "#111111",
|
|
131
|
-
zIndex: 200,
|
|
132
|
-
})
|
|
133
|
-
this.cliRenderer = renderer
|
|
134
|
-
this.config = config
|
|
135
|
-
|
|
136
|
-
this.scanKeys()
|
|
137
|
-
this.renderPage(onBack)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private scanKeys() {
|
|
141
|
-
this.keys = []
|
|
142
|
-
|
|
143
|
-
for (const appConfig of this.config.apps) {
|
|
144
|
-
if (!appConfig.enabled) continue
|
|
145
|
-
|
|
146
|
-
// Handle Portainer separately (uses API, not config file)
|
|
147
|
-
if (appConfig.id === "portainer") {
|
|
148
|
-
this.scanPortainer(appConfig.port || 9000)
|
|
149
|
-
continue
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const appDef = getApp(appConfig.id)
|
|
153
|
-
if (!appDef || !appDef.apiKeyMeta) continue
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
const volumes = appDef.volumes(this.config.rootDir)
|
|
157
|
-
if (volumes.length === 0) continue
|
|
158
|
-
|
|
159
|
-
const parts = volumes[0].split(":")
|
|
160
|
-
const hostPath = parts[0]
|
|
161
|
-
const configFilePath = join(hostPath, appDef.apiKeyMeta.configFile)
|
|
162
|
-
|
|
163
|
-
if (existsSync(configFilePath)) {
|
|
164
|
-
const content = readFileSync(configFilePath, "utf-8")
|
|
165
|
-
const result = this.extractApiKey(appDef, content, configFilePath)
|
|
166
|
-
this.keys.push({ appId: appDef.id, app: appDef.name, ...result })
|
|
167
|
-
} else {
|
|
168
|
-
this.keys.push({
|
|
169
|
-
appId: appDef.id,
|
|
170
|
-
app: appDef.name,
|
|
171
|
-
key: "Config file not found (Run app first)",
|
|
172
|
-
status: "missing",
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
} catch (e) {
|
|
176
|
-
this.keys.push({ appId: appDef.id, app: appDef.name, key: `Error: ${e}`, status: "error" })
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private scanPortainer(_port: number) {
|
|
182
|
-
const env = readEnvSync()
|
|
183
|
-
const existingApiKey = env["API_KEY_PORTAINER"]
|
|
184
|
-
|
|
185
|
-
if (existingApiKey) {
|
|
186
|
-
this.keys.push({
|
|
187
|
-
appId: "portainer",
|
|
188
|
-
app: "Portainer",
|
|
189
|
-
key: existingApiKey,
|
|
190
|
-
status: "found",
|
|
191
|
-
})
|
|
192
|
-
return
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Will attempt to initialize/login when saving
|
|
196
|
-
const globalPassword = env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
|
|
197
|
-
if (!globalPassword) {
|
|
198
|
-
this.keys.push({
|
|
199
|
-
appId: "portainer",
|
|
200
|
-
app: "Portainer",
|
|
201
|
-
key: "No PASSWORD_GLOBAL set in .env",
|
|
202
|
-
status: "missing",
|
|
203
|
-
})
|
|
204
|
-
return
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Add pending entry - actual API call happens on save
|
|
208
|
-
this.keys.push({
|
|
209
|
-
appId: "portainer",
|
|
210
|
-
app: "Portainer",
|
|
211
|
-
key: "Press S to generate API key",
|
|
212
|
-
status: "missing",
|
|
213
|
-
})
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private extractApiKey(
|
|
217
|
-
appDef: AppDefinition,
|
|
218
|
-
content: string,
|
|
219
|
-
configFilePath: string
|
|
220
|
-
): { key: string; status: "found" | "error" | "generated" } {
|
|
221
|
-
const meta = appDef.apiKeyMeta!
|
|
222
|
-
|
|
223
|
-
switch (meta.parser) {
|
|
224
|
-
case "regex": {
|
|
225
|
-
const regex = new RegExp(meta.selector)
|
|
226
|
-
const match = regex.exec(content)
|
|
227
|
-
if (match && match[1]) {
|
|
228
|
-
return { key: match[1], status: "found" }
|
|
229
|
-
}
|
|
230
|
-
return { key: "Not found in file", status: "error" }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
case "json": {
|
|
234
|
-
const json = JSON.parse(content)
|
|
235
|
-
const value = getNestedValue(json, meta.selector)
|
|
236
|
-
if (value && typeof value === "string") {
|
|
237
|
-
return { key: value, status: "found" }
|
|
238
|
-
}
|
|
239
|
-
return { key: "Key not found in JSON", status: "error" }
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
case "yaml": {
|
|
243
|
-
const yaml = parseYaml(content) as Record<string, unknown>
|
|
244
|
-
const value = getNestedValue(yaml, meta.selector)
|
|
245
|
-
if (value && typeof value === "string") {
|
|
246
|
-
return { key: value, status: "found" }
|
|
247
|
-
}
|
|
248
|
-
return { key: "Key not found in YAML", status: "error" }
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
case "ini": {
|
|
252
|
-
const section = meta.section || "General"
|
|
253
|
-
const value = parseIniValue(content, section, meta.selector)
|
|
254
|
-
|
|
255
|
-
// Check if API is enabled and if we need to generate
|
|
256
|
-
if (meta.enabledKey) {
|
|
257
|
-
const enabled = parseIniValue(content, section, meta.enabledKey)
|
|
258
|
-
const isDisabled = !enabled || enabled.toLowerCase() === "false" || enabled === "0"
|
|
259
|
-
const needsGeneration = !value || value.toLowerCase() === "none" || value === ""
|
|
260
|
-
|
|
261
|
-
if (meta.generateIfMissing && (isDisabled || needsGeneration)) {
|
|
262
|
-
const newKey = generateApiKey()
|
|
263
|
-
const updates: Record<string, string> = { [meta.selector]: newKey }
|
|
264
|
-
if (meta.enabledKey) {
|
|
265
|
-
updates[meta.enabledKey] = "True"
|
|
266
|
-
}
|
|
267
|
-
const newContent = updateIniValue(content, section, updates)
|
|
268
|
-
writeFileSync(configFilePath, newContent, "utf-8")
|
|
269
|
-
return { key: newKey, status: "generated" }
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (value && value.toLowerCase() !== "none" && value !== "") {
|
|
274
|
-
return { key: value, status: "found" }
|
|
275
|
-
}
|
|
276
|
-
return { key: "API key not configured", status: "error" }
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
default:
|
|
280
|
-
return { key: `Unknown parser: ${meta.parser}`, status: "error" }
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
private renderPage(onBack: () => void) {
|
|
285
|
-
const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
|
|
286
|
-
const hasFoundKeys = foundKeys.length > 0
|
|
287
|
-
|
|
288
|
-
const { container, content } = createPageLayout(this.cliRenderer, {
|
|
289
|
-
title: "API Key Extractor",
|
|
290
|
-
stepInfo: "Found Keys",
|
|
291
|
-
footerHint: hasFoundKeys
|
|
292
|
-
? [
|
|
293
|
-
{ type: "key", key: "S", value: "Save to .env" },
|
|
294
|
-
{ type: "key", key: "Esc/Enter", value: "Return" },
|
|
295
|
-
]
|
|
296
|
-
: [{ type: "key", key: "Esc/Enter", value: "Return" }],
|
|
297
|
-
})
|
|
298
|
-
this.add(container)
|
|
299
|
-
|
|
300
|
-
if (this.keys.length === 0) {
|
|
301
|
-
content.add(
|
|
302
|
-
new TextRenderable(this.cliRenderer, {
|
|
303
|
-
content: "No enabled apps have extractable API keys.",
|
|
304
|
-
fg: "#aaaaaa",
|
|
305
|
-
})
|
|
306
|
-
)
|
|
307
|
-
} else {
|
|
308
|
-
// Header
|
|
309
|
-
const header = new BoxRenderable(this.cliRenderer, {
|
|
310
|
-
width: "100%",
|
|
311
|
-
height: 1,
|
|
312
|
-
flexDirection: "row",
|
|
313
|
-
marginBottom: 1,
|
|
314
|
-
})
|
|
315
|
-
header.add(
|
|
316
|
-
new TextRenderable(this.cliRenderer, {
|
|
317
|
-
content: "Application".padEnd(20),
|
|
318
|
-
fg: "#ffffff",
|
|
319
|
-
attributes: 1,
|
|
320
|
-
})
|
|
321
|
-
)
|
|
322
|
-
header.add(
|
|
323
|
-
new TextRenderable(this.cliRenderer, { content: "API Key", fg: "#ffffff", attributes: 1 })
|
|
324
|
-
)
|
|
325
|
-
content.add(header)
|
|
326
|
-
|
|
327
|
-
// Rows
|
|
328
|
-
this.keys.forEach((k) => {
|
|
329
|
-
const row = new BoxRenderable(this.cliRenderer, {
|
|
330
|
-
width: "100%",
|
|
331
|
-
height: 1,
|
|
332
|
-
flexDirection: "row",
|
|
333
|
-
marginBottom: 0,
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
// Status color
|
|
337
|
-
let color = "#6272a4"
|
|
338
|
-
if (k.status === "found") color = "#50fa7b"
|
|
339
|
-
else if (k.status === "generated") color = "#8be9fd"
|
|
340
|
-
else if (k.status === "error") color = "#ff5555"
|
|
341
|
-
|
|
342
|
-
// App Name
|
|
343
|
-
row.add(
|
|
344
|
-
new TextRenderable(this.cliRenderer, {
|
|
345
|
-
content: k.app.padEnd(20),
|
|
346
|
-
fg: color,
|
|
347
|
-
})
|
|
348
|
-
)
|
|
349
|
-
|
|
350
|
-
// Key with status indicator
|
|
351
|
-
let keyDisplay = k.key
|
|
352
|
-
if (k.status === "generated") keyDisplay = `${k.key} (generated)`
|
|
353
|
-
|
|
354
|
-
row.add(
|
|
355
|
-
new TextRenderable(this.cliRenderer, {
|
|
356
|
-
content: keyDisplay,
|
|
357
|
-
fg: k.status === "found" || k.status === "generated" ? "#f1fa8c" : "#6272a4",
|
|
358
|
-
})
|
|
359
|
-
)
|
|
360
|
-
content.add(row)
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
-
// Status text for feedback
|
|
364
|
-
content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
|
|
365
|
-
this.statusText = new TextRenderable(this.cliRenderer, {
|
|
366
|
-
id: "api-key-status",
|
|
367
|
-
content: "",
|
|
368
|
-
fg: "#50fa7b",
|
|
369
|
-
})
|
|
370
|
-
content.add(this.statusText)
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Key Handler
|
|
374
|
-
this.keyHandler = (key: KeyEvent) => {
|
|
375
|
-
if (key.name === "escape" || key.name === "enter") {
|
|
376
|
-
this.destroy()
|
|
377
|
-
onBack()
|
|
378
|
-
} else if (key.name === "s" && hasFoundKeys) {
|
|
379
|
-
this.saveToEnv()
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
public destroy() {
|
|
386
|
-
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
387
|
-
if (this.parent) {
|
|
388
|
-
if (this.id) {
|
|
389
|
-
try {
|
|
390
|
-
this.parent.remove(this.id)
|
|
391
|
-
} catch {
|
|
392
|
-
/* ignore */
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
private async saveToEnv() {
|
|
399
|
-
const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
|
|
400
|
-
|
|
401
|
-
try {
|
|
402
|
-
// Build updates object with API keys
|
|
403
|
-
const updates: Record<string, string> = {}
|
|
404
|
-
|
|
405
|
-
for (const k of foundKeys) {
|
|
406
|
-
if (k.appId !== "portainer") {
|
|
407
|
-
updates[`API_KEY_${k.appId.toUpperCase()}`] = k.key
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Handle Portainer separately - need to call API
|
|
412
|
-
const portainerEntry = this.keys.find((k) => k.appId === "portainer")
|
|
413
|
-
if (portainerEntry && portainerEntry.status === "missing") {
|
|
414
|
-
await this.initializePortainer(updates)
|
|
415
|
-
} else if (portainerEntry && portainerEntry.status === "found") {
|
|
416
|
-
updates["API_KEY_PORTAINER"] = portainerEntry.key
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Save Portainer credentials if we have them
|
|
420
|
-
if (this.portainerCredentials) {
|
|
421
|
-
updates["API_KEY_PORTAINER"] = this.portainerCredentials.apiKey
|
|
422
|
-
if (this.portainerCredentials.password) {
|
|
423
|
-
updates["PASSWORD_PORTAINER"] = this.portainerCredentials.password
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (Object.keys(updates).length === 0) {
|
|
428
|
-
if (this.statusText) {
|
|
429
|
-
this.statusText.content = "No keys to save"
|
|
430
|
-
this.statusText.fg = "#f1fa8c"
|
|
431
|
-
}
|
|
432
|
-
return
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
await updateEnv(updates)
|
|
436
|
-
|
|
437
|
-
// Update status
|
|
438
|
-
if (this.statusText) {
|
|
439
|
-
const count = Object.keys(updates).length
|
|
440
|
-
this.statusText.content = `✓ Saved ${count} key(s) to .env`
|
|
441
|
-
}
|
|
442
|
-
} catch (e) {
|
|
443
|
-
if (this.statusText) {
|
|
444
|
-
this.statusText.content = `✗ Error saving to .env: ${e}`
|
|
445
|
-
this.statusText.fg = "#ff5555"
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
private async initializePortainer(_updates: Record<string, string>) {
|
|
451
|
-
const env = readEnvSync()
|
|
452
|
-
const globalUsername = env["USERNAME_GLOBAL"] || "admin"
|
|
453
|
-
const globalPassword = env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
|
|
454
|
-
|
|
455
|
-
if (!globalPassword) return
|
|
456
|
-
|
|
457
|
-
const portainerConfig = this.config.apps.find((a) => a.id === "portainer" && a.enabled)
|
|
458
|
-
if (!portainerConfig) return
|
|
459
|
-
|
|
460
|
-
const port = portainerConfig.port || 9000
|
|
461
|
-
const client = new PortainerApiClient("localhost", port)
|
|
462
|
-
|
|
463
|
-
try {
|
|
464
|
-
// Check if reachable
|
|
465
|
-
const healthy = await client.isHealthy()
|
|
466
|
-
if (!healthy) {
|
|
467
|
-
if (this.statusText) {
|
|
468
|
-
this.statusText.content = "Portainer not reachable"
|
|
469
|
-
this.statusText.fg = "#f1fa8c"
|
|
470
|
-
}
|
|
471
|
-
return
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Try to initialize or login
|
|
475
|
-
const result = await client.initializeAdmin(globalUsername, globalPassword)
|
|
476
|
-
|
|
477
|
-
if (result) {
|
|
478
|
-
// New initialization
|
|
479
|
-
const apiKey = await client.generateApiKey(result.actualPassword, "easiarr-api-key")
|
|
480
|
-
this.portainerCredentials = {
|
|
481
|
-
apiKey,
|
|
482
|
-
password: result.passwordWasPadded ? result.actualPassword : undefined,
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Update the display
|
|
486
|
-
const portainerEntry = this.keys.find((k) => k.appId === "portainer")
|
|
487
|
-
if (portainerEntry) {
|
|
488
|
-
portainerEntry.key = apiKey
|
|
489
|
-
portainerEntry.status = "generated"
|
|
490
|
-
}
|
|
491
|
-
} else {
|
|
492
|
-
// Already initialized, try login with saved password if available
|
|
493
|
-
const portainerPassword = env["PASSWORD_PORTAINER"] || globalPassword
|
|
494
|
-
await client.login(globalUsername, portainerPassword)
|
|
495
|
-
const apiKey = await client.generateApiKey(portainerPassword, "easiarr-api-key")
|
|
496
|
-
this.portainerCredentials = { apiKey }
|
|
497
|
-
|
|
498
|
-
const portainerEntry = this.keys.find((k) => k.appId === "portainer")
|
|
499
|
-
if (portainerEntry) {
|
|
500
|
-
portainerEntry.key = apiKey
|
|
501
|
-
portainerEntry.status = "generated"
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
} catch (e) {
|
|
505
|
-
if (this.statusText) {
|
|
506
|
-
this.statusText.content = `Portainer error: ${e}`
|
|
507
|
-
this.statusText.fg = "#ff5555"
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|