@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.
- package/README.md +24 -0
- package/package.json +1 -1
- package/src/api/cloudflare-api.ts +368 -0
- package/src/api/jellyfin-api.ts +1 -1
- package/src/apps/registry.ts +43 -12
- package/src/compose/generator.ts +27 -4
- package/src/compose/index.ts +1 -0
- package/src/compose/templates.ts +5 -0
- package/src/compose/traefik-config.ts +174 -0
- package/src/config/bookmarks-generator.ts +127 -0
- package/src/config/homepage-config.ts +7 -7
- package/src/config/schema.ts +13 -2
- package/src/index.ts +1 -1
- package/src/ui/screens/AppConfigurator.ts +24 -1
- package/src/ui/screens/AppManager.ts +1 -1
- package/src/ui/screens/CloudflaredSetup.ts +758 -0
- package/src/ui/screens/FullAutoSetup.ts +72 -0
- package/src/ui/screens/JellyfinSetup.ts +1 -1
- package/src/ui/screens/MainMenu.ts +45 -1
- package/src/ui/screens/QuickSetup.ts +7 -7
- package/src/ui/screens/SettingsScreen.ts +571 -0
- package/src/utils/browser.ts +26 -0
- package/src/utils/debug.ts +2 -2
- package/src/utils/migrations/1765707135_rename_easiarr_status.ts +90 -0
- package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +44 -0
- package/src/utils/migrations.ts +24 -0
|
@@ -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
|
+
}
|
package/src/utils/debug.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Debug logging utility for
|
|
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, `===
|
|
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
|
+
}
|