@muhammedaksam/easiarr 0.8.4 → 0.9.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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Traefik Configuration Generator
3
+ * Generates traefik.yml (static config) and dynamic.yml (middlewares)
4
+ */
5
+
6
+ import { writeFile, mkdir } from "node:fs/promises"
7
+ import { existsSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { createHash } from "node:crypto"
10
+ import type { EasiarrConfig } from "../config/schema"
11
+
12
+ export interface TraefikStaticConfig {
13
+ entrypoints: {
14
+ web: { address: string }
15
+ }
16
+ api: {
17
+ dashboard: boolean
18
+ insecure: boolean
19
+ }
20
+ providers: {
21
+ docker: {
22
+ endpoint: string
23
+ exposedByDefault: boolean
24
+ }
25
+ file: {
26
+ directory: string
27
+ watch: boolean
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Generate traefik.yml static configuration
34
+ */
35
+ export function generateTraefikStaticConfig(): string {
36
+ return `# Traefik Static Configuration
37
+ # Generated by easiarr
38
+
39
+ api:
40
+ dashboard: true
41
+ insecure: true
42
+
43
+ entryPoints:
44
+ web:
45
+ address: ":80"
46
+
47
+ providers:
48
+ docker:
49
+ endpoint: "unix:///var/run/docker.sock"
50
+ exposedByDefault: false
51
+ file:
52
+ directory: "/etc/traefik"
53
+ watch: true
54
+ `
55
+ }
56
+
57
+ /**
58
+ * Generate dynamic.yml with security headers middleware
59
+ */
60
+ export function generateTraefikDynamicConfig(
61
+ _middlewares: string[],
62
+ basicAuth?: { username: string; passwordHash: string }
63
+ ): string {
64
+ let yaml = `# Traefik Dynamic Configuration
65
+ # Generated by easiarr
66
+
67
+ http:
68
+ middlewares:
69
+ `
70
+
71
+ // Always include security-headers
72
+ yaml += ` security-headers:
73
+ headers:
74
+ browserXssFilter: true
75
+ contentTypeNosniff: true
76
+ forceSTSHeader: true
77
+ stsIncludeSubdomains: true
78
+ stsPreload: true
79
+ stsSeconds: 31536000
80
+ customFrameOptionsValue: "SAMEORIGIN"
81
+ `
82
+
83
+ // Add basic-auth middleware if credentials provided
84
+ if (basicAuth?.username && basicAuth?.passwordHash) {
85
+ // Escape $ in password hash for YAML
86
+ const escapedHash = basicAuth.passwordHash.replace(/\$/g, "$$$$")
87
+ yaml += `
88
+ basic-auth:
89
+ basicAuth:
90
+ users:
91
+ - "${basicAuth.username}:${escapedHash}"
92
+ `
93
+ }
94
+
95
+ return yaml
96
+ }
97
+
98
+ /**
99
+ * Generate htpasswd-compatible hash for basic auth
100
+ * Uses SHA1 hash in htpasswd format: {SHA}base64(sha1(password))
101
+ */
102
+ function generateHtpasswdHash(password: string): string {
103
+ const sha1Hash = createHash("sha1").update(password).digest("base64")
104
+ return `{SHA}${sha1Hash}`
105
+ }
106
+
107
+ /**
108
+ * Save Traefik configuration files to the config directory
109
+ */
110
+ export async function saveTraefikConfig(config: EasiarrConfig): Promise<void> {
111
+ // Check if traefik is enabled
112
+ const traefikApp = config.apps.find((a) => a.id === "traefik" && a.enabled)
113
+ if (!traefikApp) return
114
+
115
+ const traefikConfigDir = join(config.rootDir, "config", "traefik")
116
+ const letsencryptDir = join(traefikConfigDir, "letsencrypt")
117
+
118
+ try {
119
+ // Create directories if they don't exist
120
+ if (!existsSync(traefikConfigDir)) {
121
+ await mkdir(traefikConfigDir, { recursive: true })
122
+ }
123
+ if (!existsSync(letsencryptDir)) {
124
+ await mkdir(letsencryptDir, { recursive: true })
125
+ }
126
+
127
+ // Generate and save static config (traefik.yml)
128
+ const staticConfig = generateTraefikStaticConfig()
129
+ const staticPath = join(traefikConfigDir, "traefik.yml")
130
+
131
+ // Only write if file doesn't exist (don't overwrite user customizations)
132
+ if (!existsSync(staticPath)) {
133
+ await writeFile(staticPath, staticConfig, "utf-8")
134
+ }
135
+
136
+ // Check for basic auth credentials from config
137
+ let basicAuth: { username: string; passwordHash: string } | undefined
138
+ if (config.traefik?.basicAuth?.enabled) {
139
+ const username = config.traefik.basicAuth.username
140
+ const password = config.traefik.basicAuth.password
141
+ if (username && password) {
142
+ basicAuth = {
143
+ username,
144
+ passwordHash: generateHtpasswdHash(password),
145
+ }
146
+ }
147
+ }
148
+
149
+ // Generate and save dynamic config (dynamic.yml)
150
+ const dynamicConfig = generateTraefikDynamicConfig(config.traefik?.middlewares ?? [], basicAuth)
151
+ const dynamicPath = join(traefikConfigDir, "dynamic.yml")
152
+
153
+ // Always regenerate dynamic.yml as it contains auth settings
154
+ await writeFile(dynamicPath, dynamicConfig, "utf-8")
155
+
156
+ // Create acme.json with correct permissions if it doesn't exist
157
+ const acmePath = join(letsencryptDir, "acme.json")
158
+ if (!existsSync(acmePath)) {
159
+ await writeFile(acmePath, "{}", { mode: 0o600 })
160
+ }
161
+ } catch (error) {
162
+ // Permission denied - directory owned by root from Docker
163
+ // User needs to manually create configs or fix permissions
164
+ const err = error as NodeJS.ErrnoException
165
+ if (err.code === "EACCES") {
166
+ console.warn(
167
+ `[WARN] Cannot write Traefik config files (permission denied). ` +
168
+ `Fix with: sudo chown -R $(id -u):$(id -g) ${traefikConfigDir}`
169
+ )
170
+ } else {
171
+ throw error
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Bookmarks Generator
3
+ * Generates Netscape-format HTML bookmarks for browser import
4
+ */
5
+
6
+ import { writeFile } from "node:fs/promises"
7
+ import { join } from "node:path"
8
+ import { homedir } from "node:os"
9
+ import type { EasiarrConfig, AppCategory } from "./schema"
10
+ import { APP_CATEGORIES } from "./schema"
11
+ import { CATEGORY_ORDER } from "../apps/categories"
12
+ import { getApp } from "../apps/registry"
13
+ import { readEnvSync } from "../utils/env"
14
+
15
+ interface BookmarkEntry {
16
+ name: string
17
+ url: string
18
+ description: string
19
+ }
20
+
21
+ type CategoryBookmarks = Map<AppCategory, BookmarkEntry[]>
22
+
23
+ /**
24
+ * Get the URL for an app based on Traefik configuration
25
+ */
26
+ function getAppUrl(appId: string, port: number, config: EasiarrConfig, useLocalUrls: boolean): string {
27
+ if (!useLocalUrls && config.traefik?.enabled && config.traefik.domain) {
28
+ return `https://${appId}.${config.traefik.domain}/`
29
+ }
30
+ // Read LOCAL_DOCKER_IP from .env file, fallback to localhost
31
+ const env = readEnvSync()
32
+ const host = env.LOCAL_DOCKER_IP || "localhost"
33
+ return `http://${host}:${port}/`
34
+ }
35
+
36
+ /**
37
+ * Generate bookmark entries grouped by category
38
+ */
39
+ function generateBookmarksByCategory(config: EasiarrConfig, useLocalUrls: boolean): CategoryBookmarks {
40
+ const categoryBookmarks: CategoryBookmarks = new Map()
41
+
42
+ for (const appConfig of config.apps) {
43
+ if (!appConfig.enabled) continue
44
+
45
+ const appDef = getApp(appConfig.id)
46
+ if (!appDef) continue
47
+
48
+ const port = appConfig.port ?? appDef.defaultPort
49
+ const url = getAppUrl(appConfig.id, port, config, useLocalUrls)
50
+
51
+ const entry: BookmarkEntry = {
52
+ name: appDef.name,
53
+ url,
54
+ description: appDef.description,
55
+ }
56
+
57
+ const category = appDef.category
58
+ if (!categoryBookmarks.has(category)) {
59
+ categoryBookmarks.set(category, [])
60
+ }
61
+ categoryBookmarks.get(category)!.push(entry)
62
+ }
63
+
64
+ return categoryBookmarks
65
+ }
66
+
67
+ /**
68
+ * Generate Netscape-format HTML bookmarks
69
+ */
70
+ export function generateBookmarksHtml(config: EasiarrConfig, useLocalUrls = false): string {
71
+ const categoryBookmarks = generateBookmarksByCategory(config, useLocalUrls)
72
+
73
+ let html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
74
+ <META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
75
+ <TITLE>Bookmarks</TITLE>
76
+ <H1>Bookmarks</H1>
77
+ <DL><p>
78
+ <DT><H3 PERSONAL_TOOLBAR_FOLDER="true">easiarr</H3>
79
+ <DL><p>
80
+ `
81
+
82
+ // Add external resources first
83
+ html += ` <DT><A HREF="https://github.com/muhammedaksam/easiarr/">GitHub | easiarr Project Repo</A>\n`
84
+ html += ` <DT><A HREF="https://trash-guides.info/">TRaSH Guides</A>\n`
85
+
86
+ // Add apps grouped by category in defined order
87
+ for (const { id: categoryId } of CATEGORY_ORDER) {
88
+ const bookmarks = categoryBookmarks.get(categoryId)
89
+ if (!bookmarks || bookmarks.length === 0) continue
90
+
91
+ const categoryName = APP_CATEGORIES[categoryId]
92
+
93
+ // Add category header as a folder
94
+ html += ` <DT><H3>${categoryName}</H3>\n`
95
+ html += ` <DL><p>\n`
96
+
97
+ for (const bookmark of bookmarks) {
98
+ html += ` <DT><A HREF="${bookmark.url}">${bookmark.name} | ${bookmark.description}</A>\n`
99
+ }
100
+
101
+ html += ` </DL><p>\n`
102
+ }
103
+
104
+ // Close the structure
105
+ html += ` </DL><p>
106
+ </DL><p>
107
+ `
108
+
109
+ return html
110
+ }
111
+
112
+ /**
113
+ * Get the path to the bookmarks file
114
+ */
115
+ export function getBookmarksPath(): string {
116
+ return join(homedir(), ".easiarr", "bookmarks.html")
117
+ }
118
+
119
+ /**
120
+ * Save bookmarks HTML file
121
+ */
122
+ export async function saveBookmarks(config: EasiarrConfig, useLocalUrls = false): Promise<string> {
123
+ const html = generateBookmarksHtml(config, useLocalUrls)
124
+ const path = getBookmarksPath()
125
+ await writeFile(path, html, "utf-8")
126
+ return path
127
+ }
@@ -134,18 +134,18 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
134
134
  }
135
135
 
136
136
  // Build YAML output
137
- let yaml = "---\n# Auto-generated by Easiarr\n# https://github.com/muhammedaksam/easiarr\n\n"
137
+ let yaml = "---\n# Auto-generated by easiarr\n# https://github.com/muhammedaksam/easiarr\n\n"
138
138
 
139
- // Add Easiarr info section with two widgets - one for installed, one for latest
140
- yaml += `- Easiarr:\n`
141
- // Installed version from local easiarr-status container
139
+ // Add easiarr info section with two widgets - one for installed, one for latest
140
+ yaml += `- easiarr:\n`
141
+ // Installed version from local easiarr container
142
142
  yaml += ` - Installed:\n`
143
143
  yaml += ` href: https://github.com/muhammedaksam/easiarr\n`
144
144
  yaml += ` icon: mdi-docker\n`
145
145
  yaml += ` description: Your current version\n`
146
146
  yaml += ` widget:\n`
147
147
  yaml += ` type: customapi\n`
148
- yaml += ` url: http://easiarr-status:8080/config.json\n`
148
+ yaml += ` url: http://easiarr:8080/config.json\n`
149
149
  yaml += ` refreshInterval: 3600000\n` // 1 hour
150
150
  yaml += ` mappings:\n`
151
151
  yaml += ` - field: version\n`
@@ -237,10 +237,10 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
237
237
  */
238
238
  export function generateSettingsYaml(): string {
239
239
  return `---
240
- # Auto-generated by Easiarr
240
+ # Auto-generated by easiarr
241
241
  # For configuration options: https://gethomepage.dev/configs/settings/
242
242
 
243
- title: Easiarr Dashboard
243
+ title: easiarr Dashboard
244
244
 
245
245
  # Background: "Close-up Photography of Leaves With Droplets"
246
246
  # Photo by Sohail Nachiti: https://www.pexels.com/photo/close-up-photography-of-leaves-with-droplets-807598/
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Easiarr Configuration Schema
2
+ * easiarr Configuration Schema
3
3
  * TypeScript interfaces for configuration management
4
4
  */
5
5
 
@@ -65,6 +65,12 @@ export interface TraefikConfig {
65
65
  domain: string
66
66
  entrypoint: string
67
67
  middlewares: string[]
68
+ /** Basic auth using username/password */
69
+ basicAuth?: {
70
+ enabled: boolean
71
+ username: string
72
+ password: string
73
+ }
68
74
  }
69
75
 
70
76
  export interface AppConfig {
@@ -120,7 +126,7 @@ export type AppId =
120
126
  | "guacamole"
121
127
  | "guacd"
122
128
  | "ddns-updater"
123
- | "easiarr-status"
129
+ | "easiarr"
124
130
  // VPN
125
131
  | "gluetun"
126
132
  // Monitoring & Infra
@@ -133,6 +139,7 @@ export type AppId =
133
139
  // Reverse Proxy
134
140
  | "traefik"
135
141
  | "traefik-certs-dumper"
142
+ | "cloudflared"
136
143
  | "crowdsec"
137
144
  // Network/VPN
138
145
  | "headscale"
@@ -176,6 +183,8 @@ export interface AppDefinition {
176
183
  defaultPort: number
177
184
  /** Internal container port if different from defaultPort */
178
185
  internalPort?: number
186
+ /** Additional port mappings (e.g., "8083:8080" for dashboard ports) */
187
+ secondaryPorts?: string[]
179
188
  image: string
180
189
  puid: number
181
190
  pgid: number
@@ -186,6 +195,8 @@ export interface AppDefinition {
186
195
  secrets?: AppSecret[]
187
196
  devices?: string[]
188
197
  cap_add?: string[]
198
+ /** Custom command to run (e.g., "tunnel run" for cloudflared) */
199
+ command?: string
189
200
  apiKeyMeta?: ApiKeyMeta
190
201
  rootFolder?: RootFolderMeta
191
202
  prowlarrCategoryIds?: number[]
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Easiarr Entry Point
3
+ * easiarr Entry Point
4
4
  * TUI tool for generating docker-compose files for the *arr ecosystem
5
5
  *
6
6
  * Usage:
@@ -40,6 +40,7 @@ export class AppConfigurator extends BoxRenderable {
40
40
  // Global *arr credentials
41
41
  private globalUsername = "admin"
42
42
  private globalPassword = ""
43
+ private globalEmail = ""
43
44
  private overrideExisting = false
44
45
 
45
46
  // Download client credentials
@@ -85,6 +86,7 @@ export class AppConfigurator extends BoxRenderable {
85
86
  const env = readEnvSync()
86
87
  if (env.USERNAME_GLOBAL) this.globalUsername = env.USERNAME_GLOBAL
87
88
  this.globalPassword = env.PASSWORD_GLOBAL || "Ch4ng3m3!1234securityReasons"
89
+ if (env.EMAIL_GLOBAL) this.globalEmail = env.EMAIL_GLOBAL
88
90
  if (env.PASSWORD_QBITTORRENT) this.qbPass = env.PASSWORD_QBITTORRENT
89
91
  if (env.API_KEY_SABNZBD) this.sabApiKey = env.API_KEY_SABNZBD
90
92
  }
@@ -138,6 +140,19 @@ export class AppConfigurator extends BoxRenderable {
138
140
 
139
141
  content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
140
142
 
143
+ // Email input (for Cloudflare Access, notifications, etc.)
144
+ content.add(new TextRenderable(this.cliRenderer, { content: "Email (optional):", fg: "#aaaaaa" }))
145
+ const emailInput = new InputRenderable(this.cliRenderer, {
146
+ id: "global-email-input",
147
+ width: 40,
148
+ placeholder: "you@example.com",
149
+ value: this.globalEmail,
150
+ focusedBackgroundColor: "#1a1a1a",
151
+ })
152
+ content.add(emailInput)
153
+
154
+ content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
155
+
141
156
  // Override toggle
142
157
  const overrideText = new TextRenderable(this.cliRenderer, {
143
158
  id: "override-toggle",
@@ -160,13 +175,17 @@ export class AppConfigurator extends BoxRenderable {
160
175
  overrideText.content = `[O] Override existing: ${this.overrideExisting ? "Yes" : "No"}`
161
176
  overrideText.fg = this.overrideExisting ? "#50fa7b" : "#6272a4"
162
177
  } else if (key.name === "tab") {
163
- // Cycle focus: username -> password -> no focus (shortcuts work) -> username
178
+ // Cycle focus: username -> password -> email -> no focus (shortcuts work) -> username
164
179
  if (focusedInput === userInput) {
165
180
  userInput.blur()
166
181
  passInput.focus()
167
182
  focusedInput = passInput
168
183
  } else if (focusedInput === passInput) {
169
184
  passInput.blur()
185
+ emailInput.focus()
186
+ focusedInput = emailInput
187
+ } else if (focusedInput === emailInput) {
188
+ emailInput.blur()
170
189
  focusedInput = null // No focus state - shortcuts available
171
190
  } else {
172
191
  // No input focused, go back to username
@@ -178,6 +197,7 @@ export class AppConfigurator extends BoxRenderable {
178
197
  this.cliRenderer.keyInput.off("keypress", this.keyHandler)
179
198
  userInput.blur()
180
199
  passInput.blur()
200
+ emailInput.blur()
181
201
  focusedInput = null
182
202
  this.currentStep = "configure"
183
203
  this.runConfiguration()
@@ -185,10 +205,12 @@ export class AppConfigurator extends BoxRenderable {
185
205
  // Save and continue
186
206
  this.globalUsername = userInput.value || "admin"
187
207
  this.globalPassword = passInput.value
208
+ this.globalEmail = emailInput.value
188
209
 
189
210
  this.cliRenderer.keyInput.off("keypress", this.keyHandler)
190
211
  userInput.blur()
191
212
  passInput.blur()
213
+ emailInput.blur()
192
214
  focusedInput = null
193
215
 
194
216
  // Save credentials to .env
@@ -206,6 +228,7 @@ export class AppConfigurator extends BoxRenderable {
206
228
  const updates: Record<string, string> = {}
207
229
  if (this.globalUsername) updates.USERNAME_GLOBAL = this.globalUsername
208
230
  if (this.globalPassword) updates.PASSWORD_GLOBAL = this.globalPassword
231
+ if (this.globalEmail) updates.EMAIL_GLOBAL = this.globalEmail
209
232
  await updateEnv(updates)
210
233
  } catch {
211
234
  // Ignore errors - not critical
@@ -162,7 +162,7 @@ export class AppManager {
162
162
  this.config.traefik = {
163
163
  enabled: true,
164
164
  domain: "${CLOUDFLARE_DNS_ZONE}",
165
- entrypoint: "websecure",
165
+ entrypoint: "web",
166
166
  middlewares: [],
167
167
  }
168
168
  } else if (!enabled && this.config.traefik?.enabled) {