@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.
Files changed (109) hide show
  1. package/dist/index.js +29750 -0
  2. package/package.json +15 -15
  3. package/src/VersionInfo.ts +0 -12
  4. package/src/api/arr-api.ts +0 -521
  5. package/src/api/auto-setup-types.ts +0 -64
  6. package/src/api/base-api.ts +0 -216
  7. package/src/api/bazarr-api.ts +0 -297
  8. package/src/api/cloudflare-api.ts +0 -493
  9. package/src/api/custom-format-api.ts +0 -224
  10. package/src/api/grafana-api.ts +0 -230
  11. package/src/api/heimdall-api.ts +0 -141
  12. package/src/api/homarr-api.ts +0 -209
  13. package/src/api/huntarr-api.ts +0 -457
  14. package/src/api/index.ts +0 -9
  15. package/src/api/jellyfin-api.ts +0 -389
  16. package/src/api/jellyseerr-api.ts +0 -559
  17. package/src/api/maintainerr-api.ts +0 -461
  18. package/src/api/naming-config.ts +0 -101
  19. package/src/api/overseerr-api.ts +0 -392
  20. package/src/api/plex-api.ts +0 -308
  21. package/src/api/portainer-api.ts +0 -374
  22. package/src/api/profilarr-api.ts +0 -391
  23. package/src/api/prowlarr-api.ts +0 -488
  24. package/src/api/qbittorrent-api.ts +0 -300
  25. package/src/api/quality-profile-api.ts +0 -272
  26. package/src/api/tautulli-api.ts +0 -244
  27. package/src/api/uptime-kuma-api.ts +0 -343
  28. package/src/apps/categories.ts +0 -14
  29. package/src/apps/index.ts +0 -2
  30. package/src/apps/registry.ts +0 -1275
  31. package/src/compose/caddy-config.ts +0 -130
  32. package/src/compose/generator.ts +0 -287
  33. package/src/compose/index.ts +0 -7
  34. package/src/compose/templates.ts +0 -78
  35. package/src/compose/traefik-config.ts +0 -187
  36. package/src/config/bookmarks-generator.ts +0 -160
  37. package/src/config/defaults.ts +0 -37
  38. package/src/config/homepage-config.ts +0 -381
  39. package/src/config/index.ts +0 -6
  40. package/src/config/manager.ts +0 -150
  41. package/src/config/recyclarr-config.ts +0 -181
  42. package/src/config/schema.ts +0 -310
  43. package/src/config/slskd-config.ts +0 -86
  44. package/src/config/soularr-config.ts +0 -105
  45. package/src/config/trash-quality-definitions.ts +0 -213
  46. package/src/data/lidarr-custom-formats.ts +0 -127
  47. package/src/data/trash-profiles.ts +0 -333
  48. package/src/docker/client.ts +0 -312
  49. package/src/docker/index.ts +0 -18
  50. package/src/index.ts +0 -77
  51. package/src/setup/actions/arr-common.ts +0 -307
  52. package/src/setup/actions/dashboards.ts +0 -79
  53. package/src/setup/actions/download-clients.ts +0 -54
  54. package/src/setup/actions/jellyseerr.ts +0 -331
  55. package/src/setup/actions/media-servers.ts +0 -68
  56. package/src/setup/actions/monitoring.ts +0 -120
  57. package/src/setup/actions/prowlarr.ts +0 -137
  58. package/src/setup/actions/utilities.ts +0 -490
  59. package/src/setup/index.ts +0 -78
  60. package/src/setup/types.ts +0 -144
  61. package/src/structure/manager.ts +0 -129
  62. package/src/ui/App.ts +0 -111
  63. package/src/ui/components/ApplicationSelector.ts +0 -275
  64. package/src/ui/components/CredentialsForm.ts +0 -180
  65. package/src/ui/components/DownloadClientForm.ts +0 -114
  66. package/src/ui/components/FileEditor.ts +0 -91
  67. package/src/ui/components/FooterHint.ts +0 -119
  68. package/src/ui/components/PageLayout.ts +0 -160
  69. package/src/ui/components/UpdateNotification.ts +0 -102
  70. package/src/ui/index.ts +0 -7
  71. package/src/ui/screens/AdvancedSettings.ts +0 -188
  72. package/src/ui/screens/ApiKeyViewer.ts +0 -511
  73. package/src/ui/screens/AppConfigurator.ts +0 -543
  74. package/src/ui/screens/AppManager.ts +0 -284
  75. package/src/ui/screens/ContainerControl.ts +0 -581
  76. package/src/ui/screens/LogsViewer.ts +0 -478
  77. package/src/ui/screens/MainMenu.ts +0 -299
  78. package/src/ui/screens/MonitorDashboard.ts +0 -881
  79. package/src/ui/screens/SecretsEditor.ts +0 -216
  80. package/src/ui/screens/SettingsScreen.ts +0 -633
  81. package/src/ui/screens/index.ts +0 -22
  82. package/src/ui/screens/setup/BaseAppSetupScreen.ts +0 -375
  83. package/src/ui/screens/setup/CloudflaredSetup.ts +0 -460
  84. package/src/ui/screens/setup/FullAutoSetup.ts +0 -766
  85. package/src/ui/screens/setup/HomepageSetup.ts +0 -165
  86. package/src/ui/screens/setup/JellyfinSetup.ts +0 -328
  87. package/src/ui/screens/setup/JellyseerrSetup.ts +0 -242
  88. package/src/ui/screens/setup/ProwlarrSetup.ts +0 -500
  89. package/src/ui/screens/setup/QBittorrentSetup.ts +0 -221
  90. package/src/ui/screens/setup/QuickSetup.ts +0 -1169
  91. package/src/ui/screens/setup/RecyclarrSetup.ts +0 -270
  92. package/src/ui/screens/setup/TRaSHProfileSetup.ts +0 -381
  93. package/src/utils/arch.ts +0 -78
  94. package/src/utils/browser.ts +0 -27
  95. package/src/utils/categories.ts +0 -61
  96. package/src/utils/debug.ts +0 -84
  97. package/src/utils/env.ts +0 -138
  98. package/src/utils/index.ts +0 -8
  99. package/src/utils/logs.ts +0 -119
  100. package/src/utils/migrations/1765626338_rename_env_variables.ts +0 -102
  101. package/src/utils/migrations/1765707135_rename_easiarr_status.ts +0 -91
  102. package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +0 -45
  103. package/src/utils/migrations/1769515650_migrate_to_xdg.ts +0 -114
  104. package/src/utils/migrations.ts +0 -165
  105. package/src/utils/password.ts +0 -42
  106. package/src/utils/paths.ts +0 -33
  107. package/src/utils/unraid.ts +0 -103
  108. package/src/utils/update-checker.ts +0 -210
  109. 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
- }