@muhammedaksam/easiarr 0.9.1 → 1.0.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/README.md +2 -2
- package/package.json +3 -1
- package/src/api/bazarr-api.ts +220 -0
- package/src/api/cloudflare-api.ts +10 -0
- package/src/compose/generator.ts +3 -0
- package/src/compose/traefik-config.ts +16 -5
- package/src/config/manager.ts +9 -0
- package/src/docker/client.ts +11 -0
- package/src/ui/screens/FullAutoSetup.ts +52 -0
- package/src/utils/debug.ts +16 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
> ⚠️ **Work In Progress** - This project is in early experimental development. Features may be incomplete, unstable, or change without notice.
|
|
12
12
|
|
|
13
|
-
TUI tool for generating docker-compose files for the \*arr media ecosystem with
|
|
13
|
+
TUI tool for generating docker-compose files for the \*arr media ecosystem with 47 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support.
|
|
14
14
|
|
|
15
15
|
A terminal-based wizard that helps you set up Radarr, Sonarr, Prowlarr, and other \*arr applications with Docker Compose, following best practices from [TRaSH Guides](https://trash-guides.info/).
|
|
16
16
|
|
|
@@ -52,7 +52,7 @@ bun run start
|
|
|
52
52
|
- [Bun](https://bun.sh/) >= 1.0
|
|
53
53
|
- [Docker](https://www.docker.com/) with Docker Compose v2
|
|
54
54
|
|
|
55
|
-
## Supported Applications (
|
|
55
|
+
## Supported Applications (47 apps across 10 categories)
|
|
56
56
|
|
|
57
57
|
### Media Management (Servarr)
|
|
58
58
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@eslint/js": "^9.39.1",
|
|
55
|
+
"@types/bcrypt": "^6.0.0",
|
|
55
56
|
"@types/bun": "latest",
|
|
56
57
|
"@types/jest": "^30.0.0",
|
|
57
58
|
"@types/node": "^25.0.0",
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
},
|
|
68
69
|
"dependencies": {
|
|
69
70
|
"@opentui/core": "^0.1.60",
|
|
71
|
+
"bcrypt": "^6.0.0",
|
|
70
72
|
"yaml": "^2.8.2"
|
|
71
73
|
},
|
|
72
74
|
"engines": {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bazarr API Client
|
|
3
|
+
* Handles Bazarr-specific API calls for authentication and settings
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { debugLog } from "../utils/debug"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Bazarr System Settings (partial - auth related fields)
|
|
10
|
+
*/
|
|
11
|
+
export interface BazarrAuthSettings {
|
|
12
|
+
auth: {
|
|
13
|
+
type: "None" | "Basic" | "Form"
|
|
14
|
+
username: string
|
|
15
|
+
password: string
|
|
16
|
+
apikey: string
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bazarr API Client
|
|
22
|
+
* Note: Bazarr uses form data for POST, not JSON!
|
|
23
|
+
*/
|
|
24
|
+
export class BazarrApiClient {
|
|
25
|
+
private baseUrl: string
|
|
26
|
+
private apiKey: string | null = null
|
|
27
|
+
|
|
28
|
+
constructor(host: string, port: number) {
|
|
29
|
+
this.baseUrl = `http://${host}:${port}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Set API key for authentication
|
|
34
|
+
*/
|
|
35
|
+
setApiKey(key: string): void {
|
|
36
|
+
this.apiKey = key
|
|
37
|
+
debugLog("Bazarr", `API key set`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Make a GET request to Bazarr API (JSON response)
|
|
42
|
+
*/
|
|
43
|
+
private async get<T>(endpoint: string): Promise<T> {
|
|
44
|
+
let url = `${this.baseUrl}/api${endpoint}`
|
|
45
|
+
if (this.apiKey) {
|
|
46
|
+
url = `${url}${url.includes("?") ? "&" : "?"}apikey=${this.apiKey}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
debugLog("Bazarr", `GET ${url}`)
|
|
50
|
+
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
method: "GET",
|
|
53
|
+
headers: {
|
|
54
|
+
Accept: "application/json",
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const errorText = await response.text()
|
|
60
|
+
debugLog("Bazarr", `Error ${response.status}: ${errorText}`)
|
|
61
|
+
throw new Error(`Bazarr API error: ${response.status} ${response.statusText}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const text = await response.text()
|
|
65
|
+
debugLog("Bazarr", `Response: ${text.substring(0, 200)}${text.length > 200 ? "..." : ""}`)
|
|
66
|
+
if (!text) return {} as T
|
|
67
|
+
|
|
68
|
+
return JSON.parse(text) as T
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Make a POST request to Bazarr API using form data (NOT JSON)
|
|
73
|
+
* Bazarr uses request.form, not request.json
|
|
74
|
+
*/
|
|
75
|
+
private async postForm(endpoint: string, data: Record<string, string>): Promise<void> {
|
|
76
|
+
let url = `${this.baseUrl}/api${endpoint}`
|
|
77
|
+
if (this.apiKey) {
|
|
78
|
+
url = `${url}${url.includes("?") ? "&" : "?"}apikey=${this.apiKey}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Convert object to form data
|
|
82
|
+
const formData = new URLSearchParams()
|
|
83
|
+
for (const [key, value] of Object.entries(data)) {
|
|
84
|
+
formData.append(key, value)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
debugLog("Bazarr", `POST ${url}`)
|
|
88
|
+
debugLog("Bazarr", `Form data: ${formData.toString()}`)
|
|
89
|
+
|
|
90
|
+
const response = await fetch(url, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
94
|
+
},
|
|
95
|
+
body: formData.toString(),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const errorText = await response.text()
|
|
100
|
+
debugLog("Bazarr", `Error ${response.status}: ${errorText}`)
|
|
101
|
+
throw new Error(`Bazarr API error: ${response.status} ${response.statusText}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
debugLog("Bazarr", `Response status: ${response.status}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if Bazarr is healthy and reachable
|
|
109
|
+
*/
|
|
110
|
+
async isHealthy(): Promise<boolean> {
|
|
111
|
+
try {
|
|
112
|
+
await this.get("/system/status")
|
|
113
|
+
return true
|
|
114
|
+
} catch {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get current system settings
|
|
121
|
+
*/
|
|
122
|
+
async getSettings(): Promise<Record<string, unknown>> {
|
|
123
|
+
return this.get<Record<string, unknown>>("/system/settings")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Update authentication settings to enable form-based auth
|
|
128
|
+
* Bazarr settings use dot notation in form fields
|
|
129
|
+
*/
|
|
130
|
+
async enableFormAuth(username: string, password: string, override = false): Promise<boolean> {
|
|
131
|
+
try {
|
|
132
|
+
// First get current settings to check if auth is already configured
|
|
133
|
+
const currentSettings = await this.getSettings()
|
|
134
|
+
const currentAuth = (currentSettings as { auth?: { type?: string } }).auth
|
|
135
|
+
|
|
136
|
+
// Skip if auth is already configured and override is false
|
|
137
|
+
if (currentAuth?.type && currentAuth.type !== "None" && currentAuth.type !== null && !override) {
|
|
138
|
+
debugLog("Bazarr", `Auth already configured (type: ${currentAuth.type}), skipping`)
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
debugLog("Bazarr", `Current auth type: ${currentAuth?.type || "None"}`)
|
|
143
|
+
debugLog("Bazarr", `Setting form auth for user: ${username}`)
|
|
144
|
+
|
|
145
|
+
// Bazarr uses dot notation for nested settings in form data
|
|
146
|
+
await this.postForm("/system/settings", {
|
|
147
|
+
"settings-auth-type": "form",
|
|
148
|
+
"settings-auth-username": username,
|
|
149
|
+
"settings-auth-password": password,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
debugLog("Bazarr", `Form auth enabled for user: ${username}`)
|
|
153
|
+
return true
|
|
154
|
+
} catch (e) {
|
|
155
|
+
debugLog("Bazarr", `Failed to enable form auth: ${e}`)
|
|
156
|
+
throw e
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get API key from settings
|
|
162
|
+
*/
|
|
163
|
+
async getApiKey(): Promise<string | null> {
|
|
164
|
+
try {
|
|
165
|
+
const settings = await this.getSettings()
|
|
166
|
+
const auth = (settings as unknown as BazarrAuthSettings).auth
|
|
167
|
+
return auth?.apikey || null
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Configure Radarr connection in Bazarr
|
|
175
|
+
*/
|
|
176
|
+
async configureRadarr(host: string, port: number, apiKey: string): Promise<boolean> {
|
|
177
|
+
try {
|
|
178
|
+
debugLog("Bazarr", `Configuring Radarr connection: ${host}:${port}`)
|
|
179
|
+
|
|
180
|
+
await this.postForm("/system/settings", {
|
|
181
|
+
"settings-radarr-ip": host,
|
|
182
|
+
"settings-radarr-port": String(port),
|
|
183
|
+
"settings-radarr-apikey": apiKey,
|
|
184
|
+
"settings-radarr-base_url": "",
|
|
185
|
+
"settings-radarr-ssl": "false",
|
|
186
|
+
"settings-general-use_radarr": "true",
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
debugLog("Bazarr", "Radarr connection configured successfully")
|
|
190
|
+
return true
|
|
191
|
+
} catch (e) {
|
|
192
|
+
debugLog("Bazarr", `Failed to configure Radarr: ${e}`)
|
|
193
|
+
throw e
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Configure Sonarr connection in Bazarr
|
|
199
|
+
*/
|
|
200
|
+
async configureSonarr(host: string, port: number, apiKey: string): Promise<boolean> {
|
|
201
|
+
try {
|
|
202
|
+
debugLog("Bazarr", `Configuring Sonarr connection: ${host}:${port}`)
|
|
203
|
+
|
|
204
|
+
await this.postForm("/system/settings", {
|
|
205
|
+
"settings-sonarr-ip": host,
|
|
206
|
+
"settings-sonarr-port": String(port),
|
|
207
|
+
"settings-sonarr-apikey": apiKey,
|
|
208
|
+
"settings-sonarr-base_url": "",
|
|
209
|
+
"settings-sonarr-ssl": "false",
|
|
210
|
+
"settings-general-use_sonarr": "true",
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
debugLog("Bazarr", "Sonarr connection configured successfully")
|
|
214
|
+
return true
|
|
215
|
+
} catch (e) {
|
|
216
|
+
debugLog("Bazarr", `Failed to configure Sonarr: ${e}`)
|
|
217
|
+
throw e
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Cloudflare API client for tunnel and DNS management
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { debugLog } from "../utils/debug"
|
|
6
|
+
|
|
5
7
|
const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
|
|
6
8
|
|
|
7
9
|
interface CloudflareResponse<T> {
|
|
@@ -49,9 +51,15 @@ export class CloudflareApi {
|
|
|
49
51
|
|
|
50
52
|
constructor(apiToken: string) {
|
|
51
53
|
this.apiToken = apiToken
|
|
54
|
+
debugLog("CloudflareAPI", "Client initialized")
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
private async request<T>(method: string, endpoint: string, body?: unknown): Promise<CloudflareResponse<T>> {
|
|
58
|
+
debugLog("CloudflareAPI", `${method} ${endpoint}`)
|
|
59
|
+
if (body) {
|
|
60
|
+
debugLog("CloudflareAPI", `Request body: ${JSON.stringify(body)}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
const response = await fetch(`${CLOUDFLARE_API_BASE}${endpoint}`, {
|
|
56
64
|
method,
|
|
57
65
|
headers: {
|
|
@@ -65,9 +73,11 @@ export class CloudflareApi {
|
|
|
65
73
|
|
|
66
74
|
if (!data.success) {
|
|
67
75
|
const errors = data.errors.map((e) => e.message).join(", ")
|
|
76
|
+
debugLog("CloudflareAPI", `Error: ${errors}`)
|
|
68
77
|
throw new Error(`Cloudflare API error: ${errors}`)
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
debugLog("CloudflareAPI", `Response success: ${data.success}`)
|
|
71
81
|
return data
|
|
72
82
|
}
|
|
73
83
|
|
package/src/compose/generator.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getApp } from "../apps/registry"
|
|
|
10
10
|
import { generateServiceYaml } from "./templates"
|
|
11
11
|
import { updateEnv, getLocalIp } from "../utils/env"
|
|
12
12
|
import { saveTraefikConfig } from "./traefik-config"
|
|
13
|
+
import { debugLog } from "../utils/debug"
|
|
13
14
|
|
|
14
15
|
export interface ComposeService {
|
|
15
16
|
image: string
|
|
@@ -31,6 +32,7 @@ export interface ComposeFile {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function generateCompose(config: EasiarrConfig): string {
|
|
35
|
+
debugLog("ComposeGenerator", `Generating compose for ${config.apps.filter((a) => a.enabled).length} enabled apps`)
|
|
34
36
|
const services: Record<string, ComposeService> = {}
|
|
35
37
|
|
|
36
38
|
// Track ports to move to Gluetun
|
|
@@ -45,6 +47,7 @@ export function generateCompose(config: EasiarrConfig): string {
|
|
|
45
47
|
const appDef = getApp(appConfig.id)
|
|
46
48
|
if (!appDef) continue
|
|
47
49
|
|
|
50
|
+
debugLog("ComposeGenerator", `Building service: ${appConfig.id}`)
|
|
48
51
|
const service = buildService(appDef, appConfig, config)
|
|
49
52
|
services[appConfig.id] = service
|
|
50
53
|
}
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
import { writeFile, mkdir } from "node:fs/promises"
|
|
7
7
|
import { existsSync } from "node:fs"
|
|
8
8
|
import { join } from "node:path"
|
|
9
|
-
import {
|
|
9
|
+
import { hashSync } from "bcrypt"
|
|
10
10
|
import type { EasiarrConfig } from "../config/schema"
|
|
11
|
+
import { debugLog } from "../utils/debug"
|
|
11
12
|
|
|
12
13
|
export interface TraefikStaticConfig {
|
|
13
14
|
entrypoints: {
|
|
@@ -97,11 +98,15 @@ http:
|
|
|
97
98
|
|
|
98
99
|
/**
|
|
99
100
|
* Generate htpasswd-compatible hash for basic auth
|
|
100
|
-
* Uses
|
|
101
|
+
* Uses bcrypt for secure password hashing (Traefik supports bcrypt format)
|
|
102
|
+
* Format: $2b$... (bcrypt hash compatible with htpasswd)
|
|
101
103
|
*/
|
|
102
104
|
function generateHtpasswdHash(password: string): string {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
// Use bcrypt with cost factor 10 (standard security level)
|
|
106
|
+
const hash = hashSync(password, 10)
|
|
107
|
+
// Traefik expects bcrypt hashes with $2y$ prefix (PHP-compatible)
|
|
108
|
+
// Node's bcrypt uses $2b$, which Traefik also accepts
|
|
109
|
+
return hash
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
/**
|
|
@@ -110,18 +115,24 @@ function generateHtpasswdHash(password: string): string {
|
|
|
110
115
|
export async function saveTraefikConfig(config: EasiarrConfig): Promise<void> {
|
|
111
116
|
// Check if traefik is enabled
|
|
112
117
|
const traefikApp = config.apps.find((a) => a.id === "traefik" && a.enabled)
|
|
113
|
-
if (!traefikApp)
|
|
118
|
+
if (!traefikApp) {
|
|
119
|
+
debugLog("Traefik", "Traefik not enabled, skipping config generation")
|
|
120
|
+
return
|
|
121
|
+
}
|
|
114
122
|
|
|
115
123
|
const traefikConfigDir = join(config.rootDir, "config", "traefik")
|
|
116
124
|
const letsencryptDir = join(traefikConfigDir, "letsencrypt")
|
|
125
|
+
debugLog("Traefik", `Generating config in ${traefikConfigDir}`)
|
|
117
126
|
|
|
118
127
|
try {
|
|
119
128
|
// Create directories if they don't exist
|
|
120
129
|
if (!existsSync(traefikConfigDir)) {
|
|
121
130
|
await mkdir(traefikConfigDir, { recursive: true })
|
|
131
|
+
debugLog("Traefik", `Created directory: ${traefikConfigDir}`)
|
|
122
132
|
}
|
|
123
133
|
if (!existsSync(letsencryptDir)) {
|
|
124
134
|
await mkdir(letsencryptDir, { recursive: true })
|
|
135
|
+
debugLog("Traefik", `Created directory: ${letsencryptDir}`)
|
|
125
136
|
}
|
|
126
137
|
|
|
127
138
|
// Generate and save static config (traefik.yml)
|
package/src/config/manager.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { EasiarrConfig } from "./schema"
|
|
|
11
11
|
import { DEFAULT_CONFIG } from "./schema"
|
|
12
12
|
import { detectTimezone, detectUid, detectGid } from "./defaults"
|
|
13
13
|
import { VersionInfo } from "../VersionInfo"
|
|
14
|
+
import { debugLog } from "../utils/debug"
|
|
14
15
|
|
|
15
16
|
const CONFIG_DIR_NAME = ".easiarr"
|
|
16
17
|
const CONFIG_FILE_NAME = "config.json"
|
|
@@ -71,17 +72,21 @@ function migrateConfig(oldConfig: Partial<EasiarrConfig>): EasiarrConfig {
|
|
|
71
72
|
|
|
72
73
|
export async function loadConfig(): Promise<EasiarrConfig | null> {
|
|
73
74
|
const configPath = getConfigPath()
|
|
75
|
+
debugLog("Config", `Loading config from ${configPath}`)
|
|
74
76
|
|
|
75
77
|
if (!existsSync(configPath)) {
|
|
78
|
+
debugLog("Config", "Config file not found")
|
|
76
79
|
return null
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
try {
|
|
80
83
|
const content = await readFile(configPath, "utf-8")
|
|
81
84
|
let config = JSON.parse(content) as EasiarrConfig
|
|
85
|
+
debugLog("Config", `Loaded config version ${config.version}`)
|
|
82
86
|
|
|
83
87
|
// Auto-migrate if version differs from current package version
|
|
84
88
|
if (config.version !== VersionInfo.version) {
|
|
89
|
+
debugLog("Config", `Migrating config from ${config.version} to ${VersionInfo.version}`)
|
|
85
90
|
config = migrateConfig(config)
|
|
86
91
|
// Save migrated config (creates backup first)
|
|
87
92
|
await saveConfig(config)
|
|
@@ -89,6 +94,7 @@ export async function loadConfig(): Promise<EasiarrConfig | null> {
|
|
|
89
94
|
|
|
90
95
|
return config
|
|
91
96
|
} catch (error) {
|
|
97
|
+
debugLog("Config", `Failed to load config: ${error}`)
|
|
92
98
|
console.error("Failed to load config:", error)
|
|
93
99
|
return null
|
|
94
100
|
}
|
|
@@ -98,6 +104,7 @@ export async function saveConfig(config: EasiarrConfig): Promise<void> {
|
|
|
98
104
|
await ensureConfigDir()
|
|
99
105
|
|
|
100
106
|
const configPath = getConfigPath()
|
|
107
|
+
debugLog("Config", `Saving config to ${configPath}`)
|
|
101
108
|
|
|
102
109
|
// Create backup if config already exists
|
|
103
110
|
if (existsSync(configPath)) {
|
|
@@ -108,6 +115,7 @@ export async function saveConfig(config: EasiarrConfig): Promise<void> {
|
|
|
108
115
|
config.updatedAt = new Date().toISOString()
|
|
109
116
|
|
|
110
117
|
await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8")
|
|
118
|
+
debugLog("Config", `Config saved (${config.apps.length} apps)`)
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
export async function backupConfig(): Promise<void> {
|
|
@@ -122,6 +130,7 @@ export async function backupConfig(): Promise<void> {
|
|
|
122
130
|
const backupPath = join(backupDir, `config-${timestamp}.json`)
|
|
123
131
|
|
|
124
132
|
await copyFile(configPath, backupPath)
|
|
133
|
+
debugLog("Config", `Backup created: ${backupPath}`)
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
export function createDefaultConfig(rootDir: string): EasiarrConfig {
|
package/src/docker/client.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { $ } from "bun"
|
|
7
7
|
import { getComposePath } from "../config/manager"
|
|
8
|
+
import { debugLog } from "../utils/debug"
|
|
8
9
|
|
|
9
10
|
export interface ContainerStatus {
|
|
10
11
|
name: string
|
|
@@ -18,9 +19,12 @@ export async function composeUp(): Promise<{
|
|
|
18
19
|
}> {
|
|
19
20
|
try {
|
|
20
21
|
const composePath = getComposePath()
|
|
22
|
+
debugLog("Docker", `compose up -d (path: ${composePath})`)
|
|
21
23
|
const result = await $`docker compose -f ${composePath} up -d`.text()
|
|
24
|
+
debugLog("Docker", `compose up success`)
|
|
22
25
|
return { success: true, output: result }
|
|
23
26
|
} catch (error) {
|
|
27
|
+
debugLog("Docker", `compose up failed: ${error}`)
|
|
24
28
|
return { success: false, output: String(error) }
|
|
25
29
|
}
|
|
26
30
|
}
|
|
@@ -31,9 +35,12 @@ export async function composeDown(): Promise<{
|
|
|
31
35
|
}> {
|
|
32
36
|
try {
|
|
33
37
|
const composePath = getComposePath()
|
|
38
|
+
debugLog("Docker", `compose down (path: ${composePath})`)
|
|
34
39
|
const result = await $`docker compose -f ${composePath} down`.text()
|
|
40
|
+
debugLog("Docker", `compose down success`)
|
|
35
41
|
return { success: true, output: result }
|
|
36
42
|
} catch (error) {
|
|
43
|
+
debugLog("Docker", `compose down failed: ${error}`)
|
|
37
44
|
return { success: false, output: String(error) }
|
|
38
45
|
}
|
|
39
46
|
}
|
|
@@ -41,11 +48,14 @@ export async function composeDown(): Promise<{
|
|
|
41
48
|
export async function composeRestart(service?: string): Promise<{ success: boolean; output: string }> {
|
|
42
49
|
try {
|
|
43
50
|
const composePath = getComposePath()
|
|
51
|
+
debugLog("Docker", `compose restart ${service || "all"}`)
|
|
44
52
|
const result = service
|
|
45
53
|
? await $`docker compose -f ${composePath} restart ${service}`.text()
|
|
46
54
|
: await $`docker compose -f ${composePath} restart`.text()
|
|
55
|
+
debugLog("Docker", `compose restart success`)
|
|
47
56
|
return { success: true, output: result }
|
|
48
57
|
} catch (error) {
|
|
58
|
+
debugLog("Docker", `compose restart failed: ${error}`)
|
|
49
59
|
return { success: false, output: String(error) }
|
|
50
60
|
}
|
|
51
61
|
}
|
|
@@ -53,6 +63,7 @@ export async function composeRestart(service?: string): Promise<{ success: boole
|
|
|
53
63
|
export async function composeStop(service?: string): Promise<{ success: boolean; output: string }> {
|
|
54
64
|
try {
|
|
55
65
|
const composePath = getComposePath()
|
|
66
|
+
debugLog("Docker", `compose stop ${service || "all"}`)
|
|
56
67
|
const result = service
|
|
57
68
|
? await $`docker compose -f ${composePath} stop ${service}`.text()
|
|
58
69
|
: await $`docker compose -f ${composePath} stop`.text()
|
|
@@ -7,6 +7,7 @@ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/c
|
|
|
7
7
|
import { createPageLayout } from "../components/PageLayout"
|
|
8
8
|
import type { EasiarrConfig } from "../../config/schema"
|
|
9
9
|
import { ArrApiClient, type AddRootFolderOptions } from "../../api/arr-api"
|
|
10
|
+
import { BazarrApiClient } from "../../api/bazarr-api"
|
|
10
11
|
import { ProwlarrClient, type ArrAppType } from "../../api/prowlarr-api"
|
|
11
12
|
import { QBittorrentClient, type QBittorrentCategory } from "../../api/qbittorrent-api"
|
|
12
13
|
import { PortainerApiClient } from "../../api/portainer-api"
|
|
@@ -201,6 +202,7 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
try {
|
|
205
|
+
// Setup *arr apps (Radarr, Sonarr, Lidarr, etc.) with form auth
|
|
204
206
|
const arrApps = this.config.apps.filter((a) => {
|
|
205
207
|
const def = getApp(a.id)
|
|
206
208
|
return a.enabled && (def?.rootFolder || a.id === "prowlarr")
|
|
@@ -224,6 +226,56 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
224
226
|
}
|
|
225
227
|
}
|
|
226
228
|
|
|
229
|
+
// Setup Bazarr form authentication and Radarr/Sonarr connections
|
|
230
|
+
const bazarrConfig = this.config.apps.find((a) => a.id === "bazarr" && a.enabled)
|
|
231
|
+
if (bazarrConfig) {
|
|
232
|
+
const bazarrApiKey = this.env["API_KEY_BAZARR"]
|
|
233
|
+
if (bazarrApiKey) {
|
|
234
|
+
const bazarrDef = getApp("bazarr")
|
|
235
|
+
const bazarrPort = bazarrConfig.port || bazarrDef?.defaultPort || 6767
|
|
236
|
+
const bazarrClient = new BazarrApiClient("localhost", bazarrPort)
|
|
237
|
+
bazarrClient.setApiKey(bazarrApiKey)
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
// Enable form auth
|
|
241
|
+
await bazarrClient.enableFormAuth(this.globalUsername, this.globalPassword, false)
|
|
242
|
+
} catch {
|
|
243
|
+
// Skip Bazarr auth failure - non-critical
|
|
244
|
+
debugLog("FullAutoSetup", "Bazarr form auth failed, continuing...")
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Configure Radarr connection if Radarr is enabled
|
|
248
|
+
// Use container name 'radarr' since Bazarr runs in Docker
|
|
249
|
+
const radarrConfig = this.config.apps.find((a) => a.id === "radarr" && a.enabled)
|
|
250
|
+
const radarrApiKey = this.env["API_KEY_RADARR"]
|
|
251
|
+
if (radarrConfig && radarrApiKey) {
|
|
252
|
+
try {
|
|
253
|
+
const radarrDef = getApp("radarr")
|
|
254
|
+
const radarrPort = radarrConfig.port || radarrDef?.defaultPort || 7878
|
|
255
|
+
await bazarrClient.configureRadarr("radarr", radarrPort, radarrApiKey)
|
|
256
|
+
debugLog("FullAutoSetup", "Bazarr -> Radarr connection configured")
|
|
257
|
+
} catch {
|
|
258
|
+
debugLog("FullAutoSetup", "Failed to configure Bazarr -> Radarr connection")
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Configure Sonarr connection if Sonarr is enabled
|
|
263
|
+
// Use container name 'sonarr' since Bazarr runs in Docker
|
|
264
|
+
const sonarrConfig = this.config.apps.find((a) => a.id === "sonarr" && a.enabled)
|
|
265
|
+
const sonarrApiKey = this.env["API_KEY_SONARR"]
|
|
266
|
+
if (sonarrConfig && sonarrApiKey) {
|
|
267
|
+
try {
|
|
268
|
+
const sonarrDef = getApp("sonarr")
|
|
269
|
+
const sonarrPort = sonarrConfig.port || sonarrDef?.defaultPort || 8989
|
|
270
|
+
await bazarrClient.configureSonarr("sonarr", sonarrPort, sonarrApiKey)
|
|
271
|
+
debugLog("FullAutoSetup", "Bazarr -> Sonarr connection configured")
|
|
272
|
+
} catch {
|
|
273
|
+
debugLog("FullAutoSetup", "Failed to configure Bazarr -> Sonarr connection")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
227
279
|
this.updateStep("Authentication", "success")
|
|
228
280
|
} catch (e) {
|
|
229
281
|
this.updateStep("Authentication", "error", `${e}`)
|
package/src/utils/debug.ts
CHANGED
|
@@ -31,14 +31,29 @@ export function initDebug(): void {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Sanitize sensitive fields from log messages
|
|
36
|
+
* Redacts passwords, tokens, API keys, secrets, and credentials
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeMessage(message: string): string {
|
|
39
|
+
// Match common sensitive field names in JSON format
|
|
40
|
+
// Covers: passwords, tokens, API keys, secrets, credentials, auth data
|
|
41
|
+
return message.replace(
|
|
42
|
+
/"(password|passwordConfirmation|Password|Pw|passwd|pass|apiKey|api_key|ApiKey|API_KEY|token|accessToken|access_token|refreshToken|refresh_token|bearerToken|jwtToken|jwt|secret|secretKey|secret_key|privateKey|private_key|credential|auth|authorization|authToken|client_secret|clientSecret|WIREGUARD_PRIVATE_KEY|TUNNEL_TOKEN|USERNAME_VPN|PASSWORD_VPN)":\s*"[^"]*"/gi,
|
|
43
|
+
'"$1":"[REDACTED]"'
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
/**
|
|
35
48
|
* Log a debug message to debug.log file if debug mode is enabled
|
|
49
|
+
* Automatically sanitizes sensitive data (passwords, tokens, etc.)
|
|
36
50
|
*/
|
|
37
51
|
export function debugLog(category: string, message: string): void {
|
|
38
52
|
if (!DEBUG_ENABLED) return
|
|
39
53
|
|
|
40
54
|
const timestamp = new Date().toISOString()
|
|
41
|
-
const
|
|
55
|
+
const sanitizedMessage = sanitizeMessage(message)
|
|
56
|
+
const line = `[${timestamp}] [${category}] ${sanitizedMessage}\n`
|
|
42
57
|
try {
|
|
43
58
|
appendFileSync(logFile, line)
|
|
44
59
|
} catch {
|