@muhammedaksam/easiarr 1.3.5 → 1.4.1

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.
Files changed (94) hide show
  1. package/README.md +8 -5
  2. package/dist/index.js +29837 -0
  3. package/package.json +18 -16
  4. package/src/VersionInfo.ts +0 -12
  5. package/src/api/arr-api.ts +0 -508
  6. package/src/api/auto-setup-types.ts +0 -64
  7. package/src/api/bazarr-api.ts +0 -356
  8. package/src/api/cloudflare-api.ts +0 -577
  9. package/src/api/custom-format-api.ts +0 -212
  10. package/src/api/grafana-api.ts +0 -314
  11. package/src/api/heimdall-api.ts +0 -209
  12. package/src/api/homarr-api.ts +0 -296
  13. package/src/api/huntarr-api.ts +0 -622
  14. package/src/api/index.ts +0 -1
  15. package/src/api/jellyfin-api.ts +0 -473
  16. package/src/api/jellyseerr-api.ts +0 -660
  17. package/src/api/maintainerr-api.ts +0 -731
  18. package/src/api/naming-config.ts +0 -93
  19. package/src/api/overseerr-api.ts +0 -513
  20. package/src/api/plex-api.ts +0 -358
  21. package/src/api/portainer-api.ts +0 -467
  22. package/src/api/profilarr-api.ts +0 -380
  23. package/src/api/prowlarr-api.ts +0 -859
  24. package/src/api/qbittorrent-api.ts +0 -339
  25. package/src/api/quality-profile-api.ts +0 -269
  26. package/src/api/tautulli-api.ts +0 -277
  27. package/src/api/uptime-kuma-api.ts +0 -342
  28. package/src/apps/categories.ts +0 -14
  29. package/src/apps/index.ts +0 -2
  30. package/src/apps/registry.ts +0 -1255
  31. package/src/compose/caddy-config.ts +0 -129
  32. package/src/compose/generator.ts +0 -262
  33. package/src/compose/index.ts +0 -3
  34. package/src/compose/templates.ts +0 -78
  35. package/src/compose/traefik-config.ts +0 -185
  36. package/src/config/bookmarks-generator.ts +0 -149
  37. package/src/config/defaults.ts +0 -37
  38. package/src/config/homepage-config.ts +0 -376
  39. package/src/config/index.ts +0 -3
  40. package/src/config/manager.ts +0 -149
  41. package/src/config/recyclarr-config.ts +0 -179
  42. package/src/config/schema.ts +0 -310
  43. package/src/config/slskd-config.ts +0 -85
  44. package/src/config/soularr-config.ts +0 -105
  45. package/src/config/trash-quality-definitions.ts +0 -213
  46. package/src/data/lidarr-custom-formats.ts +0 -127
  47. package/src/data/trash-profiles.ts +0 -326
  48. package/src/docker/client.ts +0 -291
  49. package/src/docker/index.ts +0 -1
  50. package/src/index.ts +0 -77
  51. package/src/structure/manager.ts +0 -128
  52. package/src/ui/App.ts +0 -109
  53. package/src/ui/components/ApplicationSelector.ts +0 -272
  54. package/src/ui/components/FileEditor.ts +0 -91
  55. package/src/ui/components/FooterHint.ts +0 -119
  56. package/src/ui/components/PageLayout.ts +0 -152
  57. package/src/ui/components/UpdateNotification.ts +0 -101
  58. package/src/ui/index.ts +0 -1
  59. package/src/ui/screens/AdvancedSettings.ts +0 -181
  60. package/src/ui/screens/ApiKeyViewer.ts +0 -500
  61. package/src/ui/screens/AppConfigurator.ts +0 -731
  62. package/src/ui/screens/AppManager.ts +0 -276
  63. package/src/ui/screens/CloudflaredSetup.ts +0 -974
  64. package/src/ui/screens/ContainerControl.ts +0 -579
  65. package/src/ui/screens/FullAutoSetup.ts +0 -1719
  66. package/src/ui/screens/HomepageSetup.ts +0 -306
  67. package/src/ui/screens/JellyfinSetup.ts +0 -471
  68. package/src/ui/screens/JellyseerrSetup.ts +0 -612
  69. package/src/ui/screens/LogsViewer.ts +0 -468
  70. package/src/ui/screens/MainMenu.ts +0 -288
  71. package/src/ui/screens/MonitorDashboard.ts +0 -866
  72. package/src/ui/screens/ProwlarrSetup.ts +0 -623
  73. package/src/ui/screens/QBittorrentSetup.ts +0 -268
  74. package/src/ui/screens/QuickSetup.ts +0 -1161
  75. package/src/ui/screens/RecyclarrSetup.ts +0 -418
  76. package/src/ui/screens/SecretsEditor.ts +0 -214
  77. package/src/ui/screens/SettingsScreen.ts +0 -605
  78. package/src/ui/screens/TRaSHProfileSetup.ts +0 -395
  79. package/src/ui/screens/index.ts +0 -4
  80. package/src/util/arch.ts +0 -78
  81. package/src/utils/browser.ts +0 -26
  82. package/src/utils/categories.ts +0 -59
  83. package/src/utils/debug.ts +0 -91
  84. package/src/utils/env.ts +0 -137
  85. package/src/utils/logs.ts +0 -118
  86. package/src/utils/migrations/1765626338_rename_env_variables.ts +0 -101
  87. package/src/utils/migrations/1765707135_rename_easiarr_status.ts +0 -90
  88. package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +0 -44
  89. package/src/utils/migrations/1769515650_migrate_to_xdg.ts +0 -107
  90. package/src/utils/migrations.ts +0 -164
  91. package/src/utils/password.ts +0 -42
  92. package/src/utils/unraid.ts +0 -101
  93. package/src/utils/update-checker.ts +0 -210
  94. package/src/utils/url-utils.ts +0 -38
@@ -1,1161 +0,0 @@
1
- /**
2
- * Quick Setup Wizard
3
- * First-time setup flow for easiarr
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 { App } from "../App"
16
- import type { AppConfig, AppId, VpnMode } from "../../config/schema"
17
- import { createDefaultConfig, saveConfig } from "../../config"
18
- import { saveCompose } from "../../compose"
19
- import { createPageLayout } from "../components/PageLayout"
20
- import { ensureDirectoryStructure } from "../../structure/manager"
21
- import { SecretsEditor } from "./SecretsEditor"
22
- import { getApp } from "../../apps"
23
- import { ApplicationSelector } from "../components/ApplicationSelector"
24
- import { isUnraid, getDefaultRootDir, getUnraidInfo } from "../../utils/unraid"
25
-
26
- type WizardStep = "welcome" | "apps" | "system" | "vpn" | "traefik" | "secrets" | "confirm"
27
-
28
- // Category display order and short names for tabs
29
-
30
- export class QuickSetup {
31
- private renderer: CliRenderer
32
- private container: BoxRenderable
33
- private app: App
34
- private step: WizardStep = "welcome"
35
- private selectedApps: Set<AppId> = new Set([
36
- "radarr",
37
- "sonarr",
38
- "prowlarr",
39
- "qbittorrent",
40
- "jellyfin",
41
- "jellyseerr",
42
- "flaresolverr",
43
- "homepage",
44
- "easiarr",
45
- ])
46
-
47
- private rootDir: string = getDefaultRootDir()
48
- private timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/London"
49
- private puid: string = process.getuid?.().toString() || "1000"
50
- private pgid: string = process.getgid?.().toString() || "1000"
51
- private umask: string = "002"
52
-
53
- private keyHandler: ((key: KeyEvent) => void) | null = null
54
- // VPN Config
55
- private vpnMode: VpnMode = "full"
56
- // Traefik config
57
- private traefikEnabled: boolean = false
58
- private traefikDomain: string = "CLOUDFLARE_DNS_ZONE"
59
- private traefikEntrypoint: string = "web"
60
- private traefikMiddlewares: string[] = []
61
- // Log mount config
62
- private logMount: boolean = false
63
-
64
- constructor(renderer: CliRenderer, container: BoxRenderable, app: App) {
65
- this.renderer = renderer
66
- this.container = container
67
- this.app = app
68
-
69
- this.renderStep()
70
- }
71
-
72
- private renderStep(): void {
73
- // Clear previous key handler if exists
74
- if (this.keyHandler) {
75
- this.renderer.keyInput.off("keypress", this.keyHandler)
76
- this.keyHandler = null
77
- }
78
-
79
- // Clear all children from container
80
- const children = this.container.getChildren()
81
- for (const child of children) {
82
- this.container.remove(child.id)
83
- }
84
-
85
- switch (this.step) {
86
- case "welcome":
87
- this.renderWelcome()
88
- break
89
- case "apps":
90
- this.renderAppSelection()
91
- break
92
- case "system":
93
- this.renderSystemConfig()
94
- break
95
- case "vpn":
96
- this.renderVpnConfig()
97
- break
98
- case "traefik":
99
- this.renderTraefikConfig()
100
- break
101
- case "secrets":
102
- this.renderSecrets()
103
- break
104
- case "confirm":
105
- this.renderConfirm()
106
- break
107
- }
108
- }
109
-
110
- private renderWelcome(): void {
111
- const { container: page, content } = createPageLayout(this.renderer, {
112
- title: "Welcome to easiarr",
113
- footerHint: [
114
- { type: "key", key: "Enter", value: "Select" },
115
- { type: "key", key: "q", value: "Quit" },
116
- ],
117
- })
118
-
119
- // Spacer
120
- content.add(
121
- new TextRenderable(this.renderer, {
122
- id: "spacer1",
123
- content: "",
124
- })
125
- )
126
-
127
- // Slogan
128
- content.add(
129
- new TextRenderable(this.renderer, {
130
- id: "slogan",
131
- content: "It could be easiarr.",
132
- fg: "#4a9eff",
133
- })
134
- )
135
-
136
- // Description
137
- content.add(
138
- new TextRenderable(this.renderer, {
139
- id: "desc1",
140
- content: "This wizard will help you set up your *arr media ecosystem",
141
- fg: "#aaaaaa",
142
- })
143
- )
144
-
145
- content.add(
146
- new TextRenderable(this.renderer, {
147
- id: "desc2",
148
- content: "following TRaSH Guides best practices for optimal performance.",
149
- fg: "#aaaaaa",
150
- })
151
- )
152
-
153
- // Spacer
154
- content.add(
155
- new TextRenderable(this.renderer, {
156
- id: "spacer2",
157
- content: "",
158
- })
159
- )
160
-
161
- // Features
162
- content.add(
163
- new TextRenderable(this.renderer, {
164
- id: "feature1",
165
- content: " ✓ Proper folder structure for hardlinks & atomic moves",
166
- fg: "#00cc66",
167
- })
168
- )
169
-
170
- content.add(
171
- new TextRenderable(this.renderer, {
172
- id: "feature2",
173
- content: " ✓ Pre-configured containers with optimized settings",
174
- fg: "#00cc66",
175
- })
176
- )
177
-
178
- content.add(
179
- new TextRenderable(this.renderer, {
180
- id: "feature3",
181
- content: " ✓ 41 apps available across 10 categories",
182
- fg: "#00cc66",
183
- })
184
- )
185
-
186
- content.add(
187
- new TextRenderable(this.renderer, {
188
- id: "feature4",
189
- content: " ✓ Easy container management via TUI",
190
- fg: "#00cc66",
191
- })
192
- )
193
-
194
- // Show Unraid detection if applicable
195
- if (isUnraid()) {
196
- const unraidInfo = getUnraidInfo()
197
- content.add(
198
- new TextRenderable(this.renderer, {
199
- id: "unraid-detected",
200
- content: ` 🟢 Unraid OS detected ${unraidInfo.hasComposeManager ? "(Compose Manager found)" : ""}`,
201
- fg: "#ff8c00",
202
- })
203
- )
204
- }
205
-
206
- // Spacer
207
- content.add(
208
- new TextRenderable(this.renderer, {
209
- id: "spacer3",
210
- content: "",
211
- })
212
- )
213
-
214
- // Menu
215
- const menu = new SelectRenderable(this.renderer, {
216
- id: "welcome-menu",
217
- flexGrow: 1,
218
- width: "100%",
219
- height: 6,
220
- backgroundColor: "#151525",
221
- focusedBackgroundColor: "#252545",
222
- selectedBackgroundColor: "#3a4a6e",
223
- options: [
224
- { name: "▶ Start Setup", description: "Begin the configuration wizard" },
225
- { name: "📖 About", description: "Learn more about easiarr" },
226
- { name: "✕ Exit", description: "Quit the application" },
227
- ],
228
- })
229
-
230
- menu.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
231
- if (index === 0) {
232
- this.step = "apps"
233
- this.renderStep()
234
- } else if (index === 1) {
235
- this.showAbout()
236
- } else if (index === 2) {
237
- process.exit(0)
238
- }
239
- })
240
-
241
- content.add(menu)
242
- menu.focus()
243
-
244
- // Global key handler for welcome screen
245
- this.keyHandler = (key: KeyEvent) => {
246
- if (key.name === "q") {
247
- process.exit(0)
248
- }
249
- }
250
- this.renderer.keyInput.on("keypress", this.keyHandler)
251
-
252
- this.container.add(page)
253
- }
254
-
255
- private showAbout(): void {
256
- // Clear previous key handler if exists
257
- if (this.keyHandler) {
258
- this.renderer.keyInput.off("keypress", this.keyHandler)
259
- this.keyHandler = null
260
- }
261
-
262
- // Clear all children from container
263
- const children = this.container.getChildren()
264
- for (const child of children) {
265
- this.container.remove(child.id)
266
- }
267
-
268
- const { container: page, content } = createPageLayout(this.renderer, {
269
- title: "About easiarr",
270
- footerHint: [
271
- { type: "key", key: "Esc", value: "Back" },
272
- { type: "key", key: "q", value: "Quit" },
273
- ],
274
- })
275
-
276
- content.add(new TextRenderable(this.renderer, { id: "about-spacer1", content: "" }))
277
-
278
- content.add(
279
- new TextRenderable(this.renderer, {
280
- id: "about-name",
281
- content: "easiarr - Docker Compose Generator for *arr Ecosystem",
282
- fg: "#4a9eff",
283
- })
284
- )
285
-
286
- content.add(new TextRenderable(this.renderer, { id: "about-spacer2", content: "" }))
287
-
288
- content.add(
289
- new TextRenderable(this.renderer, {
290
- id: "about-desc1",
291
- content: "easiarr simplifies the setup and management of media automation",
292
- fg: "#aaaaaa",
293
- })
294
- )
295
-
296
- content.add(
297
- new TextRenderable(this.renderer, {
298
- id: "about-desc2",
299
- content: "applications like Radarr, Sonarr, Prowlarr, and more.",
300
- fg: "#aaaaaa",
301
- })
302
- )
303
-
304
- content.add(new TextRenderable(this.renderer, { id: "about-spacer3", content: "" }))
305
-
306
- content.add(
307
- new TextRenderable(this.renderer, {
308
- id: "about-features",
309
- content: "Features:",
310
- fg: "#ffffff",
311
- })
312
- )
313
-
314
- content.add(
315
- new TextRenderable(this.renderer, {
316
- id: "about-f1",
317
- content: " • TRaSH Guides compliant folder structure",
318
- fg: "#aaaaaa",
319
- })
320
- )
321
-
322
- content.add(
323
- new TextRenderable(this.renderer, {
324
- id: "about-f2",
325
- content: " • Automatic docker-compose.yml generation",
326
- fg: "#aaaaaa",
327
- })
328
- )
329
-
330
- content.add(
331
- new TextRenderable(this.renderer, {
332
- id: "about-f3",
333
- content: " • Interactive container management",
334
- fg: "#aaaaaa",
335
- })
336
- )
337
-
338
- content.add(
339
- new TextRenderable(this.renderer, {
340
- id: "about-f4",
341
- content: " • 41+ pre-configured applications",
342
- fg: "#aaaaaa",
343
- })
344
- )
345
-
346
- content.add(new TextRenderable(this.renderer, { id: "about-spacer4", content: "" }))
347
-
348
- content.add(
349
- new TextRenderable(this.renderer, {
350
- id: "about-link",
351
- content: "GitHub: https://github.com/muhammedaksam/easiarr",
352
- fg: "#888888",
353
- })
354
- )
355
-
356
- // Key handler for About screen
357
- this.keyHandler = (key: KeyEvent) => {
358
- if (key.name === "escape") {
359
- this.renderStep() // Go back to welcome
360
- } else if (key.name === "q") {
361
- process.exit(0)
362
- }
363
- }
364
- this.renderer.keyInput.on("keypress", this.keyHandler)
365
-
366
- this.container.add(page)
367
- }
368
-
369
- private renderAppSelection(): void {
370
- const title = "Select Apps"
371
- const stepInfo = `${title} (${this.selectedApps.size} selected)`
372
- const footerHint = [
373
- { type: "key", key: "←→", value: "Tab" },
374
- { type: "key", key: "Enter", value: "Toggle" },
375
- { type: "key", key: "q", value: "Quit" },
376
- ] as const
377
-
378
- const { container: page, content } = createPageLayout(this.renderer, {
379
- title: title,
380
- stepInfo: stepInfo,
381
- footerHint: [...footerHint],
382
- })
383
-
384
- // Application Selector
385
- // We pass our selectedApps Set directly.
386
- const selector = new ApplicationSelector(this.renderer, {
387
- selectedApps: this.selectedApps,
388
- width: "100%",
389
- flexGrow: 1,
390
- onToggle: () => {
391
- // Update step info manually if possible?
392
- },
393
- })
394
-
395
- content.add(selector)
396
-
397
- // Spacer
398
- content.add(new TextRenderable(this.renderer, { content: " " }))
399
-
400
- // Navigation Menu (Persistent at bottom)
401
- const navOptions = [
402
- { name: "▶ Continue to next step", description: "Proceed to root directory selection" },
403
- { name: "◀ Back to welcome", description: "Return to the main menu" },
404
- ]
405
-
406
- const navMenu = new SelectRenderable(this.renderer, {
407
- id: `qs-apps-nav-menu`,
408
- width: "100%",
409
- height: 4,
410
- options: navOptions,
411
- })
412
-
413
- navMenu.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
414
- if (index === 0) {
415
- // Continue
416
- this.step = "system"
417
- this.renderStep()
418
- } else {
419
- // Back
420
- this.step = "welcome"
421
- this.renderStep()
422
- }
423
- })
424
- content.add(navMenu)
425
-
426
- // Focus state
427
- let focusTarget: "selector" | "nav" = "selector"
428
- selector.focus()
429
-
430
- // Key Handler
431
- this.keyHandler = (key: KeyEvent) => {
432
- if (key.name === "q") {
433
- process.exit(0)
434
- } else if (key.name === "escape") {
435
- // Go back to welcome
436
- this.step = "welcome"
437
- this.renderStep()
438
- } else if (key.name === "tab") {
439
- if (focusTarget === "selector") {
440
- focusTarget = "nav"
441
- selector.blur()
442
- navMenu.focus()
443
- } else {
444
- focusTarget = "selector"
445
- navMenu.blur()
446
- selector.focus()
447
- }
448
- } else {
449
- // Delegate
450
- if (focusTarget === "selector") {
451
- selector.handleKey(key)
452
- }
453
- }
454
- }
455
- this.renderer.keyInput.on("keypress", this.keyHandler)
456
-
457
- this.container.add(page)
458
- }
459
-
460
- private renderSystemConfig(): void {
461
- const hasTraefik = this.selectedApps.has("traefik")
462
- const hasGluetun = this.selectedApps.has("gluetun")
463
- let totalSteps = 3
464
- if (hasTraefik) totalSteps++
465
- if (hasGluetun) totalSteps++
466
- if (this.hasSecrets()) totalSteps++
467
-
468
- const { container: page, content } = createPageLayout(this.renderer, {
469
- title: "System Configuration",
470
- stepInfo: `Step 2/${totalSteps}`,
471
- footerHint: [
472
- { type: "key", key: "Tab", value: "Next" },
473
- { type: "key", key: "Enter", value: "Next/Continue" },
474
- { type: "key", key: "Esc", value: "Back" },
475
- { type: "key", key: "q", value: "Quit" },
476
- ],
477
- })
478
-
479
- // Instructions
480
- content.add(
481
- new TextRenderable(this.renderer, {
482
- id: "sys-desc",
483
- content: "Configure global environment variables for all containers:",
484
- fg: "#888888",
485
- })
486
- )
487
- content.add(new TextRenderable(this.renderer, { content: " " }))
488
-
489
- // Container for form fields
490
- const formBox = new BoxRenderable(this.renderer, {
491
- width: "100%",
492
- flexDirection: "column",
493
- })
494
- content.add(formBox)
495
-
496
- const createField = (id: string, label: string, value: string, placeholder: string, width: number = 40) => {
497
- const row = new BoxRenderable(this.renderer, {
498
- width: "100%",
499
- height: 1,
500
- flexDirection: "row",
501
- marginBottom: 1,
502
- })
503
- row.add(
504
- new TextRenderable(this.renderer, {
505
- content: label.padEnd(16),
506
- fg: "#aaaaaa",
507
- })
508
- )
509
- const input = new InputRenderable(this.renderer, {
510
- id,
511
- width,
512
- placeholder,
513
- backgroundColor: "#2a2a3e",
514
- textColor: "#ffffff",
515
- focusedBackgroundColor: "#3a3a4e",
516
- })
517
- if (value) input.value = value
518
- row.add(input)
519
- formBox.add(row)
520
- return input
521
- }
522
-
523
- const rootInput = createField("input-root", "Root Path:", this.rootDir, "/home/user/media", 50)
524
- const puidInput = createField("input-puid", "PUID:", this.puid, "1000", 10)
525
- const pgidInput = createField("input-pgid", "PGID:", this.pgid, "1000", 10)
526
- const tzInput = createField("input-tz", "Timezone:", this.timezone, "Europe/London", 30)
527
- const umaskInput = createField("input-umask", "Umask:", this.umask, "002", 10)
528
-
529
- // Log mount toggle
530
- const logMountRow = new BoxRenderable(this.renderer, {
531
- width: "100%",
532
- height: 1,
533
- flexDirection: "row",
534
- marginBottom: 1,
535
- })
536
- logMountRow.add(
537
- new TextRenderable(this.renderer, {
538
- content: "Bind Logs:".padEnd(16),
539
- fg: "#aaaaaa",
540
- })
541
- )
542
- const logMountToggle = new SelectRenderable(this.renderer, {
543
- id: "toggle-logmount",
544
- width: 30,
545
- height: 1,
546
- options: [
547
- {
548
- name: this.logMount ? "✓ Enabled" : "○ Disabled",
549
- description: "Bind-mount container logs to ${ROOT_DIR}/logs/",
550
- },
551
- ],
552
- })
553
- logMountToggle.on(SelectRenderableEvents.ITEM_SELECTED, () => {
554
- this.logMount = !this.logMount
555
- // Rebuild the step to update toggle state
556
- this.renderStep()
557
- })
558
- logMountRow.add(logMountToggle)
559
- formBox.add(logMountRow)
560
-
561
- content.add(new TextRenderable(this.renderer, { content: " " }))
562
-
563
- // Navigation Menu (Continue / Back)
564
- const navMenu = new SelectRenderable(this.renderer, {
565
- id: "sys-nav",
566
- width: "100%",
567
- height: 3,
568
- options: [{ name: "▶ Continue", description: "Proceed to VPN/Network setup" }],
569
- })
570
- content.add(navMenu)
571
-
572
- // Focus management
573
- const inputs = [rootInput, puidInput, pgidInput, tzInput, umaskInput, logMountToggle, navMenu]
574
- let focusIndex = 0
575
- inputs[0].focus()
576
-
577
- // Sync values
578
- rootInput.on(InputRenderableEvents.CHANGE, (v) => (this.rootDir = v))
579
- puidInput.on(InputRenderableEvents.CHANGE, (v) => (this.puid = v))
580
- pgidInput.on(InputRenderableEvents.CHANGE, (v) => (this.pgid = v))
581
- tzInput.on(InputRenderableEvents.CHANGE, (v) => (this.timezone = v))
582
- umaskInput.on(InputRenderableEvents.CHANGE, (v) => (this.umask = v))
583
-
584
- // Navigation Logic
585
- const nextStep = () => {
586
- // TODO: Validate rootDir is an absolute path
587
-
588
- // Determine next step
589
- if (hasGluetun) this.step = "vpn"
590
- else if (hasTraefik) this.step = "traefik"
591
- else if (this.hasSecrets()) this.step = "secrets"
592
- else this.step = "confirm"
593
-
594
- this.renderStep()
595
- }
596
-
597
- navMenu.on(SelectRenderableEvents.ITEM_SELECTED, () => nextStep())
598
-
599
- // Key Handler
600
- this.keyHandler = (key: KeyEvent) => {
601
- if (key.name === "q") {
602
- process.exit(0)
603
- } else if (key.name === "escape") {
604
- this.step = "apps"
605
- this.renderStep()
606
- } else if (key.name === "tab" || key.name === "enter") {
607
- // Enter on last item (NavMenu) is handled by SelectRenderable
608
-
609
- if (key.name === "enter" && focusIndex === inputs.length - 1) {
610
- // Handle by SelectRenderable logic
611
- return
612
- }
613
-
614
- inputs[focusIndex].blur()
615
- if (key.shift) {
616
- focusIndex = (focusIndex - 1 + inputs.length) % inputs.length
617
- } else {
618
- focusIndex = (focusIndex + 1) % inputs.length
619
- }
620
- inputs[focusIndex].focus()
621
- }
622
- }
623
- this.renderer.keyInput.on("keypress", this.keyHandler)
624
-
625
- this.container.add(page)
626
- }
627
-
628
- private renderVpnConfig(): void {
629
- const hasTraefik = this.selectedApps.has("traefik")
630
- const hasGluetun = this.selectedApps.has("gluetun")
631
- let totalSteps = 3
632
- if (hasTraefik) totalSteps++
633
- if (hasGluetun) totalSteps++
634
- if (this.hasSecrets()) totalSteps++
635
-
636
- const stepNum = 3 // Always step 3 if present, as it comes after RootDir(2)
637
-
638
- const { container: page, content } = createPageLayout(this.renderer, {
639
- title: "VPN Configuration",
640
- stepInfo: `Step ${stepNum}/${totalSteps}`,
641
- footerHint: [
642
- { type: "key", key: "Enter", value: "Select" },
643
- { type: "key", key: "Esc", value: "Back" },
644
- { type: "key", key: "q", value: "Quit" },
645
- ],
646
- })
647
-
648
- content.add(
649
- new TextRenderable(this.renderer, {
650
- id: "vpn-desc",
651
- content: "Select how traffic should be routed through Gluetun VPN:",
652
- fg: "#888888",
653
- })
654
- )
655
-
656
- content.add(new TextRenderable(this.renderer, { id: "vpn-spacer1", content: "" }))
657
-
658
- const menu = new SelectRenderable(this.renderer, {
659
- id: "vpn-menu",
660
- width: "100%",
661
- height: 6,
662
- backgroundColor: "#151525",
663
- focusedBackgroundColor: "#252545",
664
- selectedBackgroundColor: "#3a4a6e",
665
- options: [
666
- {
667
- name: "🛡️ Full VPN",
668
- description: "Route Downloaders, Indexers, and Media Servers through VPN",
669
- },
670
- {
671
- name: "⚡ Mini VPN",
672
- description: "Route ONLY Downloaders through VPN (Recommended)",
673
- },
674
- {
675
- name: "❌ No VPN Routing",
676
- description: "Run container but don't route traffic (Manual config)",
677
- },
678
- ],
679
- })
680
-
681
- const modes: VpnMode[] = ["full", "mini", "none"]
682
- // Pre-select current mode
683
- const currentIndex = modes.indexOf(this.vpnMode)
684
- if (currentIndex !== -1) {
685
- // selecting index isn't directly exposed in options currently but defaults to 0
686
- }
687
-
688
- menu.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
689
- this.vpnMode = modes[index]
690
-
691
- // Navigate to next
692
- if (hasTraefik) {
693
- this.step = "traefik"
694
- } else if (this.hasSecrets()) {
695
- this.step = "secrets"
696
- } else {
697
- this.step = "confirm"
698
- }
699
- this.renderStep()
700
- })
701
-
702
- content.add(menu)
703
- menu.focus()
704
-
705
- // Key handler
706
- this.keyHandler = (key: KeyEvent) => {
707
- if (key.name === "q") {
708
- process.exit(0)
709
- } else if (key.name === "escape") {
710
- this.step = "system"
711
- this.renderStep()
712
- }
713
- }
714
- this.renderer.keyInput.on("keypress", this.keyHandler)
715
-
716
- this.container.add(page)
717
- }
718
-
719
- private renderTraefikConfig(): void {
720
- const hasTraefik = this.selectedApps.has("traefik")
721
- const hasGluetun = this.selectedApps.has("gluetun")
722
- let totalSteps = 3
723
- if (hasTraefik) totalSteps++
724
- if (hasGluetun) totalSteps++
725
- if (this.hasSecrets()) totalSteps++
726
-
727
- const stepNum = hasGluetun ? 4 : 3
728
-
729
- const { container: page, content } = createPageLayout(this.renderer, {
730
- title: "Traefik Configuration",
731
- stepInfo: `Step ${stepNum}/${totalSteps}`,
732
- footerHint: [
733
- { type: "key", key: "Tab", value: "Next Field" },
734
- { type: "key", key: "Enter", value: "Continue" },
735
- { type: "key", key: "Esc", value: "Back" },
736
- { type: "key", key: "q", value: "Quit" },
737
- ],
738
- })
739
-
740
- content.add(
741
- new TextRenderable(this.renderer, {
742
- id: "traefik-desc",
743
- content: "Configure Traefik reverse proxy labels for your services:",
744
- fg: "#888888",
745
- })
746
- )
747
-
748
- content.add(new TextRenderable(this.renderer, { id: "traefik-spacer1", content: "" }))
749
-
750
- // Domain field
751
- content.add(
752
- new TextRenderable(this.renderer, {
753
- id: "traefik-domain-label",
754
- content: "Domain (e.g., example.com or ${CLOUDFLARE_DNS_ZONE}):",
755
- fg: "#aaaaaa",
756
- })
757
- )
758
-
759
- const domainInput = new InputRenderable(this.renderer, {
760
- id: "traefik-domain-input",
761
- width: "100%",
762
- placeholder: "${CLOUDFLARE_DNS_ZONE}",
763
- backgroundColor: "#2a2a3e",
764
- textColor: "#ffffff",
765
- focusedBackgroundColor: "#3a3a4e",
766
- })
767
- domainInput.value = "${CLOUDFLARE_DNS_ZONE}" // Default to variable
768
-
769
- content.add(domainInput)
770
- content.add(new TextRenderable(this.renderer, { id: "traefik-spacer2", content: "" }))
771
-
772
- // Entrypoint field
773
- content.add(
774
- new TextRenderable(this.renderer, {
775
- id: "traefik-entrypoint-label",
776
- content: "Entrypoint (e.g., web, websecure):",
777
- fg: "#aaaaaa",
778
- })
779
- )
780
-
781
- const entrypointInput = new InputRenderable(this.renderer, {
782
- id: "traefik-entrypoint-input",
783
- width: "100%",
784
- placeholder: "web",
785
- backgroundColor: "#2a2a3e",
786
- textColor: "#ffffff",
787
- focusedBackgroundColor: "#3a3a4e",
788
- })
789
-
790
- content.add(entrypointInput)
791
- content.add(new TextRenderable(this.renderer, { id: "traefik-spacer3", content: "" }))
792
-
793
- // Middlewares field
794
- // Calculate smart defaults
795
- const defaultMiddlewares: string[] = []
796
- if (this.selectedApps.has("authentik")) defaultMiddlewares.push("authentik-forwardauth@file")
797
- if (this.selectedApps.has("crowdsec")) defaultMiddlewares.push("traefik-bouncer@file")
798
- // Always suggest security headers if using Traefik in this stack
799
- defaultMiddlewares.push("security-headers@file")
800
-
801
- const middlewareStr = defaultMiddlewares.join(",")
802
-
803
- content.add(
804
- new TextRenderable(this.renderer, {
805
- id: "traefik-middleware-label",
806
- content: "Middlewares (comma-separated, e.g., auth@file,headers@file):",
807
- fg: "#aaaaaa",
808
- })
809
- )
810
-
811
- const middlewareInput = new InputRenderable(this.renderer, {
812
- id: "traefik-middleware-input",
813
- width: "100%",
814
- placeholder: middlewareStr || "Leave empty for none",
815
- backgroundColor: "#2a2a3e",
816
- textColor: "#ffffff",
817
- focusedBackgroundColor: "#3a3a4e",
818
- })
819
- middlewareInput.value = middlewareStr
820
-
821
- content.add(middlewareInput)
822
- content.add(new TextRenderable(this.renderer, { id: "traefik-spacer4", content: "" }))
823
-
824
- // Navigation menu
825
- const navMenu = new SelectRenderable(this.renderer, {
826
- id: "traefik-nav-menu",
827
- width: "100%",
828
- height: 4,
829
- backgroundColor: "#151525",
830
- focusedBackgroundColor: "#252545",
831
- selectedBackgroundColor: "#3a4a6e",
832
- options: [
833
- { name: "▶ Continue to confirmation", description: "Review and generate config" },
834
- { name: "◀ Back to root directory", description: "Return to previous step" },
835
- ],
836
- })
837
-
838
- navMenu.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
839
- // Save traefik config
840
- this.traefikEnabled = true
841
- this.traefikDomain = domainInput.value || "${CLOUDFLARE_DNS_ZONE}"
842
- this.traefikEntrypoint = entrypointInput.value || "web"
843
- this.traefikMiddlewares = middlewareInput.value
844
- ? middlewareInput.value
845
- .split(",")
846
- .map((m) => m.trim())
847
- .filter(Boolean)
848
- : []
849
-
850
- if (index === 0) {
851
- if (this.hasSecrets()) {
852
- this.step = "secrets"
853
- } else {
854
- this.step = "confirm"
855
- }
856
- this.renderStep()
857
- } else {
858
- // Go back to vpn if gluetun present, otherwise system
859
- this.step = hasGluetun ? "vpn" : "system"
860
- this.renderStep()
861
- }
862
- })
863
-
864
- content.add(navMenu)
865
-
866
- // Focus management
867
- let focusIndex = 0
868
- const focusables = [domainInput, entrypointInput, middlewareInput, navMenu]
869
- focusables[focusIndex].focus()
870
-
871
- // Key handler
872
- this.keyHandler = (key: KeyEvent) => {
873
- if (key.name === "q") {
874
- process.exit(0)
875
- } else if (key.name === "escape") {
876
- this.step = hasGluetun ? "vpn" : "system"
877
- this.renderStep()
878
- } else if (key.name === "tab") {
879
- focusables[focusIndex].blur()
880
- focusIndex = (focusIndex + 1) % focusables.length
881
- focusables[focusIndex].focus()
882
- }
883
- }
884
- this.renderer.keyInput.on("keypress", this.keyHandler)
885
-
886
- this.container.add(page)
887
- }
888
-
889
- private hasSecrets(): boolean {
890
- for (const id of this.selectedApps) {
891
- const app = getApp(id)
892
- if (app?.secrets && app.secrets.length > 0) return true
893
- }
894
- return false
895
- }
896
-
897
- private renderSecrets(): void {
898
- const hasTraefik = this.selectedApps.has("traefik")
899
- const hasGluetun = this.selectedApps.has("gluetun")
900
-
901
- // Create a temporary config object for SecretsEditor
902
- const tempConfig = createDefaultConfig(this.rootDir)
903
- tempConfig.apps = Array.from(this.selectedApps).map((id) => ({ id, enabled: true }))
904
-
905
- let totalSteps = 3
906
- if (hasTraefik) totalSteps++
907
- if (hasGluetun) totalSteps++
908
- if (this.hasSecrets()) totalSteps++
909
-
910
- const stepNum = totalSteps - 1
911
-
912
- const editor = new SecretsEditor(this.renderer, {
913
- id: "secrets-editor",
914
- width: "100%",
915
- height: "100%",
916
- title: `Secrets Manager (Step ${stepNum}/${totalSteps})`,
917
- config: tempConfig,
918
- extraEnv: {
919
- ROOT_DIR: { value: this.rootDir, description: "Root media path" },
920
- TIMEZONE: { value: this.timezone, description: "System timezone" },
921
- PUID: { value: this.puid, description: "User ID" },
922
- PGID: { value: this.pgid, description: "Group ID" },
923
- UMASK: { value: this.umask, description: "File permissions mask" },
924
- },
925
- onSave: () => {
926
- this.step = "confirm"
927
- this.renderStep()
928
- },
929
- onCancel: () => {
930
- if (hasTraefik) {
931
- this.step = "traefik"
932
- } else if (hasGluetun) {
933
- this.step = "vpn"
934
- } else {
935
- this.step = "system"
936
- }
937
- this.renderStep()
938
- },
939
- })
940
-
941
- this.container.add(editor)
942
- }
943
-
944
- private renderConfirm(): void {
945
- const hasTraefik = this.selectedApps.has("traefik")
946
- const hasGluetun = this.selectedApps.has("gluetun")
947
- let totalSteps = 3
948
- if (hasTraefik) totalSteps++
949
- if (hasGluetun) totalSteps++
950
- if (this.hasSecrets()) totalSteps++
951
-
952
- const { container: page, content } = createPageLayout(this.renderer, {
953
- title: "Confirm Setup",
954
- stepInfo: `Step ${totalSteps}/${totalSteps}`,
955
- footerHint: [
956
- { type: "key", key: "Enter", value: "Select" },
957
- { type: "key", key: "Esc", value: "Back" },
958
- { type: "key", key: "q", value: "Quit" },
959
- ],
960
- })
961
-
962
- content.add(new TextRenderable(this.renderer, { id: "confirm-spacer", content: "" }))
963
-
964
- content.add(
965
- new TextRenderable(this.renderer, {
966
- id: "confirm-root",
967
- content: `📁 Root: ${this.rootDir}`,
968
- fg: "#cccccc",
969
- })
970
- )
971
-
972
- content.add(
973
- new TextRenderable(this.renderer, {
974
- id: "confirm-apps",
975
- content: `📦 Apps: ${this.selectedApps.size} selected`,
976
- fg: "#cccccc",
977
- })
978
- )
979
-
980
- // List apps
981
- const appList = Array.from(this.selectedApps).join(", ")
982
- content.add(
983
- new TextRenderable(this.renderer, {
984
- id: "confirm-applist",
985
- content: ` ${appList}`,
986
- fg: "#888888",
987
- })
988
- )
989
-
990
- // Show VPN config if enabled
991
- if (hasGluetun) {
992
- content.add(new TextRenderable(this.renderer, { id: "confirm-spacer-vpn", content: "" }))
993
- content.add(
994
- new TextRenderable(this.renderer, {
995
- id: "confirm-vpn",
996
- content: `🛡️ VPN Routing: ${this.vpnMode.toUpperCase()}`,
997
- fg: "#cccccc",
998
- })
999
- )
1000
- }
1001
-
1002
- // Show Traefik config if enabled
1003
- if (hasTraefik && this.traefikEnabled) {
1004
- content.add(new TextRenderable(this.renderer, { id: "confirm-spacer-traefik", content: "" }))
1005
- content.add(
1006
- new TextRenderable(this.renderer, {
1007
- id: "confirm-traefik",
1008
- content: `🔀 Traefik: Enabled`,
1009
- fg: "#cccccc",
1010
- })
1011
- )
1012
- content.add(
1013
- new TextRenderable(this.renderer, {
1014
- id: "confirm-traefik-domain",
1015
- content: ` Domain: \${${this.traefikDomain}}`,
1016
- fg: "#888888",
1017
- })
1018
- )
1019
- content.add(
1020
- new TextRenderable(this.renderer, {
1021
- id: "confirm-traefik-entry",
1022
- content: ` Entrypoint: ${this.traefikEntrypoint}`,
1023
- fg: "#888888",
1024
- })
1025
- )
1026
- if (this.traefikMiddlewares.length > 0) {
1027
- content.add(
1028
- new TextRenderable(this.renderer, {
1029
- id: "confirm-traefik-mw",
1030
- content: ` Middlewares: ${this.traefikMiddlewares.join(", ")}`,
1031
- fg: "#888888",
1032
- })
1033
- )
1034
- }
1035
- }
1036
-
1037
- // Show Secrets status
1038
- if (this.hasSecrets()) {
1039
- content.add(new TextRenderable(this.renderer, { id: "confirm-spacer-secrets", content: "" }))
1040
- content.add(
1041
- new TextRenderable(this.renderer, {
1042
- id: "confirm-secrets",
1043
- content: `🔑 Secrets: Configured (.env)`,
1044
- fg: "#cccccc",
1045
- })
1046
- )
1047
- }
1048
-
1049
- content.add(new TextRenderable(this.renderer, { id: "confirm-spacer2", content: "" }))
1050
-
1051
- const menu = new SelectRenderable(this.renderer, {
1052
- id: "confirm-menu",
1053
- width: "100%",
1054
- backgroundColor: "#151525",
1055
- focusedBackgroundColor: "#252545",
1056
- selectedBackgroundColor: "#3a4a6e",
1057
- options: [
1058
- {
1059
- name: "✓ Generate Config & Compose",
1060
- description: "Create files and finish",
1061
- },
1062
- { name: "◀ Back", description: "Return to previous step" },
1063
- { name: "❌ Cancel", description: "Exit without saving" },
1064
- ],
1065
- })
1066
-
1067
- menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
1068
- if (index === 0) {
1069
- await this.finishSetup()
1070
- } else if (index === 1) {
1071
- // Navigation priority: Secrets -> Traefik -> VPN -> RootDir
1072
- if (this.hasSecrets()) {
1073
- this.step = "secrets"
1074
- } else if (this.selectedApps.has("traefik")) {
1075
- this.step = "traefik"
1076
- } else if (this.selectedApps.has("gluetun")) {
1077
- this.step = "vpn"
1078
- } else {
1079
- this.step = "system"
1080
- }
1081
- this.renderStep()
1082
- } else {
1083
- process.exit(0)
1084
- }
1085
- })
1086
-
1087
- content.add(menu)
1088
- menu.focus()
1089
-
1090
- // Key handler for confirm screen
1091
- this.keyHandler = (key: KeyEvent) => {
1092
- if (key.name === "q") {
1093
- process.exit(0)
1094
- } else if (key.name === "escape") {
1095
- if (this.hasSecrets()) {
1096
- this.step = "secrets"
1097
- } else if (this.selectedApps.has("traefik")) {
1098
- this.step = "traefik"
1099
- } else if (this.selectedApps.has("gluetun")) {
1100
- this.step = "vpn"
1101
- } else {
1102
- this.step = "system"
1103
- }
1104
- this.renderStep()
1105
- }
1106
- }
1107
- this.renderer.keyInput.on("keypress", this.keyHandler)
1108
-
1109
- this.container.add(page)
1110
- }
1111
-
1112
- private async finishSetup(): Promise<void> {
1113
- // Create config
1114
- const config = createDefaultConfig(this.rootDir)
1115
- config.timezone = this.timezone
1116
- config.uid = parseInt(this.puid) || 1000
1117
- config.gid = parseInt(this.pgid) || 1000
1118
- config.umask = this.umask
1119
-
1120
- // Add selected apps
1121
- config.apps = Array.from(this.selectedApps).map(
1122
- (id): AppConfig => ({
1123
- id,
1124
- enabled: true,
1125
- })
1126
- )
1127
-
1128
- // Add VPN config if enabled
1129
- if (this.selectedApps.has("gluetun")) {
1130
- config.vpn = {
1131
- mode: this.vpnMode,
1132
- }
1133
- }
1134
-
1135
- // Add Traefik config if enabled
1136
- if (this.selectedApps.has("traefik") && this.traefikEnabled) {
1137
- config.traefik = {
1138
- enabled: true,
1139
- domain: this.traefikDomain,
1140
- entrypoint: this.traefikEntrypoint,
1141
- middlewares: this.traefikMiddlewares,
1142
- }
1143
- }
1144
-
1145
- // Add logMount config
1146
- config.logMount = this.logMount
1147
-
1148
- // Save config
1149
- await saveConfig(config)
1150
-
1151
- // Generate directory structure
1152
- await ensureDirectoryStructure(config)
1153
-
1154
- // Generate docker-compose.yml
1155
- await saveCompose(config)
1156
-
1157
- // Navigate to main menu
1158
- this.app.setConfig(config)
1159
- this.app.navigateTo("main")
1160
- }
1161
- }