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