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