@muhammedaksam/easiarr 0.8.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/package.json +1 -1
- package/src/api/cloudflare-api.ts +368 -0
- package/src/api/jellyfin-api.ts +1 -1
- package/src/apps/registry.ts +43 -12
- package/src/compose/generator.ts +27 -4
- package/src/compose/index.ts +1 -0
- package/src/compose/templates.ts +5 -0
- package/src/compose/traefik-config.ts +174 -0
- package/src/config/bookmarks-generator.ts +127 -0
- package/src/config/homepage-config.ts +7 -7
- package/src/config/schema.ts +13 -2
- package/src/index.ts +1 -1
- package/src/ui/screens/AppConfigurator.ts +24 -1
- package/src/ui/screens/AppManager.ts +1 -1
- package/src/ui/screens/CloudflaredSetup.ts +758 -0
- package/src/ui/screens/FullAutoSetup.ts +72 -0
- package/src/ui/screens/JellyfinSetup.ts +1 -1
- package/src/ui/screens/MainMenu.ts +45 -1
- package/src/ui/screens/QuickSetup.ts +7 -7
- package/src/ui/screens/SettingsScreen.ts +571 -0
- package/src/utils/browser.ts +26 -0
- package/src/utils/debug.ts +2 -2
- package/src/utils/migrations/1765707135_rename_easiarr_status.ts +90 -0
- package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +44 -0
- package/src/utils/migrations.ts +24 -0
|
@@ -0,0 +1,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
|
+
}
|