@muhammedaksam/easiarr 0.4.3 → 0.5.1

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.
@@ -6,7 +6,8 @@ import { parse as parseYaml } from "yaml"
6
6
  import { createPageLayout } from "../components/PageLayout"
7
7
  import { EasiarrConfig, AppDefinition } from "../../config/schema"
8
8
  import { getApp } from "../../apps/registry"
9
- import { updateEnv } from "../../utils/env"
9
+ import { updateEnv, readEnvSync } from "../../utils/env"
10
+ import { PortainerApiClient } from "../../api/portainer-api"
10
11
 
11
12
  /** Generate a random 32-character hex API key */
12
13
  function generateApiKey(): string {
@@ -103,12 +104,18 @@ function updateIniValue(content: string, section: string, updates: Record<string
103
104
 
104
105
  type KeyStatus = "found" | "missing" | "error" | "generated"
105
106
 
107
+ interface PortainerCredentials {
108
+ apiKey: string
109
+ password?: string // Only set if padded (different from global)
110
+ }
111
+
106
112
  export class ApiKeyViewer extends BoxRenderable {
107
113
  private config: EasiarrConfig
108
114
  private keys: Array<{ appId: string; app: string; key: string; status: KeyStatus }> = []
109
115
  private keyHandler!: (key: KeyEvent) => void
110
116
  private cliRenderer: CliRenderer
111
117
  private statusText: TextRenderable | null = null
118
+ private portainerCredentials: PortainerCredentials | null = null
112
119
 
113
120
  constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
114
121
  super(renderer, {
@@ -131,6 +138,12 @@ export class ApiKeyViewer extends BoxRenderable {
131
138
  for (const appConfig of this.config.apps) {
132
139
  if (!appConfig.enabled) continue
133
140
 
141
+ // Handle Portainer separately (uses API, not config file)
142
+ if (appConfig.id === "portainer") {
143
+ this.scanPortainer(appConfig.port || 9000)
144
+ continue
145
+ }
146
+
134
147
  const appDef = getApp(appConfig.id)
135
148
  if (!appDef || !appDef.apiKeyMeta) continue
136
149
 
@@ -160,6 +173,41 @@ export class ApiKeyViewer extends BoxRenderable {
160
173
  }
161
174
  }
162
175
 
176
+ private scanPortainer(_port: number) {
177
+ const env = readEnvSync()
178
+ const existingApiKey = env["API_KEY_PORTAINER"]
179
+
180
+ if (existingApiKey) {
181
+ this.keys.push({
182
+ appId: "portainer",
183
+ app: "Portainer",
184
+ key: existingApiKey,
185
+ status: "found",
186
+ })
187
+ return
188
+ }
189
+
190
+ // Will attempt to initialize/login when saving
191
+ const globalPassword = env["GLOBAL_PASSWORD"]
192
+ if (!globalPassword) {
193
+ this.keys.push({
194
+ appId: "portainer",
195
+ app: "Portainer",
196
+ key: "No GLOBAL_PASSWORD set in .env",
197
+ status: "missing",
198
+ })
199
+ return
200
+ }
201
+
202
+ // Add pending entry - actual API call happens on save
203
+ this.keys.push({
204
+ appId: "portainer",
205
+ app: "Portainer",
206
+ key: "Press S to generate API key",
207
+ status: "missing",
208
+ })
209
+ }
210
+
163
211
  private extractApiKey(
164
212
  appDef: AppDefinition,
165
213
  content: string,
@@ -235,7 +283,12 @@ export class ApiKeyViewer extends BoxRenderable {
235
283
  const { container, content } = createPageLayout(this.cliRenderer, {
236
284
  title: "API Key Extractor",
237
285
  stepInfo: "Found Keys",
238
- footerHint: hasFoundKeys ? "S Save to .env Esc/Enter Return" : "Esc/Enter: Return",
286
+ footerHint: hasFoundKeys
287
+ ? [
288
+ { type: "key", key: "S", value: "Save to .env" },
289
+ { type: "key", key: "Esc/Enter", value: "Return" },
290
+ ]
291
+ : [{ type: "key", key: "Esc/Enter", value: "Return" }],
239
292
  })
240
293
  this.add(container)
241
294
 
@@ -333,20 +386,47 @@ export class ApiKeyViewer extends BoxRenderable {
333
386
 
334
387
  private async saveToEnv() {
335
388
  const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
336
- if (foundKeys.length === 0) return
337
389
 
338
390
  try {
339
391
  // Build updates object with API keys
340
392
  const updates: Record<string, string> = {}
393
+
341
394
  for (const k of foundKeys) {
342
- updates[`API_KEY_${k.appId.toUpperCase()}`] = k.key
395
+ if (k.appId !== "portainer") {
396
+ updates[`API_KEY_${k.appId.toUpperCase()}`] = k.key
397
+ }
398
+ }
399
+
400
+ // Handle Portainer separately - need to call API
401
+ const portainerEntry = this.keys.find((k) => k.appId === "portainer")
402
+ if (portainerEntry && portainerEntry.status === "missing") {
403
+ await this.initializePortainer(updates)
404
+ } else if (portainerEntry && portainerEntry.status === "found") {
405
+ updates["API_KEY_PORTAINER"] = portainerEntry.key
406
+ }
407
+
408
+ // Save Portainer credentials if we have them
409
+ if (this.portainerCredentials) {
410
+ updates["API_KEY_PORTAINER"] = this.portainerCredentials.apiKey
411
+ if (this.portainerCredentials.password) {
412
+ updates["PORTAINER_PASSWORD"] = this.portainerCredentials.password
413
+ }
414
+ }
415
+
416
+ if (Object.keys(updates).length === 0) {
417
+ if (this.statusText) {
418
+ this.statusText.content = "No keys to save"
419
+ this.statusText.fg = "#f1fa8c"
420
+ }
421
+ return
343
422
  }
344
423
 
345
424
  await updateEnv(updates)
346
425
 
347
426
  // Update status
348
427
  if (this.statusText) {
349
- this.statusText.content = `✓ Saved ${foundKeys.length} API key(s) to .env`
428
+ const count = Object.keys(updates).length
429
+ this.statusText.content = `✓ Saved ${count} key(s) to .env`
350
430
  }
351
431
  } catch (e) {
352
432
  if (this.statusText) {
@@ -355,4 +435,65 @@ export class ApiKeyViewer extends BoxRenderable {
355
435
  }
356
436
  }
357
437
  }
438
+
439
+ private async initializePortainer(_updates: Record<string, string>) {
440
+ const env = readEnvSync()
441
+ const globalUsername = env["GLOBAL_USERNAME"] || "admin"
442
+ const globalPassword = env["GLOBAL_PASSWORD"]
443
+
444
+ if (!globalPassword) return
445
+
446
+ const portainerConfig = this.config.apps.find((a) => a.id === "portainer" && a.enabled)
447
+ if (!portainerConfig) return
448
+
449
+ const port = portainerConfig.port || 9000
450
+ const client = new PortainerApiClient("localhost", port)
451
+
452
+ try {
453
+ // Check if reachable
454
+ const healthy = await client.isHealthy()
455
+ if (!healthy) {
456
+ if (this.statusText) {
457
+ this.statusText.content = "Portainer not reachable"
458
+ this.statusText.fg = "#f1fa8c"
459
+ }
460
+ return
461
+ }
462
+
463
+ // Try to initialize or login
464
+ const result = await client.initializeAdmin(globalUsername, globalPassword)
465
+
466
+ if (result) {
467
+ // New initialization
468
+ const apiKey = await client.generateApiKey(result.actualPassword, "easiarr-api-key")
469
+ this.portainerCredentials = {
470
+ apiKey,
471
+ password: result.passwordWasPadded ? result.actualPassword : undefined,
472
+ }
473
+
474
+ // Update the display
475
+ const portainerEntry = this.keys.find((k) => k.appId === "portainer")
476
+ if (portainerEntry) {
477
+ portainerEntry.key = apiKey
478
+ portainerEntry.status = "generated"
479
+ }
480
+ } else {
481
+ // Already initialized, try login
482
+ await client.login(globalUsername, globalPassword)
483
+ const apiKey = await client.generateApiKey(globalPassword, "easiarr-api-key")
484
+ this.portainerCredentials = { apiKey }
485
+
486
+ const portainerEntry = this.keys.find((k) => k.appId === "portainer")
487
+ if (portainerEntry) {
488
+ portainerEntry.key = apiKey
489
+ portainerEntry.status = "generated"
490
+ }
491
+ }
492
+ } catch (e) {
493
+ if (this.statusText) {
494
+ this.statusText.content = `Portainer error: ${e}`
495
+ this.statusText.fg = "#ff5555"
496
+ }
497
+ }
498
+ }
358
499
  }
@@ -95,7 +95,12 @@ export class AppConfigurator extends BoxRenderable {
95
95
  const { container, content } = createPageLayout(this.cliRenderer, {
96
96
  title: "Configure Apps",
97
97
  stepInfo: "Global Credentials",
98
- footerHint: "Tab Cycle Fields/Shortcuts O Override Enter Continue Esc Skip",
98
+ footerHint: [
99
+ { type: "key", key: "Tab", value: "Cycle Fields/Shortcuts" },
100
+ { type: "key", key: "O", value: "Override" },
101
+ { type: "key", key: "Enter", value: "Continue" },
102
+ { type: "key", key: "Esc", value: "Skip" },
103
+ ],
99
104
  })
100
105
  this.pageContainer = container
101
106
  this.add(container)
@@ -358,7 +363,7 @@ export class AppConfigurator extends BoxRenderable {
358
363
  const { container, content } = createPageLayout(this.cliRenderer, {
359
364
  title: "Configure Apps",
360
365
  stepInfo: "Setting up root folders",
361
- footerHint: "Please wait...",
366
+ footerHint: [{ type: "text", value: "Please wait..." }],
362
367
  })
363
368
  this.pageContainer = container
364
369
  this.contentBox = content
@@ -427,7 +432,10 @@ export class AppConfigurator extends BoxRenderable {
427
432
  const { container, content } = createPageLayout(this.cliRenderer, {
428
433
  title: "Configure Apps",
429
434
  stepInfo: "qBittorrent Credentials",
430
- footerHint: "Enter credentials from qBittorrent WebUI Esc Skip",
435
+ footerHint: [
436
+ { type: "text", value: "Enter credentials from qBittorrent WebUI" },
437
+ { type: "key", key: "Esc", value: "Skip" },
438
+ ],
431
439
  })
432
440
  this.pageContainer = container
433
441
  this.add(container)
@@ -483,7 +491,10 @@ export class AppConfigurator extends BoxRenderable {
483
491
  const { container, content } = createPageLayout(this.cliRenderer, {
484
492
  title: "Configure Apps",
485
493
  stepInfo: "SABnzbd Credentials",
486
- footerHint: "Enter API key from SABnzbd Config → General Esc Skip",
494
+ footerHint: [
495
+ { type: "text", value: "Enter API key from SABnzbd Config → General" },
496
+ { type: "key", key: "Esc", value: "Skip" },
497
+ ],
487
498
  })
488
499
  this.pageContainer = container
489
500
  this.add(container)
@@ -615,7 +626,7 @@ export class AppConfigurator extends BoxRenderable {
615
626
  const { container, content } = createPageLayout(this.cliRenderer, {
616
627
  title: "Configure Apps",
617
628
  stepInfo: "Complete",
618
- footerHint: "Press any key to return",
629
+ footerHint: [{ type: "text", value: "Press any key to return" }],
619
630
  })
620
631
  this.add(container)
621
632
 
@@ -54,7 +54,12 @@ export class AppManager {
54
54
  const { container: page, content } = createPageLayout(this.renderer as CliRenderer, {
55
55
  title: "Manage Apps",
56
56
  stepInfo: "Toggle apps linked to your configuration",
57
- footerHint: "←→ Tab Enter Toggle s Save q Back",
57
+ footerHint: [
58
+ { type: "key", key: "←→", value: "Tab" },
59
+ { type: "key", key: "Enter", value: "Toggle" },
60
+ { type: "key", key: "s", value: "Save" },
61
+ { type: "key", key: "q", value: "Back" },
62
+ ],
58
63
  })
59
64
  this.page = page
60
65
 
@@ -47,7 +47,10 @@ export class ContainerControl {
47
47
  const { container: page, content } = createPageLayout(this.renderer as CliRenderer, {
48
48
  title: "Container Control",
49
49
  stepInfo: finalStepInfo,
50
- footerHint: "Enter Select/Action q Back",
50
+ footerHint: [
51
+ { type: "key", key: "Enter", value: "Select/Action" },
52
+ { type: "key", key: "q", value: "Back" },
53
+ ],
51
54
  })
52
55
 
53
56
  if (!dockerOk) {
@@ -9,10 +9,11 @@ import type { EasiarrConfig } from "../../config/schema"
9
9
  import { ArrApiClient, type AddRootFolderOptions } from "../../api/arr-api"
10
10
  import { ProwlarrClient, type ArrAppType } from "../../api/prowlarr-api"
11
11
  import { QBittorrentClient, type QBittorrentCategory } from "../../api/qbittorrent-api"
12
+ import { PortainerApiClient } from "../../api/portainer-api"
12
13
  import { getApp } from "../../apps/registry"
13
14
  // import type { AppId } from "../../config/schema"
14
15
  import { getCategoriesForApps } from "../../utils/categories"
15
- import { readEnvSync } from "../../utils/env"
16
+ import { readEnvSync, updateEnv } from "../../utils/env"
16
17
  import { debugLog } from "../../utils/debug"
17
18
 
18
19
  interface SetupStep {
@@ -47,7 +48,10 @@ export class FullAutoSetup extends BoxRenderable {
47
48
  const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
48
49
  title: "Full Auto Setup",
49
50
  stepInfo: "Configure all services automatically",
50
- footerHint: "Enter Start/Continue Esc Back",
51
+ footerHint: [
52
+ { type: "key", key: "Enter", value: "Start/Continue" },
53
+ { type: "key", key: "Esc", value: "Back" },
54
+ ],
51
55
  })
52
56
  super(cliRenderer, { width: "100%", height: "100%" })
53
57
  this.add(pageContainer)
@@ -74,6 +78,7 @@ export class FullAutoSetup extends BoxRenderable {
74
78
  { name: "Prowlarr Apps", status: "pending" },
75
79
  { name: "FlareSolverr", status: "pending" },
76
80
  { name: "qBittorrent", status: "pending" },
81
+ { name: "Portainer", status: "pending" },
77
82
  ]
78
83
  }
79
84
 
@@ -119,6 +124,9 @@ export class FullAutoSetup extends BoxRenderable {
119
124
  // Step 5: qBittorrent
120
125
  await this.setupQBittorrent()
121
126
 
127
+ // Step 6: Portainer
128
+ await this.setupPortainer()
129
+
122
130
  this.isRunning = false
123
131
  this.isDone = true
124
132
  this.refreshContent()
@@ -327,6 +335,74 @@ export class FullAutoSetup extends BoxRenderable {
327
335
  this.refreshContent()
328
336
  }
329
337
 
338
+ private async setupPortainer(): Promise<void> {
339
+ this.updateStep("Portainer", "running")
340
+ this.refreshContent()
341
+
342
+ const portainerConfig = this.config.apps.find((a) => a.id === "portainer" && a.enabled)
343
+ if (!portainerConfig) {
344
+ this.updateStep("Portainer", "skipped", "Not enabled")
345
+ this.refreshContent()
346
+ return
347
+ }
348
+
349
+ if (!this.globalPassword) {
350
+ this.updateStep("Portainer", "skipped", "No GLOBAL_PASSWORD set")
351
+ this.refreshContent()
352
+ return
353
+ }
354
+
355
+ try {
356
+ const port = portainerConfig.port || 9000
357
+ const client = new PortainerApiClient("localhost", port)
358
+
359
+ // Check if we can reach Portainer
360
+ const healthy = await client.isHealthy()
361
+ if (!healthy) {
362
+ this.updateStep("Portainer", "skipped", "Not reachable yet")
363
+ this.refreshContent()
364
+ return
365
+ }
366
+
367
+ // Initialize admin user (auto-pads password if needed)
368
+ const result = await client.initializeAdmin(this.globalUsername, this.globalPassword)
369
+
370
+ if (result) {
371
+ // Generate API key and save to .env
372
+ const apiKey = await client.generateApiKey(result.actualPassword, "easiarr-api-key")
373
+
374
+ const envUpdates: Record<string, string> = {
375
+ API_KEY_PORTAINER: apiKey,
376
+ }
377
+
378
+ // Save password if it was padded (different from global)
379
+ if (result.passwordWasPadded) {
380
+ envUpdates.PORTAINER_PASSWORD = result.actualPassword
381
+ }
382
+
383
+ await updateEnv(envUpdates)
384
+ this.updateStep("Portainer", "success", "Admin + API key created")
385
+ } else {
386
+ // Already initialized, try to login and get API key if we don't have one
387
+ if (!this.env["API_KEY_PORTAINER"]) {
388
+ try {
389
+ await client.login(this.globalUsername, this.globalPassword)
390
+ const apiKey = await client.generateApiKey(this.globalPassword, "easiarr-api-key")
391
+ await updateEnv({ API_KEY_PORTAINER: apiKey })
392
+ this.updateStep("Portainer", "success", "API key generated")
393
+ } catch {
394
+ this.updateStep("Portainer", "skipped", "Already initialized")
395
+ }
396
+ } else {
397
+ this.updateStep("Portainer", "skipped", "Already configured")
398
+ }
399
+ }
400
+ } catch (e) {
401
+ this.updateStep("Portainer", "error", `${e}`)
402
+ }
403
+ this.refreshContent()
404
+ }
405
+
330
406
  private updateStep(name: string, status: SetupStep["status"], message?: string): void {
331
407
  const step = this.steps.find((s) => s.name === name)
332
408
  if (step) {
@@ -15,6 +15,7 @@ import { TRaSHProfileSetup } from "./TRaSHProfileSetup"
15
15
  import { ProwlarrSetup } from "./ProwlarrSetup"
16
16
  import { QBittorrentSetup } from "./QBittorrentSetup"
17
17
  import { FullAutoSetup } from "./FullAutoSetup"
18
+ import { MonitorDashboard } from "./MonitorDashboard"
18
19
 
19
20
  export class MainMenu {
20
21
  private renderer: RenderContext
@@ -37,7 +38,7 @@ export class MainMenu {
37
38
  const { container: page, content } = createPageLayout(this.renderer as CliRenderer, {
38
39
  title: "Main Menu",
39
40
  stepInfo: "Docker Compose Generator for *arr Ecosystem",
40
- footerHint: "Enter Select Ctrl+C Exit",
41
+ footerHint: [{ type: "key", key: "Enter", value: "Select" }],
41
42
  })
42
43
  this.page = page
43
44
 
@@ -82,7 +83,7 @@ export class MainMenu {
82
83
  this.menu = new SelectRenderable(this.renderer, {
83
84
  id: "main-menu-select",
84
85
  width: "100%",
85
- height: 10,
86
+ flexGrow: 1,
86
87
  options: [
87
88
  {
88
89
  name: "📦 Manage Apps",
@@ -124,6 +125,10 @@ export class MainMenu {
124
125
  name: "🚀 Full Auto Setup",
125
126
  description: "Run all configurations (Auth, Root Folders, Prowlarr, etc.)",
126
127
  },
128
+ {
129
+ name: "📊 Monitor Dashboard",
130
+ description: "Configure app health monitoring",
131
+ },
127
132
  { name: "❌ Exit", description: "Close easiarr" },
128
133
  ],
129
134
  })
@@ -211,7 +216,18 @@ export class MainMenu {
211
216
  this.container.add(autoSetup)
212
217
  break
213
218
  }
214
- case 10:
219
+ case 10: {
220
+ // Monitor Dashboard
221
+ this.menu.blur()
222
+ this.page.visible = false
223
+ const monitor = new MonitorDashboard(this.renderer as CliRenderer, this.config, () => {
224
+ this.page.visible = true
225
+ this.menu.focus()
226
+ })
227
+ this.container.add(monitor)
228
+ break
229
+ }
230
+ case 11:
215
231
  process.exit(0)
216
232
  break
217
233
  }