@muhammedaksam/easiarr 0.8.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,758 @@
1
+ import {
2
+ BoxRenderable,
3
+ CliRenderer,
4
+ TextRenderable,
5
+ KeyEvent,
6
+ InputRenderable,
7
+ InputRenderableEvents,
8
+ SelectRenderable,
9
+ SelectRenderableEvents,
10
+ } from "@opentui/core"
11
+ import { createPageLayout } from "../components/PageLayout"
12
+ import { EasiarrConfig } from "../../config/schema"
13
+ import { updateEnv, readEnvSync } from "../../utils/env"
14
+ import { saveConfig } from "../../config"
15
+ import { saveCompose } from "../../compose"
16
+ import { CloudflareApi, setupCloudflaredTunnel } from "../../api/cloudflare-api"
17
+
18
+ type SetupStep = "api_token" | "domain" | "confirm" | "progress" | "done"
19
+
20
+ export class CloudflaredSetup extends BoxRenderable {
21
+ private cliRenderer: CliRenderer
22
+ private config: EasiarrConfig
23
+ private onBack: () => void
24
+ private keyHandler: ((key: KeyEvent) => void) | null = null
25
+ private step: SetupStep = "api_token"
26
+
27
+ // Form values
28
+ private apiToken = ""
29
+ private domain = ""
30
+ private tunnelName = "easiarr"
31
+ private accessEmail = "" // Optional: email for Cloudflare Access protection
32
+
33
+ // Status
34
+ private statusMessages: string[] = []
35
+ private error: string | null = null
36
+
37
+ constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
38
+ super(renderer, {
39
+ id: "cloudflared-setup",
40
+ width: "100%",
41
+ height: "100%",
42
+ flexDirection: "column",
43
+ })
44
+
45
+ this.cliRenderer = renderer
46
+ this.config = config
47
+ this.onBack = onBack
48
+
49
+ // Load existing values from .env
50
+ const env = readEnvSync()
51
+ this.apiToken = env.CLOUDFLARE_API_TOKEN || ""
52
+ this.domain = env.CLOUDFLARE_DNS_ZONE || config.traefik?.domain || ""
53
+
54
+ this.renderContent()
55
+ }
56
+
57
+ private renderContent(): void {
58
+ // Clear previous
59
+ if (this.keyHandler) {
60
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
61
+ this.keyHandler = null
62
+ }
63
+
64
+ const children = this.getChildren()
65
+ for (const child of children) {
66
+ this.remove(child.id)
67
+ }
68
+
69
+ const { container: page, content } = createPageLayout(this.cliRenderer, {
70
+ title: "☁️ Cloudflare Tunnel Setup",
71
+ stepInfo: this.getStepInfo(),
72
+ footerHint: [
73
+ { type: "key", key: "Esc", value: "Back" },
74
+ { type: "key", key: "Enter", value: "Continue" },
75
+ ],
76
+ })
77
+
78
+ this.add(page)
79
+
80
+ // Render based on step
81
+ switch (this.step) {
82
+ case "api_token":
83
+ this.renderApiTokenStep(content)
84
+ break
85
+ case "domain":
86
+ this.renderDomainStep(content)
87
+ break
88
+ case "confirm":
89
+ this.renderConfirmStep(content)
90
+ break
91
+ case "progress":
92
+ this.renderProgressStep(content)
93
+ break
94
+ case "done":
95
+ this.renderDoneStep(content)
96
+ break
97
+ }
98
+ }
99
+
100
+ private getStepInfo(): string {
101
+ switch (this.step) {
102
+ case "api_token":
103
+ return "Step 1/4: Enter Cloudflare API Token"
104
+ case "domain":
105
+ return "Step 2/4: Configure Domain"
106
+ case "confirm":
107
+ return "Step 3/4: Confirm Settings"
108
+ case "progress":
109
+ return "Step 4/4: Setting up tunnel..."
110
+ case "done":
111
+ return "Setup Complete!"
112
+ default:
113
+ return ""
114
+ }
115
+ }
116
+
117
+ private renderApiTokenStep(content: BoxRenderable): void {
118
+ content.add(
119
+ new TextRenderable(this.cliRenderer, {
120
+ content: "Enter your Cloudflare API Token with these permissions:",
121
+ fg: "#888888",
122
+ })
123
+ )
124
+ content.add(
125
+ new TextRenderable(this.cliRenderer, {
126
+ content: " • Account:Account Settings:Read (required)",
127
+ fg: "#50fa7b",
128
+ })
129
+ )
130
+ content.add(
131
+ new TextRenderable(this.cliRenderer, {
132
+ content: " • Account:Cloudflare Tunnel:Edit",
133
+ fg: "#50fa7b",
134
+ })
135
+ )
136
+ content.add(
137
+ new TextRenderable(this.cliRenderer, {
138
+ content: " • Zone:DNS:Edit",
139
+ fg: "#50fa7b",
140
+ })
141
+ )
142
+ content.add(
143
+ new TextRenderable(this.cliRenderer, {
144
+ content: " • Account:Access: Apps and Policies:Edit (optional)",
145
+ fg: "#888888",
146
+ })
147
+ )
148
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
149
+
150
+ content.add(
151
+ new TextRenderable(this.cliRenderer, {
152
+ content: "Create at: https://dash.cloudflare.com/profile/api-tokens",
153
+ fg: "#4a9eff",
154
+ })
155
+ )
156
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
157
+
158
+ // Token input row
159
+ const tokenRow = new BoxRenderable(this.cliRenderer, {
160
+ width: "100%",
161
+ height: 1,
162
+ flexDirection: "row",
163
+ })
164
+ tokenRow.add(
165
+ new TextRenderable(this.cliRenderer, {
166
+ content: "API Token: ",
167
+ fg: "#aaaaaa",
168
+ })
169
+ )
170
+ const tokenInput = new InputRenderable(this.cliRenderer, {
171
+ id: "cf-api-token",
172
+ width: 60,
173
+ placeholder: "Paste your API token here",
174
+ value: this.apiToken,
175
+ })
176
+ tokenInput.onPaste = (v) => {
177
+ this.apiToken = v.text.replace(/[\r\n]/g, "")
178
+ tokenInput.value = this.apiToken
179
+ }
180
+ tokenInput.on(InputRenderableEvents.CHANGE, (v) => (this.apiToken = v))
181
+ tokenRow.add(tokenInput)
182
+ content.add(tokenRow)
183
+
184
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
185
+
186
+ // Navigation
187
+ const nav = new SelectRenderable(this.cliRenderer, {
188
+ id: "cf-token-nav",
189
+ width: "100%",
190
+ height: 6,
191
+ options: [
192
+ { name: "➡️ Continue", description: "Verify token and continue" },
193
+ { name: "✕ Cancel", description: "Return to main menu" },
194
+ ],
195
+ })
196
+
197
+ nav.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
198
+ if (index === 0) {
199
+ if (!this.apiToken.trim()) {
200
+ return // Don't proceed without token
201
+ }
202
+ // Verify token by trying to list zones
203
+ try {
204
+ const api = new CloudflareApi(this.apiToken)
205
+ const zones = await api.listZones()
206
+ if (zones.length === 0) {
207
+ this.error = "No zones found. Check token permissions."
208
+ this.renderContent()
209
+ return
210
+ }
211
+ // Auto-detect domain if not set
212
+ if (!this.domain && zones.length > 0) {
213
+ this.domain = zones[0].name
214
+ }
215
+ this.step = "domain"
216
+ this.error = null
217
+ this.renderContent()
218
+ } catch (e) {
219
+ this.error = `Invalid token: ${(e as Error).message}`
220
+ this.renderContent()
221
+ }
222
+ } else {
223
+ this.cleanup()
224
+ this.onBack()
225
+ }
226
+ })
227
+
228
+ content.add(nav)
229
+
230
+ if (this.error) {
231
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
232
+ content.add(
233
+ new TextRenderable(this.cliRenderer, {
234
+ content: `⚠️ ${this.error}`,
235
+ fg: "#ff6666",
236
+ })
237
+ )
238
+ }
239
+
240
+ // Focus on input then nav
241
+ tokenInput.focus()
242
+
243
+ this.keyHandler = (key: KeyEvent) => {
244
+ if (key.name === "escape") {
245
+ this.cleanup()
246
+ this.onBack()
247
+ } else if (key.name === "tab") {
248
+ tokenInput.blur()
249
+ nav.focus()
250
+ }
251
+ }
252
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
253
+ }
254
+
255
+ private renderDomainStep(content: BoxRenderable): void {
256
+ content.add(
257
+ new TextRenderable(this.cliRenderer, {
258
+ content: "Configure your domain for the tunnel:",
259
+ fg: "#888888",
260
+ })
261
+ )
262
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
263
+
264
+ // Domain input
265
+ const domainRow = new BoxRenderable(this.cliRenderer, {
266
+ width: "100%",
267
+ height: 1,
268
+ flexDirection: "row",
269
+ })
270
+ domainRow.add(
271
+ new TextRenderable(this.cliRenderer, {
272
+ content: "Domain: ",
273
+ fg: "#aaaaaa",
274
+ })
275
+ )
276
+ const domainInput = new InputRenderable(this.cliRenderer, {
277
+ id: "cf-domain",
278
+ width: 40,
279
+ placeholder: "example.com",
280
+ value: this.domain,
281
+ })
282
+ domainInput.onPaste = (v) => {
283
+ this.domain = v.text.replace(/[\r\n]/g, "")
284
+ domainInput.value = this.domain
285
+ }
286
+ domainInput.on(InputRenderableEvents.CHANGE, (v) => (this.domain = v))
287
+ domainRow.add(domainInput)
288
+ content.add(domainRow)
289
+
290
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
291
+
292
+ // Tunnel name input
293
+ const nameRow = new BoxRenderable(this.cliRenderer, {
294
+ width: "100%",
295
+ height: 1,
296
+ flexDirection: "row",
297
+ })
298
+ nameRow.add(
299
+ new TextRenderable(this.cliRenderer, {
300
+ content: "Tunnel name: ",
301
+ fg: "#aaaaaa",
302
+ })
303
+ )
304
+ const nameInput = new InputRenderable(this.cliRenderer, {
305
+ id: "cf-tunnel-name",
306
+ width: 30,
307
+ placeholder: "easiarr",
308
+ value: this.tunnelName,
309
+ })
310
+ nameInput.onPaste = (v) => {
311
+ this.tunnelName = v.text.replace(/[\r\n]/g, "")
312
+ nameInput.value = this.tunnelName
313
+ }
314
+ nameInput.on(InputRenderableEvents.CHANGE, (v) => (this.tunnelName = v))
315
+ nameRow.add(nameInput)
316
+ content.add(nameRow)
317
+
318
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
319
+
320
+ content.add(
321
+ new TextRenderable(this.cliRenderer, {
322
+ content: `Services will be accessible at: *.${this.domain || "example.com"}`,
323
+ fg: "#50fa7b",
324
+ })
325
+ )
326
+
327
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
328
+
329
+ // Access email (optional)
330
+ content.add(
331
+ new TextRenderable(this.cliRenderer, {
332
+ content: "Cloudflare Access (optional - adds email login):",
333
+ fg: "#888888",
334
+ })
335
+ )
336
+ const emailRow = new BoxRenderable(this.cliRenderer, {
337
+ width: "100%",
338
+ height: 1,
339
+ flexDirection: "row",
340
+ })
341
+ emailRow.add(
342
+ new TextRenderable(this.cliRenderer, {
343
+ content: "Email: ",
344
+ fg: "#aaaaaa",
345
+ })
346
+ )
347
+ const emailInput = new InputRenderable(this.cliRenderer, {
348
+ id: "cf-email",
349
+ width: 40,
350
+ placeholder: "your@email.com (leave blank to skip)",
351
+ value: this.accessEmail,
352
+ })
353
+ emailInput.onPaste = (v) => {
354
+ this.accessEmail = v.text.replace(/[\r\n]/g, "")
355
+ emailInput.value = this.accessEmail
356
+ }
357
+ emailInput.on(InputRenderableEvents.CHANGE, (v) => (this.accessEmail = v))
358
+ emailRow.add(emailInput)
359
+ content.add(emailRow)
360
+
361
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
362
+
363
+ // Navigation
364
+ const nav = new SelectRenderable(this.cliRenderer, {
365
+ id: "cf-domain-nav",
366
+ width: "100%",
367
+ height: 8,
368
+ options: [
369
+ { name: "◀ Back", description: "Go back to API token" },
370
+ { name: "➡️ Continue", description: "Review and confirm" },
371
+ { name: "✕ Cancel", description: "Return to main menu" },
372
+ ],
373
+ })
374
+
375
+ nav.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
376
+ if (index === 0) {
377
+ this.step = "api_token"
378
+ this.renderContent()
379
+ } else if (index === 1) {
380
+ if (!this.domain.trim()) return
381
+ this.step = "confirm"
382
+ this.renderContent()
383
+ } else {
384
+ this.cleanup()
385
+ this.onBack()
386
+ }
387
+ })
388
+
389
+ content.add(nav)
390
+
391
+ // Focus management - cycle through inputs with Tab
392
+ const focusables = [domainInput, nameInput, emailInput, nav]
393
+ let focusIndex = 0
394
+ focusables[focusIndex].focus()
395
+
396
+ this.keyHandler = (key: KeyEvent) => {
397
+ if (key.name === "escape") {
398
+ this.step = "api_token"
399
+ this.renderContent()
400
+ } else if (key.name === "tab") {
401
+ // Blur current
402
+ focusables[focusIndex].blur()
403
+ // Move to next
404
+ focusIndex = (focusIndex + 1) % focusables.length
405
+ focusables[focusIndex].focus()
406
+ }
407
+ }
408
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
409
+ }
410
+
411
+ private renderConfirmStep(content: BoxRenderable): void {
412
+ content.add(
413
+ new TextRenderable(this.cliRenderer, {
414
+ content: "Review your settings:",
415
+ fg: "#888888",
416
+ })
417
+ )
418
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
419
+
420
+ content.add(
421
+ new TextRenderable(this.cliRenderer, {
422
+ content: `Domain: ${this.domain}`,
423
+ fg: "#50fa7b",
424
+ })
425
+ )
426
+ content.add(
427
+ new TextRenderable(this.cliRenderer, {
428
+ content: `Tunnel name: ${this.tunnelName}`,
429
+ fg: "#50fa7b",
430
+ })
431
+ )
432
+ content.add(
433
+ new TextRenderable(this.cliRenderer, {
434
+ content: `Ingress: *.${this.domain} → http://traefik:80`,
435
+ fg: "#50fa7b",
436
+ })
437
+ )
438
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
439
+
440
+ content.add(
441
+ new TextRenderable(this.cliRenderer, {
442
+ content: "This will:",
443
+ fg: "#888888",
444
+ })
445
+ )
446
+ content.add(
447
+ new TextRenderable(this.cliRenderer, {
448
+ content: " 1. Create/update Cloudflare Tunnel",
449
+ fg: "#aaaaaa",
450
+ })
451
+ )
452
+ content.add(
453
+ new TextRenderable(this.cliRenderer, {
454
+ content: ` 2. Add DNS CNAME: *.${this.domain}`,
455
+ fg: "#aaaaaa",
456
+ })
457
+ )
458
+ content.add(
459
+ new TextRenderable(this.cliRenderer, {
460
+ content: " 3. Save tunnel token to .env",
461
+ fg: "#aaaaaa",
462
+ })
463
+ )
464
+ content.add(
465
+ new TextRenderable(this.cliRenderer, {
466
+ content: " 4. Update Traefik domain/entrypoint",
467
+ fg: "#aaaaaa",
468
+ })
469
+ )
470
+ if (this.accessEmail.trim()) {
471
+ content.add(
472
+ new TextRenderable(this.cliRenderer, {
473
+ content: ` 5. Create Cloudflare Access for: ${this.accessEmail}`,
474
+ fg: "#aaaaaa",
475
+ })
476
+ )
477
+ }
478
+
479
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
480
+
481
+ // Navigation
482
+ const nav = new SelectRenderable(this.cliRenderer, {
483
+ id: "cf-confirm-nav",
484
+ width: "100%",
485
+ height: 8,
486
+ options: [
487
+ { name: "◀ Back", description: "Go back to domain settings" },
488
+ { name: "🚀 Setup Tunnel", description: "Create tunnel and configure DNS" },
489
+ { name: "✕ Cancel", description: "Return to main menu" },
490
+ ],
491
+ })
492
+
493
+ nav.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
494
+ if (index === 0) {
495
+ this.step = "domain"
496
+ this.renderContent()
497
+ } else if (index === 1) {
498
+ this.step = "progress"
499
+ this.renderContent()
500
+ await this.runSetup()
501
+ } else {
502
+ this.cleanup()
503
+ this.onBack()
504
+ }
505
+ })
506
+
507
+ content.add(nav)
508
+ nav.focus()
509
+
510
+ this.keyHandler = (key: KeyEvent) => {
511
+ if (key.name === "escape") {
512
+ this.step = "domain"
513
+ this.renderContent()
514
+ }
515
+ }
516
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
517
+ }
518
+
519
+ private renderProgressStep(content: BoxRenderable): void {
520
+ content.add(
521
+ new TextRenderable(this.cliRenderer, {
522
+ content: "Setting up Cloudflare Tunnel...",
523
+ fg: "#4a9eff",
524
+ })
525
+ )
526
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
527
+
528
+ for (const msg of this.statusMessages) {
529
+ content.add(
530
+ new TextRenderable(this.cliRenderer, {
531
+ content: msg,
532
+ fg: msg.startsWith("✓") ? "#50fa7b" : msg.startsWith("✗") ? "#ff6666" : "#aaaaaa",
533
+ })
534
+ )
535
+ }
536
+
537
+ if (this.error) {
538
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
539
+ content.add(
540
+ new TextRenderable(this.cliRenderer, {
541
+ content: `Error: ${this.error}`,
542
+ fg: "#ff6666",
543
+ })
544
+ )
545
+ }
546
+ }
547
+
548
+ private renderDoneStep(content: BoxRenderable): void {
549
+ content.add(
550
+ new TextRenderable(this.cliRenderer, {
551
+ content: "✓ Cloudflare Tunnel setup complete!",
552
+ fg: "#50fa7b",
553
+ })
554
+ )
555
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
556
+
557
+ for (const msg of this.statusMessages) {
558
+ content.add(
559
+ new TextRenderable(this.cliRenderer, {
560
+ content: msg,
561
+ fg: msg.startsWith("✓") ? "#50fa7b" : "#aaaaaa",
562
+ })
563
+ )
564
+ }
565
+
566
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
567
+ content.add(
568
+ new TextRenderable(this.cliRenderer, {
569
+ content: "Next steps:",
570
+ fg: "#888888",
571
+ })
572
+ )
573
+ content.add(
574
+ new TextRenderable(this.cliRenderer, {
575
+ content: ` 1. Restart containers: docker compose up -d --force-recreate`,
576
+ fg: "#aaaaaa",
577
+ })
578
+ )
579
+ content.add(
580
+ new TextRenderable(this.cliRenderer, {
581
+ content: ` 2. Access services at: https://radarr.${this.domain}`,
582
+ fg: "#aaaaaa",
583
+ })
584
+ )
585
+ if (!this.accessEmail.trim()) {
586
+ content.add(
587
+ new TextRenderable(this.cliRenderer, {
588
+ content: " 3. (Recommended) Set up Cloudflare Access for authentication",
589
+ fg: "#aaaaaa",
590
+ })
591
+ )
592
+ } else {
593
+ content.add(
594
+ new TextRenderable(this.cliRenderer, {
595
+ content: ` 3. ✓ Services protected by email authentication`,
596
+ fg: "#50fa7b",
597
+ })
598
+ )
599
+ }
600
+
601
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
602
+
603
+ const nav = new SelectRenderable(this.cliRenderer, {
604
+ id: "cf-done-nav",
605
+ width: "100%",
606
+ height: 4,
607
+ options: [{ name: "✓ Done", description: "Return to main menu" }],
608
+ })
609
+
610
+ nav.on(SelectRenderableEvents.ITEM_SELECTED, () => {
611
+ this.cleanup()
612
+ this.onBack()
613
+ })
614
+
615
+ content.add(nav)
616
+ nav.focus()
617
+
618
+ this.keyHandler = (key: KeyEvent) => {
619
+ if (key.name === "escape" || key.name === "return") {
620
+ this.cleanup()
621
+ this.onBack()
622
+ }
623
+ }
624
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
625
+ }
626
+
627
+ private async runSetup(): Promise<void> {
628
+ this.statusMessages = []
629
+ this.error = null
630
+
631
+ try {
632
+ // Step 1: Setup tunnel
633
+ this.statusMessages.push("Creating/updating Cloudflare Tunnel...")
634
+ this.renderContent()
635
+
636
+ const result = await setupCloudflaredTunnel(this.apiToken, this.domain, this.tunnelName)
637
+
638
+ this.statusMessages.pop()
639
+ this.statusMessages.push(`✓ Tunnel created: ${this.tunnelName}`)
640
+ this.statusMessages.push(`✓ DNS CNAME added: *.${this.domain}`)
641
+ this.statusMessages.push(`✓ Ingress configured: *.${this.domain} → traefik:80`)
642
+ this.renderContent()
643
+
644
+ // Step 2: Save to .env
645
+ this.statusMessages.push("Saving credentials to .env...")
646
+ this.renderContent()
647
+
648
+ await updateEnv({
649
+ CLOUDFLARE_API_TOKEN: this.apiToken,
650
+ CLOUDFLARE_TUNNEL_TOKEN: result.tunnelToken,
651
+ CLOUDFLARE_DNS_ZONE: this.domain,
652
+ })
653
+
654
+ this.statusMessages.pop()
655
+ this.statusMessages.push("✓ Credentials saved to .env")
656
+ this.renderContent()
657
+
658
+ // Step 3: Update config
659
+ this.statusMessages.push("Updating configuration...")
660
+ this.renderContent()
661
+
662
+ // Enable cloudflared if not enabled
663
+ const cloudflaredApp = this.config.apps.find((a) => a.id === "cloudflared")
664
+ if (cloudflaredApp) {
665
+ cloudflaredApp.enabled = true
666
+ } else {
667
+ this.config.apps.push({ id: "cloudflared", enabled: true })
668
+ }
669
+
670
+ // Update Traefik settings
671
+ if (this.config.traefik) {
672
+ this.config.traefik.domain = this.domain
673
+ this.config.traefik.entrypoint = "web" // Use web for tunnel
674
+ }
675
+
676
+ this.config.updatedAt = new Date().toISOString()
677
+ await saveConfig(this.config)
678
+ await saveCompose(this.config)
679
+
680
+ this.statusMessages.pop()
681
+ this.statusMessages.push("✓ Configuration updated")
682
+ this.statusMessages.push("✓ docker-compose.yml regenerated")
683
+ this.renderContent()
684
+
685
+ // Step 4: Optional Access setup OR Basic Auth
686
+ if (this.accessEmail.trim()) {
687
+ this.statusMessages.push("Creating Cloudflare Access...")
688
+ this.renderContent()
689
+
690
+ const api = new CloudflareApi(this.apiToken)
691
+ await api.setupAccessProtection(this.domain, [this.accessEmail.trim()], "easiarr")
692
+
693
+ this.statusMessages.pop()
694
+ this.statusMessages.push(`✓ Cloudflare Access created for: ${this.accessEmail}`)
695
+ this.renderContent()
696
+ } else {
697
+ // No Cloudflare Access - enable basic auth with global credentials
698
+ const env = readEnvSync()
699
+ const username = env.USERNAME_GLOBAL
700
+ const password = env.PASSWORD_GLOBAL
701
+
702
+ if (username && password) {
703
+ this.statusMessages.push("Enabling basic auth protection...")
704
+ this.renderContent()
705
+
706
+ // Update traefik config with basic auth
707
+ if (this.config.traefik) {
708
+ this.config.traefik.basicAuth = {
709
+ enabled: true,
710
+ username,
711
+ password,
712
+ }
713
+ // Add basic-auth middleware
714
+ if (!this.config.traefik.middlewares.includes("basic-auth")) {
715
+ this.config.traefik.middlewares.push("basic-auth")
716
+ }
717
+ }
718
+
719
+ // Re-save config and compose
720
+ await saveConfig(this.config)
721
+ await saveCompose(this.config)
722
+
723
+ this.statusMessages.pop()
724
+ this.statusMessages.push(`✓ Basic auth enabled (username: ${username})`)
725
+ this.renderContent()
726
+ } else {
727
+ this.statusMessages.push("⚠️ No protection enabled (no email or GLOBAL_PASSWORD set)")
728
+ this.renderContent()
729
+ }
730
+ }
731
+
732
+ // Done!
733
+ this.step = "done"
734
+ this.renderContent()
735
+ } catch (e) {
736
+ this.error = (e as Error).message
737
+ this.statusMessages.push(`✗ Setup failed: ${this.error}`)
738
+ this.renderContent()
739
+
740
+ // Add back button on error
741
+ setTimeout(() => {
742
+ this.step = "confirm"
743
+ this.renderContent()
744
+ }, 3000)
745
+ }
746
+ }
747
+
748
+ private cleanup(): void {
749
+ if (this.keyHandler) {
750
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
751
+ this.keyHandler = null
752
+ }
753
+ const parent = this.parent
754
+ if (parent) {
755
+ parent.remove(this.id)
756
+ }
757
+ }
758
+ }