@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,571 @@
1
+ /**
2
+ * Settings Screen
3
+ * Edit Traefik, VPN, and system configuration
4
+ */
5
+
6
+ import type { CliRenderer, KeyEvent } from "@opentui/core"
7
+ import {
8
+ BoxRenderable,
9
+ TextRenderable,
10
+ SelectRenderable,
11
+ SelectRenderableEvents,
12
+ InputRenderable,
13
+ InputRenderableEvents,
14
+ } from "@opentui/core"
15
+ import type { EasiarrConfig, VpnMode } from "../../config/schema"
16
+ import { saveConfig } from "../../config"
17
+ import { saveCompose } from "../../compose"
18
+ import { createPageLayout } from "../components/PageLayout"
19
+
20
+ type SettingsSection = "traefik" | "vpn" | "system"
21
+
22
+ export class SettingsScreen extends BoxRenderable {
23
+ private config: EasiarrConfig
24
+ private onBack: () => void
25
+ private keyHandler: ((key: KeyEvent) => void) | null = null
26
+ private activeSection: SettingsSection = "traefik"
27
+ private page: BoxRenderable | null = null
28
+ private cliRenderer: CliRenderer
29
+
30
+ // Traefik settings (local copies to edit)
31
+ private traefikDomain: string
32
+ private traefikEntrypoint: string
33
+ private traefikMiddlewares: string
34
+
35
+ // VPN settings
36
+ private vpnMode: VpnMode
37
+
38
+ // System settings
39
+ private rootDir: string
40
+ private puid: string
41
+ private pgid: string
42
+ private timezone: string
43
+ private umask: string
44
+
45
+ constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
46
+ super(renderer, {
47
+ id: "settings-screen",
48
+ width: "100%",
49
+ height: "100%",
50
+ flexDirection: "column",
51
+ })
52
+
53
+ this.cliRenderer = renderer
54
+ this.config = config
55
+ this.onBack = onBack
56
+
57
+ // Initialize local copies from config
58
+ this.traefikDomain = config.traefik?.domain || "${CLOUDFLARE_DNS_ZONE}"
59
+ this.traefikEntrypoint = config.traefik?.entrypoint || "web"
60
+ this.traefikMiddlewares = config.traefik?.middlewares?.join(",") || ""
61
+
62
+ this.vpnMode = config.vpn?.mode || "none"
63
+
64
+ this.rootDir = config.rootDir
65
+ this.puid = config.uid.toString()
66
+ this.pgid = config.gid.toString()
67
+ this.timezone = config.timezone
68
+ this.umask = config.umask
69
+
70
+ this.renderContent()
71
+ }
72
+
73
+ private renderContent(): void {
74
+ // Clear previous
75
+ if (this.keyHandler) {
76
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
77
+ this.keyHandler = null
78
+ }
79
+
80
+ const children = this.getChildren()
81
+ for (const child of children) {
82
+ this.remove(child.id)
83
+ }
84
+
85
+ const { container: page, content } = createPageLayout(this.cliRenderer as CliRenderer, {
86
+ title: "Settings",
87
+ stepInfo: "Edit Traefik, VPN, and System configuration",
88
+ footerHint: [
89
+ { type: "key", key: "Tab", value: "Next" },
90
+ { type: "key", key: "Enter", value: "Select/Continue" },
91
+ { type: "key", key: "s", value: "Save" },
92
+ { type: "key", key: "Esc", value: "Back" },
93
+ ],
94
+ })
95
+ this.page = page
96
+
97
+ // Section tabs
98
+ const tabsBox = new BoxRenderable(this.cliRenderer, {
99
+ width: "100%",
100
+ height: 1,
101
+ flexDirection: "row",
102
+ marginBottom: 1,
103
+ })
104
+
105
+ const sections: { id: SettingsSection; label: string }[] = [
106
+ { id: "traefik", label: "Traefik" },
107
+ { id: "vpn", label: "VPN" },
108
+ { id: "system", label: "System" },
109
+ ]
110
+
111
+ for (const section of sections) {
112
+ const isActive = this.activeSection === section.id
113
+ tabsBox.add(
114
+ new TextRenderable(this.cliRenderer, {
115
+ content: ` ${section.label} `,
116
+ fg: isActive ? "#4a9eff" : "#666666",
117
+ marginRight: 2,
118
+ })
119
+ )
120
+ }
121
+
122
+ content.add(tabsBox)
123
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
124
+
125
+ // Render active section
126
+ switch (this.activeSection) {
127
+ case "traefik":
128
+ this.renderTraefikSection(content)
129
+ break
130
+ case "vpn":
131
+ this.renderVpnSection(content)
132
+ break
133
+ case "system":
134
+ this.renderSystemSection(content)
135
+ break
136
+ }
137
+
138
+ this.add(page)
139
+ }
140
+
141
+ private renderTraefikSection(content: BoxRenderable): void {
142
+ if (!this.config.traefik?.enabled) {
143
+ content.add(
144
+ new TextRenderable(this.cliRenderer, {
145
+ content: "Traefik is not enabled. Enable it in App Manager first.",
146
+ fg: "#ff6666",
147
+ })
148
+ )
149
+ this.setupBackHandler()
150
+ return
151
+ }
152
+
153
+ content.add(
154
+ new TextRenderable(this.cliRenderer, {
155
+ content: "Configure Traefik reverse proxy settings:",
156
+ fg: "#888888",
157
+ })
158
+ )
159
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
160
+
161
+ // Domain
162
+ const domainRow = new BoxRenderable(this.cliRenderer, {
163
+ width: "100%",
164
+ height: 1,
165
+ flexDirection: "row",
166
+ marginBottom: 1,
167
+ })
168
+ domainRow.add(
169
+ new TextRenderable(this.cliRenderer, {
170
+ content: "Domain:".padEnd(16),
171
+ fg: "#aaaaaa",
172
+ })
173
+ )
174
+ const domainInput = new InputRenderable(this.cliRenderer, {
175
+ id: "settings-traefik-domain",
176
+ width: 40,
177
+ placeholder: "${CLOUDFLARE_DNS_ZONE}",
178
+ backgroundColor: "#2a2a3e",
179
+ textColor: "#ffffff",
180
+ focusedBackgroundColor: "#3a3a4e",
181
+ })
182
+ domainInput.value = this.traefikDomain
183
+ domainInput.on(InputRenderableEvents.CHANGE, (v) => (this.traefikDomain = v))
184
+ domainRow.add(domainInput)
185
+ content.add(domainRow)
186
+
187
+ // Entrypoint
188
+ const epRow = new BoxRenderable(this.cliRenderer, {
189
+ width: "100%",
190
+ height: 1,
191
+ flexDirection: "row",
192
+ marginBottom: 1,
193
+ })
194
+ epRow.add(
195
+ new TextRenderable(this.cliRenderer, {
196
+ content: "Entrypoint:".padEnd(16),
197
+ fg: "#aaaaaa",
198
+ })
199
+ )
200
+ const epInput = new InputRenderable(this.cliRenderer, {
201
+ id: "settings-traefik-entrypoint",
202
+ width: 20,
203
+ placeholder: "web",
204
+ backgroundColor: "#2a2a3e",
205
+ textColor: "#ffffff",
206
+ focusedBackgroundColor: "#3a3a4e",
207
+ })
208
+ epInput.value = this.traefikEntrypoint
209
+ epInput.on(InputRenderableEvents.CHANGE, (v) => (this.traefikEntrypoint = v))
210
+ epRow.add(epInput)
211
+
212
+ // Hint for cloudflared
213
+ const cloudflaredEnabled = this.config.apps.some((a) => a.id === "cloudflared" && a.enabled)
214
+ if (cloudflaredEnabled && this.traefikEntrypoint === "websecure") {
215
+ epRow.add(
216
+ new TextRenderable(this.cliRenderer, {
217
+ content: " ⚠️ Use 'web' for Cloudflare Tunnel",
218
+ fg: "#ffcc00",
219
+ })
220
+ )
221
+ }
222
+ content.add(epRow)
223
+
224
+ // Middlewares
225
+ const mwRow = new BoxRenderable(this.cliRenderer, {
226
+ width: "100%",
227
+ height: 1,
228
+ flexDirection: "row",
229
+ marginBottom: 1,
230
+ })
231
+ mwRow.add(
232
+ new TextRenderable(this.cliRenderer, {
233
+ content: "Middlewares:".padEnd(16),
234
+ fg: "#aaaaaa",
235
+ })
236
+ )
237
+ const mwInput = new InputRenderable(this.cliRenderer, {
238
+ id: "settings-traefik-middlewares",
239
+ width: 50,
240
+ placeholder: "security-headers@file",
241
+ backgroundColor: "#2a2a3e",
242
+ textColor: "#ffffff",
243
+ focusedBackgroundColor: "#3a3a4e",
244
+ })
245
+ mwInput.value = this.traefikMiddlewares
246
+ mwInput.on(InputRenderableEvents.CHANGE, (v) => (this.traefikMiddlewares = v))
247
+ mwRow.add(mwInput)
248
+ content.add(mwRow)
249
+
250
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
251
+
252
+ // Navigation
253
+ const navMenu = new SelectRenderable(this.cliRenderer, {
254
+ id: "settings-traefik-nav",
255
+ width: "100%",
256
+ height: 4,
257
+ options: [
258
+ { name: "💾 Save Changes", description: "Save and regenerate docker-compose.yml" },
259
+ { name: "➡️ Next: VPN", description: "Go to VPN settings" },
260
+ { name: "◀ Back", description: "Return to main menu" },
261
+ ],
262
+ })
263
+
264
+ navMenu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
265
+ if (index === 0) {
266
+ await this.save()
267
+ } else if (index === 1) {
268
+ this.activeSection = "vpn"
269
+ this.renderContent()
270
+ } else {
271
+ this.cleanup()
272
+ this.onBack()
273
+ }
274
+ })
275
+
276
+ content.add(navMenu)
277
+
278
+ // Focus management
279
+ const inputs = [domainInput, epInput, mwInput, navMenu]
280
+ let focusIndex = 0
281
+ inputs[0].focus()
282
+
283
+ this.keyHandler = (key: KeyEvent) => {
284
+ if (key.name === "escape") {
285
+ this.cleanup()
286
+ this.onBack()
287
+ } else if (key.name === "s" && !key.ctrl) {
288
+ this.save()
289
+ } else if (key.name === "tab" || key.name === "enter") {
290
+ if (key.name === "enter" && focusIndex === inputs.length - 1) return
291
+ inputs[focusIndex].blur()
292
+ focusIndex = key.shift ? (focusIndex - 1 + inputs.length) % inputs.length : (focusIndex + 1) % inputs.length
293
+ inputs[focusIndex].focus()
294
+ }
295
+ }
296
+ ;(this.cliRenderer as CliRenderer).keyInput.on("keypress", this.keyHandler)
297
+ }
298
+
299
+ private renderVpnSection(content: BoxRenderable): void {
300
+ content.add(
301
+ new TextRenderable(this.cliRenderer, {
302
+ content: "Configure VPN routing through Gluetun:",
303
+ fg: "#888888",
304
+ })
305
+ )
306
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
307
+
308
+ const gluetunEnabled = this.config.apps.some((a) => a.id === "gluetun" && a.enabled)
309
+ if (!gluetunEnabled) {
310
+ content.add(
311
+ new TextRenderable(this.cliRenderer, {
312
+ content: "Gluetun VPN is not enabled. Enable it in App Manager first.",
313
+ fg: "#ff6666",
314
+ })
315
+ )
316
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
317
+ }
318
+
319
+ // Current mode display
320
+ const modeLabels: Record<VpnMode, string> = {
321
+ full: "🛡️ Full VPN",
322
+ mini: "⚡ Mini VPN",
323
+ none: "❌ No VPN Routing",
324
+ }
325
+ content.add(
326
+ new TextRenderable(this.cliRenderer, {
327
+ content: `Current mode: ${modeLabels[this.vpnMode]}`,
328
+ fg: "#50fa7b",
329
+ })
330
+ )
331
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
332
+
333
+ // Combined menu with VPN options + navigation
334
+ const menu = new SelectRenderable(this.cliRenderer, {
335
+ id: "settings-vpn-menu",
336
+ width: "100%",
337
+ height: 8,
338
+ options: [
339
+ { name: "🛡️ Full VPN", description: "Route Downloaders, Indexers, and Media Servers through VPN" },
340
+ { name: "⚡ Mini VPN", description: "Route ONLY Downloaders through VPN (Recommended)" },
341
+ { name: "❌ No VPN Routing", description: "Run container but don't route traffic" },
342
+ { name: "◀ Previous: Traefik", description: "Go to Traefik settings" },
343
+ { name: "➡️ Next: System", description: "Go to System settings" },
344
+ { name: "💾 Save Changes", description: "Save and regenerate docker-compose.yml" },
345
+ { name: "✕ Back", description: "Return to main menu" },
346
+ ],
347
+ })
348
+
349
+ const modes: VpnMode[] = ["full", "mini", "none"]
350
+
351
+ menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
352
+ if (index < 3) {
353
+ // VPN mode selection
354
+ this.vpnMode = modes[index]
355
+ this.renderContent() // Re-render to show selection
356
+ } else if (index === 3) {
357
+ this.activeSection = "traefik"
358
+ this.renderContent()
359
+ } else if (index === 4) {
360
+ this.activeSection = "system"
361
+ this.renderContent()
362
+ } else if (index === 5) {
363
+ await this.save()
364
+ } else {
365
+ this.cleanup()
366
+ this.onBack()
367
+ }
368
+ })
369
+
370
+ content.add(menu)
371
+ menu.focus()
372
+
373
+ this.keyHandler = (key: KeyEvent) => {
374
+ if (key.name === "escape") {
375
+ this.cleanup()
376
+ this.onBack()
377
+ }
378
+ }
379
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
380
+ }
381
+
382
+ private renderSystemSection(content: BoxRenderable): void {
383
+ content.add(
384
+ new TextRenderable(this.cliRenderer, {
385
+ content: "Configure system-wide settings:",
386
+ fg: "#888888",
387
+ })
388
+ )
389
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
390
+
391
+ const createField = (id: string, label: string, value: string, placeholder: string, width: number = 40) => {
392
+ const row = new BoxRenderable(this.cliRenderer, {
393
+ width: "100%",
394
+ height: 1,
395
+ flexDirection: "row",
396
+ marginBottom: 1,
397
+ })
398
+ row.add(
399
+ new TextRenderable(this.cliRenderer, {
400
+ content: label.padEnd(16),
401
+ fg: "#aaaaaa",
402
+ })
403
+ )
404
+ const input = new InputRenderable(this.cliRenderer, {
405
+ id,
406
+ width,
407
+ placeholder,
408
+ backgroundColor: "#2a2a3e",
409
+ textColor: "#ffffff",
410
+ focusedBackgroundColor: "#3a3a4e",
411
+ })
412
+ input.value = value
413
+ row.add(input)
414
+ content.add(row)
415
+ return input
416
+ }
417
+
418
+ const rootInput = createField("settings-sys-root", "Root Path:", this.rootDir, "/home/user/media", 50)
419
+ const puidInput = createField("settings-sys-puid", "PUID:", this.puid, "1000", 10)
420
+ const pgidInput = createField("settings-sys-pgid", "PGID:", this.pgid, "1000", 10)
421
+ const tzInput = createField("settings-sys-tz", "Timezone:", this.timezone, "Europe/London", 30)
422
+ const umaskInput = createField("settings-sys-umask", "Umask:", this.umask, "002", 10)
423
+
424
+ rootInput.on(InputRenderableEvents.CHANGE, (v) => (this.rootDir = v))
425
+ puidInput.on(InputRenderableEvents.CHANGE, (v) => (this.puid = v))
426
+ pgidInput.on(InputRenderableEvents.CHANGE, (v) => (this.pgid = v))
427
+ tzInput.on(InputRenderableEvents.CHANGE, (v) => (this.timezone = v))
428
+ umaskInput.on(InputRenderableEvents.CHANGE, (v) => (this.umask = v))
429
+
430
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
431
+
432
+ const navMenu = new SelectRenderable(this.cliRenderer, {
433
+ id: "settings-sys-nav",
434
+ width: "100%",
435
+ height: 4,
436
+ options: [
437
+ { name: "💾 Save All Changes", description: "Save config and regenerate docker-compose.yml" },
438
+ { name: "◀ Back", description: "Return to main menu" },
439
+ ],
440
+ })
441
+
442
+ navMenu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
443
+ if (index === 0) {
444
+ await this.save()
445
+ } else {
446
+ this.cleanup()
447
+ this.onBack()
448
+ }
449
+ })
450
+
451
+ content.add(navMenu)
452
+
453
+ const inputs = [rootInput, puidInput, pgidInput, tzInput, umaskInput, navMenu]
454
+ let focusIndex = 0
455
+ inputs[0].focus()
456
+
457
+ this.keyHandler = (key: KeyEvent) => {
458
+ if (key.name === "escape") {
459
+ this.cleanup()
460
+ this.onBack()
461
+ } else if (key.name === "s" && !key.ctrl) {
462
+ this.save()
463
+ } else if (key.name === "tab" || key.name === "enter") {
464
+ if (key.name === "enter" && focusIndex === inputs.length - 1) return
465
+ inputs[focusIndex].blur()
466
+ focusIndex = key.shift ? (focusIndex - 1 + inputs.length) % inputs.length : (focusIndex + 1) % inputs.length
467
+ inputs[focusIndex].focus()
468
+ }
469
+ }
470
+ ;(this.cliRenderer as CliRenderer).keyInput.on("keypress", this.keyHandler)
471
+ }
472
+
473
+ private setupSectionNav(content: BoxRenderable, prev: SettingsSection, next: SettingsSection): void {
474
+ const navMenu = new SelectRenderable(this.cliRenderer, {
475
+ id: "settings-section-nav",
476
+ width: "100%",
477
+ height: 4,
478
+ options: [
479
+ { name: "◀ Previous", description: `Go to ${prev} settings` },
480
+ { name: "➡️ Next", description: `Go to ${next} settings` },
481
+ { name: "💾 Save", description: "Save all changes" },
482
+ { name: "✕ Back", description: "Return to main menu" },
483
+ ],
484
+ })
485
+
486
+ navMenu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
487
+ if (index === 0) {
488
+ this.activeSection = prev
489
+ this.renderContent()
490
+ } else if (index === 1) {
491
+ this.activeSection = next
492
+ this.renderContent()
493
+ } else if (index === 2) {
494
+ await this.save()
495
+ } else {
496
+ this.cleanup()
497
+ this.onBack()
498
+ }
499
+ })
500
+
501
+ content.add(navMenu)
502
+ }
503
+
504
+ private setupBackHandler(): void {
505
+ const navMenu = new SelectRenderable(this.cliRenderer, {
506
+ id: "settings-back-nav",
507
+ width: "100%",
508
+ height: 2,
509
+ options: [{ name: "◀ Back", description: "Return to main menu" }],
510
+ })
511
+
512
+ navMenu.on(SelectRenderableEvents.ITEM_SELECTED, () => {
513
+ this.cleanup()
514
+ this.onBack()
515
+ })
516
+
517
+ if (this.page) {
518
+ const content = this.page.getChildren()[0] as BoxRenderable
519
+ content?.add(navMenu)
520
+ }
521
+ navMenu.focus()
522
+
523
+ this.keyHandler = (key: KeyEvent) => {
524
+ if (key.name === "escape") {
525
+ this.cleanup()
526
+ this.onBack()
527
+ }
528
+ }
529
+ ;(this.cliRenderer as CliRenderer).keyInput.on("keypress", this.keyHandler)
530
+ }
531
+
532
+ private async save(): Promise<void> {
533
+ // Update config with local values
534
+ if (this.config.traefik) {
535
+ this.config.traefik.domain = this.traefikDomain
536
+ this.config.traefik.entrypoint = this.traefikEntrypoint
537
+ this.config.traefik.middlewares = this.traefikMiddlewares
538
+ .split(",")
539
+ .map((m) => m.trim())
540
+ .filter((m) => m)
541
+ }
542
+
543
+ if (this.config.vpn) {
544
+ this.config.vpn.mode = this.vpnMode
545
+ }
546
+
547
+ this.config.rootDir = this.rootDir
548
+ this.config.uid = parseInt(this.puid, 10) || 1000
549
+ this.config.gid = parseInt(this.pgid, 10) || 1000
550
+ this.config.timezone = this.timezone
551
+ this.config.umask = this.umask
552
+ this.config.updatedAt = new Date().toISOString()
553
+
554
+ await saveConfig(this.config)
555
+ await saveCompose(this.config)
556
+
557
+ this.cleanup()
558
+ this.onBack()
559
+ }
560
+
561
+ private cleanup(): void {
562
+ if (this.keyHandler) {
563
+ ;(this.cliRenderer as CliRenderer).keyInput.off("keypress", this.keyHandler)
564
+ this.keyHandler = null
565
+ }
566
+ const parent = this.parent
567
+ if (parent) {
568
+ parent.remove(this.id)
569
+ }
570
+ }
571
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Browser Utilities
3
+ * Open URLs in the default browser
4
+ */
5
+
6
+ import { $ } from "bun"
7
+ import { platform } from "node:os"
8
+
9
+ /**
10
+ * Open a URL in the default browser
11
+ */
12
+ export async function openUrl(url: string): Promise<void> {
13
+ const os = platform()
14
+
15
+ try {
16
+ if (os === "linux") {
17
+ await $`xdg-open ${url}`.quiet()
18
+ } else if (os === "darwin") {
19
+ await $`open ${url}`.quiet()
20
+ } else if (os === "win32") {
21
+ await $`cmd /c start ${url}`.quiet()
22
+ }
23
+ } catch {
24
+ // Silently fail if browser can't be opened
25
+ }
26
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Debug logging utility for Easiarr
2
+ * Debug logging utility for easiarr
3
3
  *
4
4
  * Enable debug logging via:
5
5
  * - CLI flag: easiarr --debug
@@ -25,7 +25,7 @@ const logFile = join(easiarrDir, "debug.log")
25
25
  export function initDebug(): void {
26
26
  if (!DEBUG_ENABLED) return
27
27
  try {
28
- writeFileSync(logFile, `=== Easiarr Debug Log - ${new Date().toISOString()} ===\n`)
28
+ writeFileSync(logFile, `=== easiarr Debug Log - ${new Date().toISOString()} ===\n`)
29
29
  } catch {
30
30
  // Ignore
31
31
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Migration: rename easiarr-status to easiarr
3
+ *
4
+ * OLD: { "id": "easiarr-status", "enabled": true }
5
+ * NEW: { "id": "easiarr", "enabled": true }
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
9
+ import { join } from "node:path"
10
+ import { homedir } from "node:os"
11
+ import { debugLog } from "../debug"
12
+
13
+ const CONFIG_FILE = join(homedir(), ".easiarr", "config.json")
14
+
15
+ export const name = "rename_easiarr_status"
16
+
17
+ export function up(): boolean {
18
+ if (!existsSync(CONFIG_FILE)) {
19
+ debugLog("Migrations", "No config.json file found, skipping migration")
20
+ return false
21
+ }
22
+
23
+ try {
24
+ const content = readFileSync(CONFIG_FILE, "utf-8")
25
+ const config = JSON.parse(content)
26
+
27
+ if (!config.apps || !Array.isArray(config.apps)) {
28
+ debugLog("Migrations", "No apps array in config, skipping migration")
29
+ return false
30
+ }
31
+
32
+ let changed = false
33
+
34
+ for (const app of config.apps) {
35
+ if (app.id === "easiarr-status") {
36
+ app.id = "easiarr"
37
+ changed = true
38
+ debugLog("Migrations", "Renamed easiarr-status → easiarr")
39
+ }
40
+ }
41
+
42
+ if (changed) {
43
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8")
44
+ debugLog("Migrations", "Migration completed: app id renamed")
45
+ return true
46
+ }
47
+
48
+ debugLog("Migrations", "No changes needed")
49
+ return false
50
+ } catch (e) {
51
+ debugLog("Migrations", `Migration error: ${e}`)
52
+ return false
53
+ }
54
+ }
55
+
56
+ export function down(): boolean {
57
+ if (!existsSync(CONFIG_FILE)) {
58
+ return false
59
+ }
60
+
61
+ try {
62
+ const content = readFileSync(CONFIG_FILE, "utf-8")
63
+ const config = JSON.parse(content)
64
+
65
+ if (!config.apps || !Array.isArray(config.apps)) {
66
+ return false
67
+ }
68
+
69
+ let changed = false
70
+
71
+ for (const app of config.apps) {
72
+ if (app.id === "easiarr") {
73
+ app.id = "easiarr-status"
74
+ changed = true
75
+ debugLog("Migrations", "Rolled back easiarr → easiarr-status")
76
+ }
77
+ }
78
+
79
+ if (changed) {
80
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8")
81
+ debugLog("Migrations", "Rollback completed: app id restored")
82
+ return true
83
+ }
84
+
85
+ return false
86
+ } catch (e) {
87
+ debugLog("Migrations", `Rollback error: ${e}`)
88
+ return false
89
+ }
90
+ }