@muhammedaksam/easiarr 0.1.1 → 0.1.5

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.1.1",
3
+ "version": "0.1.5",
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",
@@ -94,6 +94,10 @@ export const APPS: Record<AppId, AppDefinition> = {
94
94
  path: "/data/media/books",
95
95
  apiVersion: "v1",
96
96
  },
97
+ arch: {
98
+ deprecated: ["arm64", "arm32"],
99
+ warning: "Readarr is deprecated - no ARM64 support (project abandoned by upstream)",
100
+ },
97
101
  },
98
102
 
99
103
  bazarr: {
@@ -109,9 +113,9 @@ export const APPS: Record<AppId, AppDefinition> = {
109
113
  dependsOn: ["sonarr", "radarr"],
110
114
  trashGuide: "docs/Bazarr/",
111
115
  apiKeyMeta: {
112
- configFile: "config/config.ini",
113
- parser: "regex",
114
- selector: "apikey\\s*=\\s*(.+)",
116
+ configFile: "config/config.yaml",
117
+ parser: "yaml",
118
+ selector: "auth.apikey",
115
119
  },
116
120
  },
117
121
 
@@ -127,8 +131,15 @@ export const APPS: Record<AppId, AppDefinition> = {
127
131
  volumes: (root) => [`${root}/config/mylar3:/config`, `${root}/data:/data`],
128
132
  apiKeyMeta: {
129
133
  configFile: "mylar/config.ini",
130
- parser: "regex",
131
- selector: "api_key\\s*=\\s*(.+)",
134
+ parser: "ini",
135
+ section: "API",
136
+ selector: "api_key",
137
+ enabledKey: "api_enabled",
138
+ generateIfMissing: true,
139
+ },
140
+ rootFolder: {
141
+ path: "/data/media/comics",
142
+ apiVersion: "v1",
132
143
  },
133
144
  },
134
145
 
@@ -138,7 +149,7 @@ export const APPS: Record<AppId, AppDefinition> = {
138
149
  description: "Adult media collection manager",
139
150
  category: "servarr",
140
151
  defaultPort: 6969,
141
- image: "hotio/whisparr:nightly",
152
+ image: "ghcr.io/hotio/whisparr:nightly",
142
153
  puid: 13015,
143
154
  pgid: 13000,
144
155
  volumes: (root) => [`${root}/config/whisparr:/config`, `${root}/data:/data`],
@@ -866,3 +877,32 @@ export function getAllApps(): AppDefinition[] {
866
877
  export function getAppIds(): AppId[] {
867
878
  return Object.keys(APPS) as AppId[]
868
879
  }
880
+
881
+ import { getSystemArch, isAppCompatible, getArchWarning, isAppDeprecated } from "../util/arch"
882
+
883
+ /**
884
+ * Get all apps compatible with the current system architecture
885
+ */
886
+ export function getCompatibleApps(): AppDefinition[] {
887
+ const arch = getSystemArch()
888
+ return Object.values(APPS).filter((app) => isAppCompatible(app, arch))
889
+ }
890
+
891
+ /**
892
+ * Get apps that have warnings for the current architecture (deprecated but may work)
893
+ */
894
+ export function getAppsWithArchWarnings(): { app: AppDefinition; warning: string }[] {
895
+ const arch = getSystemArch()
896
+ const result: { app: AppDefinition; warning: string }[] = []
897
+
898
+ for (const app of Object.values(APPS)) {
899
+ const warning = getArchWarning(app, arch)
900
+ if (warning) {
901
+ result.push({ app, warning })
902
+ }
903
+ }
904
+
905
+ return result
906
+ }
907
+
908
+ export { getSystemArch, isAppCompatible, getArchWarning, isAppDeprecated }
@@ -123,6 +123,17 @@ export type AppCategory =
123
123
  | "monitoring"
124
124
  | "infrastructure"
125
125
 
126
+ export type Architecture = "x64" | "arm64" | "arm32"
127
+
128
+ export interface ArchCompatibility {
129
+ /** Architectures with full support */
130
+ supported?: Architecture[]
131
+ /** Architectures with deprecated/broken support - will show warning */
132
+ deprecated?: Architecture[]
133
+ /** Warning message to show for deprecated architectures */
134
+ warning?: string
135
+ }
136
+
126
137
  export interface AppDefinition {
127
138
  id: AppId
128
139
  name: string
@@ -141,6 +152,8 @@ export interface AppDefinition {
141
152
  cap_add?: string[]
142
153
  apiKeyMeta?: ApiKeyMeta
143
154
  rootFolder?: RootFolderMeta
155
+ /** Architecture compatibility info - omit if supports all */
156
+ arch?: ArchCompatibility
144
157
  }
145
158
 
146
159
  export interface RootFolderMeta {
@@ -151,12 +164,18 @@ export interface RootFolderMeta {
151
164
  export interface ApiKeyMeta {
152
165
  configFile: string // Relative to config volume root
153
166
  parser: ApiKeyParserType
154
- selector: string // Regex group 1, or XML tag, or INI key, or JSON path
167
+ selector: string // Regex group 1, or XML tag, or INI key, JSON/YAML dot path
168
+ /** INI section name (for parser: "ini") */
169
+ section?: string
170
+ /** INI key that controls if API is enabled (for parser: "ini") */
171
+ enabledKey?: string
172
+ /** Generate API key if missing or None (for apps like Mylar3) */
173
+ generateIfMissing?: boolean
155
174
  description?: string
156
175
  transform?: (value: string) => string
157
176
  }
158
177
 
159
- export type ApiKeyParserType = "xml" | "ini" | "json" | "regex"
178
+ export type ApiKeyParserType = "xml" | "ini" | "json" | "yaml" | "regex"
160
179
 
161
180
  export interface AppSecret {
162
181
  name: string
@@ -11,7 +11,7 @@ import {
11
11
  } from "@opentui/core"
12
12
  import { AppId } from "../../config/schema"
13
13
  import { CATEGORY_ORDER } from "../../apps/categories"
14
- import { getAppsByCategory } from "../../apps"
14
+ import { getAppsByCategory, getArchWarning } from "../../apps"
15
15
 
16
16
  export interface ApplicationSelectorOptions extends BoxOptions {
17
17
  selectedApps: Set<AppId>
@@ -139,10 +139,15 @@ export class ApplicationSelector extends BoxRenderable {
139
139
  const category = CATEGORY_ORDER[this.currentCategoryIndex]
140
140
  const apps = getAppsByCategory()[category.id] || []
141
141
 
142
- const options = apps.map((app) => ({
143
- name: `${this.selectedApps.has(app.id) ? "[✓]" : "[ ]"} ${app.name}`,
144
- description: `Port ${app.defaultPort} - ${app.description}`,
145
- }))
142
+ const options = apps.map((app) => {
143
+ const archWarning = getArchWarning(app)
144
+ const checkmark = this.selectedApps.has(app.id) ? "[✓]" : "[ ]"
145
+ const warnIcon = archWarning ? " ⚠️" : ""
146
+ return {
147
+ name: `${checkmark} ${app.name}${warnIcon}`,
148
+ description: archWarning ? `⚠️ ${archWarning}` : `Port ${app.defaultPort} - ${app.description}`,
149
+ }
150
+ })
146
151
 
147
152
  this.appList.options = options
148
153
  }
@@ -177,6 +182,17 @@ export class ApplicationSelector extends BoxRenderable {
177
182
  check(["overseerr", "jellyseerr"], "Multiple request managers")
178
183
  check(["prowlarr", "jackett"], "Multiple indexers")
179
184
 
185
+ // Architecture warnings for selected apps
186
+ const allApps = Object.values(getAppsByCategory()).flat()
187
+ for (const app of allApps) {
188
+ if (this.selectedApps.has(app.id)) {
189
+ const archWarn = getArchWarning(app)
190
+ if (archWarn) {
191
+ warnings.push(archWarn)
192
+ }
193
+ }
194
+ }
195
+
180
196
  return warnings
181
197
  }
182
198
 
@@ -1,15 +1,112 @@
1
1
  import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
2
- import { existsSync, readFileSync } from "node:fs"
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
3
3
  import { writeFile, readFile } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
+ import { randomBytes } from "node:crypto"
6
+ import { parse as parseYaml } from "yaml"
5
7
  import { createPageLayout } from "../components/PageLayout"
6
- import { EasiarrConfig } from "../../config/schema"
8
+ import { EasiarrConfig, AppDefinition } from "../../config/schema"
7
9
  import { getApp } from "../../apps/registry"
8
10
  import { getComposePath } from "../../config/manager"
9
11
 
12
+ /** Generate a random 32-character hex API key */
13
+ function generateApiKey(): string {
14
+ return randomBytes(16).toString("hex")
15
+ }
16
+
17
+ /** Get nested value from object using dot notation */
18
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
19
+ return path.split(".").reduce<unknown>((o, k) => (o as Record<string, unknown>)?.[k], obj)
20
+ }
21
+
22
+ /** Parse INI file and get value from section.key */
23
+ function parseIniValue(content: string, section: string, key: string): string | null {
24
+ const lines = content.split("\n")
25
+ let inSection = false
26
+
27
+ for (const line of lines) {
28
+ const trimmed = line.trim()
29
+
30
+ // Check section header
31
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
32
+ const sectionName = trimmed.slice(1, -1)
33
+ inSection = sectionName.toLowerCase() === section.toLowerCase()
34
+ continue
35
+ }
36
+
37
+ // Parse key=value in current section
38
+ if (inSection && trimmed.includes("=")) {
39
+ const [k, ...valueParts] = trimmed.split("=")
40
+ if (k.trim().toLowerCase() === key.toLowerCase()) {
41
+ let value = valueParts.join("=").trim()
42
+ // Remove quotes if present
43
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
44
+ value = value.slice(1, -1)
45
+ }
46
+ return value
47
+ }
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ /** Update INI file with new values for section */
54
+ function updateIniValue(content: string, section: string, updates: Record<string, string>): string {
55
+ const lines = content.split("\n")
56
+ const result: string[] = []
57
+ let inSection = false
58
+ const updatedKeys = new Set<string>()
59
+
60
+ for (const line of lines) {
61
+ const trimmed = line.trim()
62
+
63
+ // Check section header
64
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
65
+ // Before leaving current section, add any missing keys
66
+ if (inSection) {
67
+ for (const [k, v] of Object.entries(updates)) {
68
+ if (!updatedKeys.has(k.toLowerCase())) {
69
+ result.push(`${k} = ${v}`)
70
+ }
71
+ }
72
+ }
73
+ const sectionName = trimmed.slice(1, -1)
74
+ inSection = sectionName.toLowerCase() === section.toLowerCase()
75
+ result.push(line)
76
+ continue
77
+ }
78
+
79
+ // Update key=value in current section
80
+ if (inSection && trimmed.includes("=")) {
81
+ const [k] = trimmed.split("=")
82
+ const keyName = k.trim()
83
+ const keyLower = keyName.toLowerCase()
84
+
85
+ let handled = false
86
+ for (const [updateKey, updateValue] of Object.entries(updates)) {
87
+ if (updateKey.toLowerCase() === keyLower) {
88
+ result.push(`${keyName} = ${updateValue}`)
89
+ updatedKeys.add(keyLower)
90
+ handled = true
91
+ break
92
+ }
93
+ }
94
+ if (!handled) {
95
+ result.push(line)
96
+ }
97
+ } else {
98
+ result.push(line)
99
+ }
100
+ }
101
+
102
+ return result.join("\n")
103
+ }
104
+
105
+ type KeyStatus = "found" | "missing" | "error" | "generated"
106
+
10
107
  export class ApiKeyViewer extends BoxRenderable {
11
108
  private config: EasiarrConfig
12
- private keys: Array<{ appId: string; app: string; key: string; status: "found" | "missing" | "error" }> = []
109
+ private keys: Array<{ appId: string; app: string; key: string; status: KeyStatus }> = []
13
110
  private keyHandler!: (key: KeyEvent) => void
14
111
  private cliRenderer: CliRenderer
15
112
  private statusText: TextRenderable | null = null
@@ -19,8 +116,8 @@ export class ApiKeyViewer extends BoxRenderable {
19
116
  id: "api-key-viewer",
20
117
  width: "100%",
21
118
  height: "100%",
22
- backgroundColor: "#111111", // Dark bg
23
- zIndex: 200, // Above main menu
119
+ backgroundColor: "#111111",
120
+ zIndex: 200,
24
121
  })
25
122
  this.cliRenderer = renderer
26
123
  this.config = config
@@ -39,38 +136,17 @@ export class ApiKeyViewer extends BoxRenderable {
39
136
  if (!appDef || !appDef.apiKeyMeta) continue
40
137
 
41
138
  try {
42
- // Resolve config path
43
- // Volumes are: ["${root}/config/radarr:/config", ...]
44
- // We assume index 0 is the config volume
45
139
  const volumes = appDef.volumes(this.config.rootDir)
46
140
  if (volumes.length === 0) continue
47
141
 
48
142
  const parts = volumes[0].split(":")
49
143
  const hostPath = parts[0]
50
-
51
144
  const configFilePath = join(hostPath, appDef.apiKeyMeta.configFile)
52
145
 
53
146
  if (existsSync(configFilePath)) {
54
147
  const content = readFileSync(configFilePath, "utf-8")
55
-
56
- if (appDef.apiKeyMeta.parser === "regex") {
57
- const regex = new RegExp(appDef.apiKeyMeta.selector)
58
- const match = regex.exec(content)
59
- if (match && match[1]) {
60
- this.keys.push({ appId: appDef.id, app: appDef.name, key: match[1], status: "found" })
61
- } else {
62
- this.keys.push({ appId: appDef.id, app: appDef.name, key: "Not found in file", status: "error" })
63
- }
64
- } else if (appDef.apiKeyMeta.parser === "json") {
65
- const json = JSON.parse(content)
66
- // Support dot notation like "main.apiKey"
67
- const value = appDef.apiKeyMeta.selector.split(".").reduce((obj, key) => obj?.[key], json)
68
- if (value && typeof value === "string") {
69
- this.keys.push({ appId: appDef.id, app: appDef.name, key: value, status: "found" })
70
- } else {
71
- this.keys.push({ appId: appDef.id, app: appDef.name, key: "Key not found in JSON", status: "error" })
72
- }
73
- }
148
+ const result = this.extractApiKey(appDef, content, configFilePath)
149
+ this.keys.push({ appId: appDef.id, app: appDef.name, ...result })
74
150
  } else {
75
151
  this.keys.push({
76
152
  appId: appDef.id,
@@ -79,14 +155,82 @@ export class ApiKeyViewer extends BoxRenderable {
79
155
  status: "missing",
80
156
  })
81
157
  }
82
- } catch {
83
- this.keys.push({ appId: appDef.id, app: appDef.name, key: "Error reading file", status: "error" })
158
+ } catch (e) {
159
+ this.keys.push({ appId: appDef.id, app: appDef.name, key: `Error: ${e}`, status: "error" })
160
+ }
161
+ }
162
+ }
163
+
164
+ private extractApiKey(
165
+ appDef: AppDefinition,
166
+ content: string,
167
+ configFilePath: string
168
+ ): { key: string; status: "found" | "error" | "generated" } {
169
+ const meta = appDef.apiKeyMeta!
170
+
171
+ switch (meta.parser) {
172
+ case "regex": {
173
+ const regex = new RegExp(meta.selector)
174
+ const match = regex.exec(content)
175
+ if (match && match[1]) {
176
+ return { key: match[1], status: "found" }
177
+ }
178
+ return { key: "Not found in file", status: "error" }
179
+ }
180
+
181
+ case "json": {
182
+ const json = JSON.parse(content)
183
+ const value = getNestedValue(json, meta.selector)
184
+ if (value && typeof value === "string") {
185
+ return { key: value, status: "found" }
186
+ }
187
+ return { key: "Key not found in JSON", status: "error" }
84
188
  }
189
+
190
+ case "yaml": {
191
+ const yaml = parseYaml(content) as Record<string, unknown>
192
+ const value = getNestedValue(yaml, meta.selector)
193
+ if (value && typeof value === "string") {
194
+ return { key: value, status: "found" }
195
+ }
196
+ return { key: "Key not found in YAML", status: "error" }
197
+ }
198
+
199
+ case "ini": {
200
+ const section = meta.section || "General"
201
+ const value = parseIniValue(content, section, meta.selector)
202
+
203
+ // Check if API is enabled and if we need to generate
204
+ if (meta.enabledKey) {
205
+ const enabled = parseIniValue(content, section, meta.enabledKey)
206
+ const isDisabled = !enabled || enabled.toLowerCase() === "false" || enabled === "0"
207
+ const needsGeneration = !value || value.toLowerCase() === "none" || value === ""
208
+
209
+ if (meta.generateIfMissing && (isDisabled || needsGeneration)) {
210
+ const newKey = generateApiKey()
211
+ const updates: Record<string, string> = { [meta.selector]: newKey }
212
+ if (meta.enabledKey) {
213
+ updates[meta.enabledKey] = "True"
214
+ }
215
+ const newContent = updateIniValue(content, section, updates)
216
+ writeFileSync(configFilePath, newContent, "utf-8")
217
+ return { key: newKey, status: "generated" }
218
+ }
219
+ }
220
+
221
+ if (value && value.toLowerCase() !== "none" && value !== "") {
222
+ return { key: value, status: "found" }
223
+ }
224
+ return { key: "API key not configured", status: "error" }
225
+ }
226
+
227
+ default:
228
+ return { key: `Unknown parser: ${meta.parser}`, status: "error" }
85
229
  }
86
230
  }
87
231
 
88
232
  private renderPage(onBack: () => void) {
89
- const foundKeys = this.keys.filter((k) => k.status === "found")
233
+ const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
90
234
  const hasFoundKeys = foundKeys.length > 0
91
235
 
92
236
  const { container, content } = createPageLayout(this.cliRenderer, {
@@ -126,19 +270,28 @@ export class ApiKeyViewer extends BoxRenderable {
126
270
  marginBottom: 0,
127
271
  })
128
272
 
273
+ // Status color
274
+ let color = "#6272a4"
275
+ if (k.status === "found") color = "#50fa7b"
276
+ else if (k.status === "generated") color = "#8be9fd"
277
+ else if (k.status === "error") color = "#ff5555"
278
+
129
279
  // App Name
130
280
  row.add(
131
281
  new TextRenderable(this.cliRenderer, {
132
282
  content: k.app.padEnd(20),
133
- fg: k.status === "found" ? "#50fa7b" : "#ff5555",
283
+ fg: color,
134
284
  })
135
285
  )
136
286
 
137
- // Key
287
+ // Key with status indicator
288
+ let keyDisplay = k.key
289
+ if (k.status === "generated") keyDisplay = `${k.key} (generated)`
290
+
138
291
  row.add(
139
292
  new TextRenderable(this.cliRenderer, {
140
- content: k.key,
141
- fg: k.status === "found" ? "#f1fa8c" : "#6272a4",
293
+ content: keyDisplay,
294
+ fg: k.status === "found" || k.status === "generated" ? "#f1fa8c" : "#6272a4",
142
295
  })
143
296
  )
144
297
  content.add(row)
@@ -180,7 +333,7 @@ export class ApiKeyViewer extends BoxRenderable {
180
333
  }
181
334
 
182
335
  private async saveToEnv() {
183
- const foundKeys = this.keys.filter((k) => k.status === "found")
336
+ const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
184
337
  if (foundKeys.length === 0) return
185
338
 
186
339
  try {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Architecture Detection Utility
3
+ * Detects system architecture and checks app compatibility
4
+ */
5
+
6
+ import type { AppDefinition, Architecture } from "../config/schema"
7
+
8
+ /**
9
+ * Get the current system architecture
10
+ */
11
+ export function getSystemArch(): Architecture {
12
+ const arch = process.arch
13
+ switch (arch) {
14
+ case "x64":
15
+ case "ia32":
16
+ return "x64"
17
+ case "arm64":
18
+ return "arm64"
19
+ case "arm":
20
+ return "arm32"
21
+ default:
22
+ return "x64" // Default to x64 for unknown
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Check if an app is compatible with the given architecture
28
+ * Returns true if compatible (no issues), false if deprecated/broken
29
+ */
30
+ export function isAppCompatible(app: AppDefinition, arch?: Architecture): boolean {
31
+ const systemArch = arch ?? getSystemArch()
32
+
33
+ if (!app.arch) {
34
+ return true // No arch restrictions = supports all
35
+ }
36
+
37
+ // Check if explicitly deprecated
38
+ if (app.arch.deprecated?.includes(systemArch)) {
39
+ return false
40
+ }
41
+
42
+ // If supported list exists, check if current arch is in it
43
+ if (app.arch.supported && !app.arch.supported.includes(systemArch)) {
44
+ return false
45
+ }
46
+
47
+ return true
48
+ }
49
+
50
+ /**
51
+ * Get warning message for an app on the current architecture
52
+ * Returns null if no warning
53
+ */
54
+ export function getArchWarning(app: AppDefinition, arch?: Architecture): string | null {
55
+ const systemArch = arch ?? getSystemArch()
56
+
57
+ if (!app.arch) {
58
+ return null
59
+ }
60
+
61
+ if (app.arch.deprecated?.includes(systemArch)) {
62
+ return app.arch.warning || `${app.name} has deprecated support for ${systemArch}`
63
+ }
64
+
65
+ if (app.arch.supported && !app.arch.supported.includes(systemArch)) {
66
+ return `${app.name} does not support ${systemArch} architecture`
67
+ }
68
+
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * Check if app is deprecated (but might still work)
74
+ */
75
+ export function isAppDeprecated(app: AppDefinition, arch?: Architecture): boolean {
76
+ const systemArch = arch ?? getSystemArch()
77
+ return app.arch?.deprecated?.includes(systemArch) ?? false
78
+ }