@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,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traefik Configuration Generator
|
|
3
|
+
* Generates traefik.yml (static config) and dynamic.yml (middlewares)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFile, mkdir } from "node:fs/promises"
|
|
7
|
+
import { existsSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { createHash } from "node:crypto"
|
|
10
|
+
import type { EasiarrConfig } from "../config/schema"
|
|
11
|
+
|
|
12
|
+
export interface TraefikStaticConfig {
|
|
13
|
+
entrypoints: {
|
|
14
|
+
web: { address: string }
|
|
15
|
+
}
|
|
16
|
+
api: {
|
|
17
|
+
dashboard: boolean
|
|
18
|
+
insecure: boolean
|
|
19
|
+
}
|
|
20
|
+
providers: {
|
|
21
|
+
docker: {
|
|
22
|
+
endpoint: string
|
|
23
|
+
exposedByDefault: boolean
|
|
24
|
+
}
|
|
25
|
+
file: {
|
|
26
|
+
directory: string
|
|
27
|
+
watch: boolean
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate traefik.yml static configuration
|
|
34
|
+
*/
|
|
35
|
+
export function generateTraefikStaticConfig(): string {
|
|
36
|
+
return `# Traefik Static Configuration
|
|
37
|
+
# Generated by easiarr
|
|
38
|
+
|
|
39
|
+
api:
|
|
40
|
+
dashboard: true
|
|
41
|
+
insecure: true
|
|
42
|
+
|
|
43
|
+
entryPoints:
|
|
44
|
+
web:
|
|
45
|
+
address: ":80"
|
|
46
|
+
|
|
47
|
+
providers:
|
|
48
|
+
docker:
|
|
49
|
+
endpoint: "unix:///var/run/docker.sock"
|
|
50
|
+
exposedByDefault: false
|
|
51
|
+
file:
|
|
52
|
+
directory: "/etc/traefik"
|
|
53
|
+
watch: true
|
|
54
|
+
`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate dynamic.yml with security headers middleware
|
|
59
|
+
*/
|
|
60
|
+
export function generateTraefikDynamicConfig(
|
|
61
|
+
_middlewares: string[],
|
|
62
|
+
basicAuth?: { username: string; passwordHash: string }
|
|
63
|
+
): string {
|
|
64
|
+
let yaml = `# Traefik Dynamic Configuration
|
|
65
|
+
# Generated by easiarr
|
|
66
|
+
|
|
67
|
+
http:
|
|
68
|
+
middlewares:
|
|
69
|
+
`
|
|
70
|
+
|
|
71
|
+
// Always include security-headers
|
|
72
|
+
yaml += ` security-headers:
|
|
73
|
+
headers:
|
|
74
|
+
browserXssFilter: true
|
|
75
|
+
contentTypeNosniff: true
|
|
76
|
+
forceSTSHeader: true
|
|
77
|
+
stsIncludeSubdomains: true
|
|
78
|
+
stsPreload: true
|
|
79
|
+
stsSeconds: 31536000
|
|
80
|
+
customFrameOptionsValue: "SAMEORIGIN"
|
|
81
|
+
`
|
|
82
|
+
|
|
83
|
+
// Add basic-auth middleware if credentials provided
|
|
84
|
+
if (basicAuth?.username && basicAuth?.passwordHash) {
|
|
85
|
+
// Escape $ in password hash for YAML
|
|
86
|
+
const escapedHash = basicAuth.passwordHash.replace(/\$/g, "$$$$")
|
|
87
|
+
yaml += `
|
|
88
|
+
basic-auth:
|
|
89
|
+
basicAuth:
|
|
90
|
+
users:
|
|
91
|
+
- "${basicAuth.username}:${escapedHash}"
|
|
92
|
+
`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return yaml
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate htpasswd-compatible hash for basic auth
|
|
100
|
+
* Uses SHA1 hash in htpasswd format: {SHA}base64(sha1(password))
|
|
101
|
+
*/
|
|
102
|
+
function generateHtpasswdHash(password: string): string {
|
|
103
|
+
const sha1Hash = createHash("sha1").update(password).digest("base64")
|
|
104
|
+
return `{SHA}${sha1Hash}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Save Traefik configuration files to the config directory
|
|
109
|
+
*/
|
|
110
|
+
export async function saveTraefikConfig(config: EasiarrConfig): Promise<void> {
|
|
111
|
+
// Check if traefik is enabled
|
|
112
|
+
const traefikApp = config.apps.find((a) => a.id === "traefik" && a.enabled)
|
|
113
|
+
if (!traefikApp) return
|
|
114
|
+
|
|
115
|
+
const traefikConfigDir = join(config.rootDir, "config", "traefik")
|
|
116
|
+
const letsencryptDir = join(traefikConfigDir, "letsencrypt")
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// Create directories if they don't exist
|
|
120
|
+
if (!existsSync(traefikConfigDir)) {
|
|
121
|
+
await mkdir(traefikConfigDir, { recursive: true })
|
|
122
|
+
}
|
|
123
|
+
if (!existsSync(letsencryptDir)) {
|
|
124
|
+
await mkdir(letsencryptDir, { recursive: true })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Generate and save static config (traefik.yml)
|
|
128
|
+
const staticConfig = generateTraefikStaticConfig()
|
|
129
|
+
const staticPath = join(traefikConfigDir, "traefik.yml")
|
|
130
|
+
|
|
131
|
+
// Only write if file doesn't exist (don't overwrite user customizations)
|
|
132
|
+
if (!existsSync(staticPath)) {
|
|
133
|
+
await writeFile(staticPath, staticConfig, "utf-8")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check for basic auth credentials from config
|
|
137
|
+
let basicAuth: { username: string; passwordHash: string } | undefined
|
|
138
|
+
if (config.traefik?.basicAuth?.enabled) {
|
|
139
|
+
const username = config.traefik.basicAuth.username
|
|
140
|
+
const password = config.traefik.basicAuth.password
|
|
141
|
+
if (username && password) {
|
|
142
|
+
basicAuth = {
|
|
143
|
+
username,
|
|
144
|
+
passwordHash: generateHtpasswdHash(password),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Generate and save dynamic config (dynamic.yml)
|
|
150
|
+
const dynamicConfig = generateTraefikDynamicConfig(config.traefik?.middlewares ?? [], basicAuth)
|
|
151
|
+
const dynamicPath = join(traefikConfigDir, "dynamic.yml")
|
|
152
|
+
|
|
153
|
+
// Always regenerate dynamic.yml as it contains auth settings
|
|
154
|
+
await writeFile(dynamicPath, dynamicConfig, "utf-8")
|
|
155
|
+
|
|
156
|
+
// Create acme.json with correct permissions if it doesn't exist
|
|
157
|
+
const acmePath = join(letsencryptDir, "acme.json")
|
|
158
|
+
if (!existsSync(acmePath)) {
|
|
159
|
+
await writeFile(acmePath, "{}", { mode: 0o600 })
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// Permission denied - directory owned by root from Docker
|
|
163
|
+
// User needs to manually create configs or fix permissions
|
|
164
|
+
const err = error as NodeJS.ErrnoException
|
|
165
|
+
if (err.code === "EACCES") {
|
|
166
|
+
console.warn(
|
|
167
|
+
`[WARN] Cannot write Traefik config files (permission denied). ` +
|
|
168
|
+
`Fix with: sudo chown -R $(id -u):$(id -g) ${traefikConfigDir}`
|
|
169
|
+
)
|
|
170
|
+
} else {
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bookmarks Generator
|
|
3
|
+
* Generates Netscape-format HTML bookmarks for browser import
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFile } from "node:fs/promises"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import { homedir } from "node:os"
|
|
9
|
+
import type { EasiarrConfig, AppCategory } from "./schema"
|
|
10
|
+
import { APP_CATEGORIES } from "./schema"
|
|
11
|
+
import { CATEGORY_ORDER } from "../apps/categories"
|
|
12
|
+
import { getApp } from "../apps/registry"
|
|
13
|
+
import { readEnvSync } from "../utils/env"
|
|
14
|
+
|
|
15
|
+
interface BookmarkEntry {
|
|
16
|
+
name: string
|
|
17
|
+
url: string
|
|
18
|
+
description: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CategoryBookmarks = Map<AppCategory, BookmarkEntry[]>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the URL for an app based on Traefik configuration
|
|
25
|
+
*/
|
|
26
|
+
function getAppUrl(appId: string, port: number, config: EasiarrConfig, useLocalUrls: boolean): string {
|
|
27
|
+
if (!useLocalUrls && config.traefik?.enabled && config.traefik.domain) {
|
|
28
|
+
return `https://${appId}.${config.traefik.domain}/`
|
|
29
|
+
}
|
|
30
|
+
// Read LOCAL_DOCKER_IP from .env file, fallback to localhost
|
|
31
|
+
const env = readEnvSync()
|
|
32
|
+
const host = env.LOCAL_DOCKER_IP || "localhost"
|
|
33
|
+
return `http://${host}:${port}/`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate bookmark entries grouped by category
|
|
38
|
+
*/
|
|
39
|
+
function generateBookmarksByCategory(config: EasiarrConfig, useLocalUrls: boolean): CategoryBookmarks {
|
|
40
|
+
const categoryBookmarks: CategoryBookmarks = new Map()
|
|
41
|
+
|
|
42
|
+
for (const appConfig of config.apps) {
|
|
43
|
+
if (!appConfig.enabled) continue
|
|
44
|
+
|
|
45
|
+
const appDef = getApp(appConfig.id)
|
|
46
|
+
if (!appDef) continue
|
|
47
|
+
|
|
48
|
+
const port = appConfig.port ?? appDef.defaultPort
|
|
49
|
+
const url = getAppUrl(appConfig.id, port, config, useLocalUrls)
|
|
50
|
+
|
|
51
|
+
const entry: BookmarkEntry = {
|
|
52
|
+
name: appDef.name,
|
|
53
|
+
url,
|
|
54
|
+
description: appDef.description,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const category = appDef.category
|
|
58
|
+
if (!categoryBookmarks.has(category)) {
|
|
59
|
+
categoryBookmarks.set(category, [])
|
|
60
|
+
}
|
|
61
|
+
categoryBookmarks.get(category)!.push(entry)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return categoryBookmarks
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate Netscape-format HTML bookmarks
|
|
69
|
+
*/
|
|
70
|
+
export function generateBookmarksHtml(config: EasiarrConfig, useLocalUrls = false): string {
|
|
71
|
+
const categoryBookmarks = generateBookmarksByCategory(config, useLocalUrls)
|
|
72
|
+
|
|
73
|
+
let html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
|
74
|
+
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
|
|
75
|
+
<TITLE>Bookmarks</TITLE>
|
|
76
|
+
<H1>Bookmarks</H1>
|
|
77
|
+
<DL><p>
|
|
78
|
+
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">easiarr</H3>
|
|
79
|
+
<DL><p>
|
|
80
|
+
`
|
|
81
|
+
|
|
82
|
+
// Add external resources first
|
|
83
|
+
html += ` <DT><A HREF="https://github.com/muhammedaksam/easiarr/">GitHub | easiarr Project Repo</A>\n`
|
|
84
|
+
html += ` <DT><A HREF="https://trash-guides.info/">TRaSH Guides</A>\n`
|
|
85
|
+
|
|
86
|
+
// Add apps grouped by category in defined order
|
|
87
|
+
for (const { id: categoryId } of CATEGORY_ORDER) {
|
|
88
|
+
const bookmarks = categoryBookmarks.get(categoryId)
|
|
89
|
+
if (!bookmarks || bookmarks.length === 0) continue
|
|
90
|
+
|
|
91
|
+
const categoryName = APP_CATEGORIES[categoryId]
|
|
92
|
+
|
|
93
|
+
// Add category header as a folder
|
|
94
|
+
html += ` <DT><H3>${categoryName}</H3>\n`
|
|
95
|
+
html += ` <DL><p>\n`
|
|
96
|
+
|
|
97
|
+
for (const bookmark of bookmarks) {
|
|
98
|
+
html += ` <DT><A HREF="${bookmark.url}">${bookmark.name} | ${bookmark.description}</A>\n`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
html += ` </DL><p>\n`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Close the structure
|
|
105
|
+
html += ` </DL><p>
|
|
106
|
+
</DL><p>
|
|
107
|
+
`
|
|
108
|
+
|
|
109
|
+
return html
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get the path to the bookmarks file
|
|
114
|
+
*/
|
|
115
|
+
export function getBookmarksPath(): string {
|
|
116
|
+
return join(homedir(), ".easiarr", "bookmarks.html")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Save bookmarks HTML file
|
|
121
|
+
*/
|
|
122
|
+
export async function saveBookmarks(config: EasiarrConfig, useLocalUrls = false): Promise<string> {
|
|
123
|
+
const html = generateBookmarksHtml(config, useLocalUrls)
|
|
124
|
+
const path = getBookmarksPath()
|
|
125
|
+
await writeFile(path, html, "utf-8")
|
|
126
|
+
return path
|
|
127
|
+
}
|
|
@@ -134,18 +134,18 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// Build YAML output
|
|
137
|
-
let yaml = "---\n# Auto-generated by
|
|
137
|
+
let yaml = "---\n# Auto-generated by easiarr\n# https://github.com/muhammedaksam/easiarr\n\n"
|
|
138
138
|
|
|
139
|
-
// Add
|
|
140
|
-
yaml += `-
|
|
141
|
-
// Installed version from local easiarr
|
|
139
|
+
// Add easiarr info section with two widgets - one for installed, one for latest
|
|
140
|
+
yaml += `- easiarr:\n`
|
|
141
|
+
// Installed version from local easiarr container
|
|
142
142
|
yaml += ` - Installed:\n`
|
|
143
143
|
yaml += ` href: https://github.com/muhammedaksam/easiarr\n`
|
|
144
144
|
yaml += ` icon: mdi-docker\n`
|
|
145
145
|
yaml += ` description: Your current version\n`
|
|
146
146
|
yaml += ` widget:\n`
|
|
147
147
|
yaml += ` type: customapi\n`
|
|
148
|
-
yaml += ` url: http://easiarr
|
|
148
|
+
yaml += ` url: http://easiarr:8080/config.json\n`
|
|
149
149
|
yaml += ` refreshInterval: 3600000\n` // 1 hour
|
|
150
150
|
yaml += ` mappings:\n`
|
|
151
151
|
yaml += ` - field: version\n`
|
|
@@ -237,10 +237,10 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
|
|
|
237
237
|
*/
|
|
238
238
|
export function generateSettingsYaml(): string {
|
|
239
239
|
return `---
|
|
240
|
-
# Auto-generated by
|
|
240
|
+
# Auto-generated by easiarr
|
|
241
241
|
# For configuration options: https://gethomepage.dev/configs/settings/
|
|
242
242
|
|
|
243
|
-
title:
|
|
243
|
+
title: easiarr Dashboard
|
|
244
244
|
|
|
245
245
|
# Background: "Close-up Photography of Leaves With Droplets"
|
|
246
246
|
# Photo by Sohail Nachiti: https://www.pexels.com/photo/close-up-photography-of-leaves-with-droplets-807598/
|
package/src/config/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* easiarr Configuration Schema
|
|
3
3
|
* TypeScript interfaces for configuration management
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -65,6 +65,12 @@ export interface TraefikConfig {
|
|
|
65
65
|
domain: string
|
|
66
66
|
entrypoint: string
|
|
67
67
|
middlewares: string[]
|
|
68
|
+
/** Basic auth using username/password */
|
|
69
|
+
basicAuth?: {
|
|
70
|
+
enabled: boolean
|
|
71
|
+
username: string
|
|
72
|
+
password: string
|
|
73
|
+
}
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
export interface AppConfig {
|
|
@@ -120,7 +126,7 @@ export type AppId =
|
|
|
120
126
|
| "guacamole"
|
|
121
127
|
| "guacd"
|
|
122
128
|
| "ddns-updater"
|
|
123
|
-
| "easiarr
|
|
129
|
+
| "easiarr"
|
|
124
130
|
// VPN
|
|
125
131
|
| "gluetun"
|
|
126
132
|
// Monitoring & Infra
|
|
@@ -133,6 +139,7 @@ export type AppId =
|
|
|
133
139
|
// Reverse Proxy
|
|
134
140
|
| "traefik"
|
|
135
141
|
| "traefik-certs-dumper"
|
|
142
|
+
| "cloudflared"
|
|
136
143
|
| "crowdsec"
|
|
137
144
|
// Network/VPN
|
|
138
145
|
| "headscale"
|
|
@@ -176,6 +183,8 @@ export interface AppDefinition {
|
|
|
176
183
|
defaultPort: number
|
|
177
184
|
/** Internal container port if different from defaultPort */
|
|
178
185
|
internalPort?: number
|
|
186
|
+
/** Additional port mappings (e.g., "8083:8080" for dashboard ports) */
|
|
187
|
+
secondaryPorts?: string[]
|
|
179
188
|
image: string
|
|
180
189
|
puid: number
|
|
181
190
|
pgid: number
|
|
@@ -186,6 +195,8 @@ export interface AppDefinition {
|
|
|
186
195
|
secrets?: AppSecret[]
|
|
187
196
|
devices?: string[]
|
|
188
197
|
cap_add?: string[]
|
|
198
|
+
/** Custom command to run (e.g., "tunnel run" for cloudflared) */
|
|
199
|
+
command?: string
|
|
189
200
|
apiKeyMeta?: ApiKeyMeta
|
|
190
201
|
rootFolder?: RootFolderMeta
|
|
191
202
|
prowlarrCategoryIds?: number[]
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
40
40
|
// Global *arr credentials
|
|
41
41
|
private globalUsername = "admin"
|
|
42
42
|
private globalPassword = ""
|
|
43
|
+
private globalEmail = ""
|
|
43
44
|
private overrideExisting = false
|
|
44
45
|
|
|
45
46
|
// Download client credentials
|
|
@@ -85,6 +86,7 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
85
86
|
const env = readEnvSync()
|
|
86
87
|
if (env.USERNAME_GLOBAL) this.globalUsername = env.USERNAME_GLOBAL
|
|
87
88
|
this.globalPassword = env.PASSWORD_GLOBAL || "Ch4ng3m3!1234securityReasons"
|
|
89
|
+
if (env.EMAIL_GLOBAL) this.globalEmail = env.EMAIL_GLOBAL
|
|
88
90
|
if (env.PASSWORD_QBITTORRENT) this.qbPass = env.PASSWORD_QBITTORRENT
|
|
89
91
|
if (env.API_KEY_SABNZBD) this.sabApiKey = env.API_KEY_SABNZBD
|
|
90
92
|
}
|
|
@@ -138,6 +140,19 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
138
140
|
|
|
139
141
|
content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
|
|
140
142
|
|
|
143
|
+
// Email input (for Cloudflare Access, notifications, etc.)
|
|
144
|
+
content.add(new TextRenderable(this.cliRenderer, { content: "Email (optional):", fg: "#aaaaaa" }))
|
|
145
|
+
const emailInput = new InputRenderable(this.cliRenderer, {
|
|
146
|
+
id: "global-email-input",
|
|
147
|
+
width: 40,
|
|
148
|
+
placeholder: "you@example.com",
|
|
149
|
+
value: this.globalEmail,
|
|
150
|
+
focusedBackgroundColor: "#1a1a1a",
|
|
151
|
+
})
|
|
152
|
+
content.add(emailInput)
|
|
153
|
+
|
|
154
|
+
content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
|
|
155
|
+
|
|
141
156
|
// Override toggle
|
|
142
157
|
const overrideText = new TextRenderable(this.cliRenderer, {
|
|
143
158
|
id: "override-toggle",
|
|
@@ -160,13 +175,17 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
160
175
|
overrideText.content = `[O] Override existing: ${this.overrideExisting ? "Yes" : "No"}`
|
|
161
176
|
overrideText.fg = this.overrideExisting ? "#50fa7b" : "#6272a4"
|
|
162
177
|
} else if (key.name === "tab") {
|
|
163
|
-
// Cycle focus: username -> password -> no focus (shortcuts work) -> username
|
|
178
|
+
// Cycle focus: username -> password -> email -> no focus (shortcuts work) -> username
|
|
164
179
|
if (focusedInput === userInput) {
|
|
165
180
|
userInput.blur()
|
|
166
181
|
passInput.focus()
|
|
167
182
|
focusedInput = passInput
|
|
168
183
|
} else if (focusedInput === passInput) {
|
|
169
184
|
passInput.blur()
|
|
185
|
+
emailInput.focus()
|
|
186
|
+
focusedInput = emailInput
|
|
187
|
+
} else if (focusedInput === emailInput) {
|
|
188
|
+
emailInput.blur()
|
|
170
189
|
focusedInput = null // No focus state - shortcuts available
|
|
171
190
|
} else {
|
|
172
191
|
// No input focused, go back to username
|
|
@@ -178,6 +197,7 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
178
197
|
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
179
198
|
userInput.blur()
|
|
180
199
|
passInput.blur()
|
|
200
|
+
emailInput.blur()
|
|
181
201
|
focusedInput = null
|
|
182
202
|
this.currentStep = "configure"
|
|
183
203
|
this.runConfiguration()
|
|
@@ -185,10 +205,12 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
185
205
|
// Save and continue
|
|
186
206
|
this.globalUsername = userInput.value || "admin"
|
|
187
207
|
this.globalPassword = passInput.value
|
|
208
|
+
this.globalEmail = emailInput.value
|
|
188
209
|
|
|
189
210
|
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
190
211
|
userInput.blur()
|
|
191
212
|
passInput.blur()
|
|
213
|
+
emailInput.blur()
|
|
192
214
|
focusedInput = null
|
|
193
215
|
|
|
194
216
|
// Save credentials to .env
|
|
@@ -206,6 +228,7 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
206
228
|
const updates: Record<string, string> = {}
|
|
207
229
|
if (this.globalUsername) updates.USERNAME_GLOBAL = this.globalUsername
|
|
208
230
|
if (this.globalPassword) updates.PASSWORD_GLOBAL = this.globalPassword
|
|
231
|
+
if (this.globalEmail) updates.EMAIL_GLOBAL = this.globalEmail
|
|
209
232
|
await updateEnv(updates)
|
|
210
233
|
} catch {
|
|
211
234
|
// Ignore errors - not critical
|