@muhammedaksam/easiarr 0.5.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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",
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Portainer API Client
3
+ * Handles Portainer-specific API calls for initialization and management
4
+ */
5
+
6
+ import { debugLog } from "../utils/debug"
7
+ import { ensureMinPasswordLength } from "../utils/password"
8
+
9
+ // Portainer requires minimum 12 character password
10
+ export const PORTAINER_MIN_PASSWORD_LENGTH = 12
11
+
12
+ export interface PortainerUser {
13
+ Id?: number
14
+ Username: string
15
+ Password?: string
16
+ Role?: number
17
+ }
18
+
19
+ // Result from admin initialization - includes actual password used
20
+ export interface PortainerInitResult {
21
+ user: PortainerUser
22
+ /** The actual password used (may be padded if global was < 12 chars) */
23
+ actualPassword: string
24
+ /** True if password was modified (padded) from the original */
25
+ passwordWasPadded: boolean
26
+ }
27
+
28
+ export interface PortainerStatus {
29
+ Version: string
30
+ InstanceID: string
31
+ }
32
+
33
+ export interface PortainerSettings {
34
+ AuthenticationMethod: number
35
+ LogoURL: string
36
+ BlackListedLabels: string[]
37
+ InternalAuthSettings: {
38
+ RequiredPasswordLength: number
39
+ }
40
+ }
41
+
42
+ // Auth response from login
43
+ export interface PortainerAuthResponse {
44
+ jwt: string
45
+ }
46
+
47
+ // API Key creation response
48
+ export interface PortainerApiKeyResponse {
49
+ rawAPIKey: string
50
+ apiKey: {
51
+ id: number
52
+ userId: number
53
+ description: string
54
+ prefix: string
55
+ dateCreated: number
56
+ lastUsed: number
57
+ digest: string
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Portainer API Client
63
+ */
64
+ export class PortainerApiClient {
65
+ private baseUrl: string
66
+ private jwtToken: string | null = null
67
+
68
+ constructor(host: string, port: number) {
69
+ this.baseUrl = `http://${host}:${port}`
70
+ }
71
+
72
+ private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
73
+ const url = `${this.baseUrl}/api${endpoint}`
74
+ const headers: Record<string, string> = {
75
+ "Content-Type": "application/json",
76
+ ...(options.headers as Record<string, string>),
77
+ }
78
+
79
+ // Add JWT token if authenticated
80
+ if (this.jwtToken) {
81
+ headers["Authorization"] = `Bearer ${this.jwtToken}`
82
+ }
83
+
84
+ debugLog("PortainerAPI", `${options.method || "GET"} ${url}`)
85
+ if (options.body) {
86
+ debugLog("PortainerAPI", `Request Body: ${options.body}`)
87
+ }
88
+
89
+ const response = await fetch(url, { ...options, headers })
90
+ const text = await response.text()
91
+
92
+ debugLog("PortainerAPI", `Response ${response.status} from ${endpoint}`)
93
+ if (text && text.length < 2000) {
94
+ debugLog("PortainerAPI", `Response Body: ${text}`)
95
+ }
96
+
97
+ if (!response.ok) {
98
+ throw new Error(`Portainer API request failed: ${response.status} ${response.statusText} - ${text}`)
99
+ }
100
+
101
+ if (!text) return {} as T
102
+ return JSON.parse(text) as T
103
+ }
104
+
105
+ /**
106
+ * Check if Portainer needs initial admin user setup.
107
+ * Returns true if no admin user exists yet.
108
+ */
109
+ async needsInitialization(): Promise<boolean> {
110
+ try {
111
+ const response = await fetch(`${this.baseUrl}/api/users/admin/check`)
112
+ // 404 means no admin exists yet - needs initialization
113
+ // 204 means admin already exists
114
+ return response.status === 404
115
+ } catch (error) {
116
+ debugLog("PortainerAPI", `Check admin error: ${error}`)
117
+ return false
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Login to Portainer and store JWT token for subsequent requests.
123
+ *
124
+ * @param username - Admin username
125
+ * @param password - Admin password
126
+ * @returns JWT token
127
+ */
128
+ async login(username: string, password: string): Promise<string> {
129
+ const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
130
+
131
+ const response = await this.request<PortainerAuthResponse>("/auth", {
132
+ method: "POST",
133
+ body: JSON.stringify({
134
+ username,
135
+ password: safePassword,
136
+ }),
137
+ })
138
+
139
+ this.jwtToken = response.jwt
140
+ return response.jwt
141
+ }
142
+
143
+ /**
144
+ * Initialize the admin user for a fresh Portainer installation.
145
+ * Password will be automatically padded if shorter than 12 characters.
146
+ * Automatically logs in after initialization.
147
+ *
148
+ * @param username - Admin username
149
+ * @param password - Admin password (will be padded if needed)
150
+ * @returns Init result with user, actual password, and padding flag - or null if already initialized
151
+ */
152
+ async initializeAdmin(username: string, password: string): Promise<PortainerInitResult | null> {
153
+ // Check if initialization is needed
154
+ const needsInit = await this.needsInitialization()
155
+ if (!needsInit) {
156
+ debugLog("PortainerAPI", "Admin already initialized, skipping")
157
+ return null
158
+ }
159
+
160
+ // Ensure password meets Portainer's minimum length requirement
161
+ const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
162
+ const wasPadded = safePassword !== password
163
+
164
+ if (wasPadded) {
165
+ debugLog("PortainerAPI", `Password padded from ${password.length} to ${safePassword.length} characters`)
166
+ }
167
+
168
+ const user = await this.request<PortainerUser>("/users/admin/init", {
169
+ method: "POST",
170
+ body: JSON.stringify({
171
+ Username: username,
172
+ Password: safePassword,
173
+ }),
174
+ })
175
+
176
+ // Auto-login after initialization
177
+ await this.login(username, safePassword)
178
+
179
+ return {
180
+ user,
181
+ actualPassword: safePassword,
182
+ passwordWasPadded: wasPadded,
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Generate a permanent API key for the authenticated user.
188
+ * Must be logged in first (call login() or initializeAdmin()).
189
+ *
190
+ * @param userId - User ID (default: 1 for admin)
191
+ * @param description - Description for the API key
192
+ * @param password - User password for confirmation
193
+ * @returns Raw API key to save to .env as API_KEY_PORTAINER
194
+ */
195
+ async generateApiKey(password: string, description: string = "easiarr-api-key", userId: number = 1): Promise<string> {
196
+ if (!this.jwtToken) {
197
+ throw new Error("Must be logged in to generate API key. Call login() first.")
198
+ }
199
+
200
+ const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
201
+
202
+ const response = await this.request<PortainerApiKeyResponse>(`/users/${userId}/tokens`, {
203
+ method: "POST",
204
+ body: JSON.stringify({
205
+ password: safePassword,
206
+ description,
207
+ }),
208
+ })
209
+
210
+ return response.rawAPIKey
211
+ }
212
+
213
+ /**
214
+ * Get Portainer system status
215
+ */
216
+ async getStatus(): Promise<PortainerStatus> {
217
+ return this.request<PortainerStatus>("/status")
218
+ }
219
+
220
+ /**
221
+ * Check if Portainer is reachable
222
+ */
223
+ async isHealthy(): Promise<boolean> {
224
+ try {
225
+ await this.getStatus()
226
+ return true
227
+ } catch {
228
+ return false
229
+ }
230
+ }
231
+ }
@@ -448,6 +448,7 @@ export const APPS: Record<AppId, AppDefinition> = {
448
448
  puid: 0,
449
449
  pgid: 0,
450
450
  volumes: (root) => [`${root}/config/portainer:/data`, "/var/run/docker.sock:/var/run/docker.sock"],
451
+ minPasswordLength: 12, // Portainer requires minimum 12 character password
451
452
  },
452
453
 
453
454
  huntarr: {
@@ -188,6 +188,8 @@ export interface AppDefinition {
188
188
  prowlarrCategoryIds?: number[]
189
189
  /** Architecture compatibility info - omit if supports all */
190
190
  arch?: ArchCompatibility
191
+ /** Minimum password length requirement for user creation */
192
+ minPasswordLength?: number
191
193
  }
192
194
 
193
195
  export interface RootFolderMeta {
@@ -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,
@@ -338,20 +386,47 @@ export class ApiKeyViewer extends BoxRenderable {
338
386
 
339
387
  private async saveToEnv() {
340
388
  const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
341
- if (foundKeys.length === 0) return
342
389
 
343
390
  try {
344
391
  // Build updates object with API keys
345
392
  const updates: Record<string, string> = {}
393
+
346
394
  for (const k of foundKeys) {
347
- 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
348
422
  }
349
423
 
350
424
  await updateEnv(updates)
351
425
 
352
426
  // Update status
353
427
  if (this.statusText) {
354
- 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`
355
430
  }
356
431
  } catch (e) {
357
432
  if (this.statusText) {
@@ -360,4 +435,65 @@ export class ApiKeyViewer extends BoxRenderable {
360
435
  }
361
436
  }
362
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
+ }
363
499
  }
@@ -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 {
@@ -77,6 +78,7 @@ export class FullAutoSetup extends BoxRenderable {
77
78
  { name: "Prowlarr Apps", status: "pending" },
78
79
  { name: "FlareSolverr", status: "pending" },
79
80
  { name: "qBittorrent", status: "pending" },
81
+ { name: "Portainer", status: "pending" },
80
82
  ]
81
83
  }
82
84
 
@@ -122,6 +124,9 @@ export class FullAutoSetup extends BoxRenderable {
122
124
  // Step 5: qBittorrent
123
125
  await this.setupQBittorrent()
124
126
 
127
+ // Step 6: Portainer
128
+ await this.setupPortainer()
129
+
125
130
  this.isRunning = false
126
131
  this.isDone = true
127
132
  this.refreshContent()
@@ -330,6 +335,74 @@ export class FullAutoSetup extends BoxRenderable {
330
335
  this.refreshContent()
331
336
  }
332
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
+
333
406
  private updateStep(name: string, status: SetupStep["status"], message?: string): void {
334
407
  const step = this.steps.find((s) => s.name === name)
335
408
  if (step) {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Password Utilities
3
+ * Helpers for password validation and transformation
4
+ */
5
+
6
+ /**
7
+ * Ensures a password meets minimum length requirements by repeating it.
8
+ * If the password is already long enough, returns it unchanged.
9
+ *
10
+ * @param password - The original password
11
+ * @param minLength - Minimum required length
12
+ * @returns Password padded to at least minLength characters
13
+ */
14
+ export function ensureMinPasswordLength(password: string, minLength: number): string {
15
+ if (!password || password.length === 0) {
16
+ throw new Error("Password cannot be empty")
17
+ }
18
+
19
+ if (password.length >= minLength) {
20
+ return password
21
+ }
22
+
23
+ // Repeat the password until it meets minimum length
24
+ let extendedPassword = password
25
+ while (extendedPassword.length < minLength) {
26
+ extendedPassword += password
27
+ }
28
+
29
+ // Return exactly minLength characters
30
+ return extendedPassword.slice(0, minLength)
31
+ }
32
+
33
+ /**
34
+ * Validates if a password meets minimum length requirements.
35
+ *
36
+ * @param password - The password to validate
37
+ * @param minLength - Minimum required length
38
+ * @returns true if password meets requirements
39
+ */
40
+ export function isPasswordValid(password: string, minLength: number): boolean {
41
+ return password !== undefined && password !== null && password.length >= minLength
42
+ }