@muhammedaksam/easiarr 0.5.2 → 0.6.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/package.json +3 -1
- package/src/apps/registry.ts +20 -20
- package/src/index.ts +4 -0
- package/src/ui/screens/ApiKeyViewer.ts +6 -6
- package/src/ui/screens/AppConfigurator.ts +6 -6
- package/src/ui/screens/FullAutoSetup.ts +9 -9
- package/src/utils/migrations/1765626338_rename_env_variables.ts +101 -0
- package/src/utils/migrations.ts +110 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"test:watch": "jest --watch",
|
|
47
47
|
"check": "bun run typecheck && bun run lint && bun run format:check && bun run test",
|
|
48
48
|
"fix": "bun run lint:fix && bun run format",
|
|
49
|
+
"migration:create": "bun run scripts/create-migration.ts",
|
|
50
|
+
"release": "./scripts/release.sh",
|
|
49
51
|
"start": "bun run src/index.ts"
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
package/src/apps/registry.ts
CHANGED
|
@@ -254,13 +254,13 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
254
254
|
environment: { WEBUI_PORT: "8080" },
|
|
255
255
|
secrets: [
|
|
256
256
|
{
|
|
257
|
-
name: "
|
|
257
|
+
name: "USERNAME_QBITTORRENT",
|
|
258
258
|
description: "Username for qBittorrent WebUI",
|
|
259
259
|
required: false,
|
|
260
260
|
default: "admin",
|
|
261
261
|
},
|
|
262
262
|
{
|
|
263
|
-
name: "
|
|
263
|
+
name: "PASSWORD_QBITTORRENT",
|
|
264
264
|
description: "Password for qBittorrent WebUI",
|
|
265
265
|
required: false,
|
|
266
266
|
mask: true,
|
|
@@ -516,19 +516,19 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
516
516
|
GUACD_HOSTNAME: "guacd",
|
|
517
517
|
POSTGRESQL_HOSTNAME: "postgresql",
|
|
518
518
|
POSTGRESQL_DATABASE: "guacamole",
|
|
519
|
-
POSTGRESQL_USER: "${
|
|
520
|
-
POSTGRESQL_PASSWORD: "${
|
|
519
|
+
POSTGRESQL_USER: "${USERNAME_POSTGRESQL}",
|
|
520
|
+
POSTGRESQL_PASSWORD: "${PASSWORD_POSTGRESQL}",
|
|
521
521
|
},
|
|
522
522
|
dependsOn: ["guacd", "postgresql"],
|
|
523
523
|
secrets: [
|
|
524
524
|
{
|
|
525
|
-
name: "
|
|
525
|
+
name: "USERNAME_POSTGRESQL",
|
|
526
526
|
description: "PostgreSQL Username",
|
|
527
527
|
required: true,
|
|
528
528
|
default: "postgres",
|
|
529
529
|
},
|
|
530
530
|
{
|
|
531
|
-
name: "
|
|
531
|
+
name: "PASSWORD_POSTGRESQL",
|
|
532
532
|
description: "PostgreSQL Password",
|
|
533
533
|
required: true,
|
|
534
534
|
mask: true,
|
|
@@ -576,8 +576,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
576
576
|
volumes: (root) => [`${root}/config/gluetun:/gluetun`],
|
|
577
577
|
environment: {
|
|
578
578
|
VPN_SERVICE_PROVIDER: "${VPN_SERVICE_PROVIDER}",
|
|
579
|
-
OPENVPN_USER: "${
|
|
580
|
-
OPENVPN_PASSWORD: "${
|
|
579
|
+
OPENVPN_USER: "${USERNAME_VPN}",
|
|
580
|
+
OPENVPN_PASSWORD: "${PASSWORD_VPN}",
|
|
581
581
|
WIREGUARD_PRIVATE_KEY: "${WIREGUARD_PRIVATE_KEY}",
|
|
582
582
|
HTTPPROXY: "on",
|
|
583
583
|
SHADOWSOCKS: "on",
|
|
@@ -590,12 +590,12 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
590
590
|
default: "custom",
|
|
591
591
|
},
|
|
592
592
|
{
|
|
593
|
-
name: "
|
|
593
|
+
name: "USERNAME_VPN",
|
|
594
594
|
description: "OpenVPN Username",
|
|
595
595
|
required: false,
|
|
596
596
|
},
|
|
597
597
|
{
|
|
598
|
-
name: "
|
|
598
|
+
name: "PASSWORD_VPN",
|
|
599
599
|
description: "OpenVPN Password",
|
|
600
600
|
required: false,
|
|
601
601
|
mask: true,
|
|
@@ -777,8 +777,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
777
777
|
AUTHENTIK_REDIS__HOST: "valkey",
|
|
778
778
|
AUTHENTIK_POSTGRESQL__HOST: "postgresql",
|
|
779
779
|
AUTHENTIK_POSTGRESQL__NAME: "authentik",
|
|
780
|
-
AUTHENTIK_POSTGRESQL__USER: "${
|
|
781
|
-
AUTHENTIK_POSTGRESQL__PASSWORD: "${
|
|
780
|
+
AUTHENTIK_POSTGRESQL__USER: "${USERNAME_POSTGRESQL}",
|
|
781
|
+
AUTHENTIK_POSTGRESQL__PASSWORD: "${PASSWORD_POSTGRESQL}",
|
|
782
782
|
AUTHENTIK_SECRET_KEY: "${AUTHENTIK_SECRET_KEY}",
|
|
783
783
|
},
|
|
784
784
|
dependsOn: ["postgresql", "valkey", "authentik-worker"],
|
|
@@ -791,13 +791,13 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
791
791
|
generate: true,
|
|
792
792
|
},
|
|
793
793
|
{
|
|
794
|
-
name: "
|
|
794
|
+
name: "USERNAME_POSTGRESQL",
|
|
795
795
|
description: "Postgres Username",
|
|
796
796
|
required: true,
|
|
797
797
|
default: "postgres",
|
|
798
798
|
},
|
|
799
799
|
{
|
|
800
|
-
name: "
|
|
800
|
+
name: "PASSWORD_POSTGRESQL",
|
|
801
801
|
description: "Postgres Password",
|
|
802
802
|
required: true,
|
|
803
803
|
mask: true,
|
|
@@ -824,8 +824,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
824
824
|
AUTHENTIK_REDIS__HOST: "valkey",
|
|
825
825
|
AUTHENTIK_POSTGRESQL__HOST: "postgresql",
|
|
826
826
|
AUTHENTIK_POSTGRESQL__NAME: "authentik",
|
|
827
|
-
AUTHENTIK_POSTGRESQL__USER: "${
|
|
828
|
-
AUTHENTIK_POSTGRESQL__PASSWORD: "${
|
|
827
|
+
AUTHENTIK_POSTGRESQL__USER: "${USERNAME_POSTGRESQL}",
|
|
828
|
+
AUTHENTIK_POSTGRESQL__PASSWORD: "${PASSWORD_POSTGRESQL}",
|
|
829
829
|
AUTHENTIK_SECRET_KEY: "${AUTHENTIK_SECRET_KEY}",
|
|
830
830
|
},
|
|
831
831
|
dependsOn: ["postgresql", "valkey"],
|
|
@@ -842,19 +842,19 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
842
842
|
pgid: 13000,
|
|
843
843
|
volumes: (root) => [`${root}/config/postgresql:/var/lib/postgresql/data`],
|
|
844
844
|
environment: {
|
|
845
|
-
POSTGRES_USER: "${
|
|
846
|
-
POSTGRES_PASSWORD: "${
|
|
845
|
+
POSTGRES_USER: "${USERNAME_POSTGRESQL}",
|
|
846
|
+
POSTGRES_PASSWORD: "${PASSWORD_POSTGRESQL}",
|
|
847
847
|
POSTGRES_DB: "authentik", // Default to authentik db or user needs to change?
|
|
848
848
|
},
|
|
849
849
|
secrets: [
|
|
850
850
|
{
|
|
851
|
-
name: "
|
|
851
|
+
name: "USERNAME_POSTGRESQL",
|
|
852
852
|
description: "PostgreSQL Username",
|
|
853
853
|
required: true,
|
|
854
854
|
default: "postgres",
|
|
855
855
|
},
|
|
856
856
|
{
|
|
857
|
-
name: "
|
|
857
|
+
name: "PASSWORD_POSTGRESQL",
|
|
858
858
|
description: "PostgreSQL Password",
|
|
859
859
|
required: true,
|
|
860
860
|
mask: true,
|
package/src/index.ts
CHANGED
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
import { createCliRenderer } from "@opentui/core"
|
|
13
13
|
import { App } from "./ui/App"
|
|
14
14
|
import { initDebug } from "./utils/debug"
|
|
15
|
+
import { runMigrations } from "./utils/migrations"
|
|
15
16
|
|
|
16
17
|
async function main() {
|
|
17
18
|
// Initialize debug logging if enabled
|
|
18
19
|
initDebug()
|
|
19
20
|
|
|
21
|
+
// Run migrations to update env variable names if needed
|
|
22
|
+
await runMigrations()
|
|
23
|
+
|
|
20
24
|
const renderer = await createCliRenderer({
|
|
21
25
|
consoleOptions: {
|
|
22
26
|
startInDebugMode: false,
|
|
@@ -188,12 +188,12 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
// Will attempt to initialize/login when saving
|
|
191
|
-
const globalPassword = env["
|
|
191
|
+
const globalPassword = env["PASSWORD_GLOBAL"]
|
|
192
192
|
if (!globalPassword) {
|
|
193
193
|
this.keys.push({
|
|
194
194
|
appId: "portainer",
|
|
195
195
|
app: "Portainer",
|
|
196
|
-
key: "No
|
|
196
|
+
key: "No PASSWORD_GLOBAL set in .env",
|
|
197
197
|
status: "missing",
|
|
198
198
|
})
|
|
199
199
|
return
|
|
@@ -409,7 +409,7 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
409
409
|
if (this.portainerCredentials) {
|
|
410
410
|
updates["API_KEY_PORTAINER"] = this.portainerCredentials.apiKey
|
|
411
411
|
if (this.portainerCredentials.password) {
|
|
412
|
-
updates["
|
|
412
|
+
updates["PASSWORD_PORTAINER"] = this.portainerCredentials.password
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
415
|
|
|
@@ -438,8 +438,8 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
438
438
|
|
|
439
439
|
private async initializePortainer(_updates: Record<string, string>) {
|
|
440
440
|
const env = readEnvSync()
|
|
441
|
-
const globalUsername = env["
|
|
442
|
-
const globalPassword = env["
|
|
441
|
+
const globalUsername = env["USERNAME_GLOBAL"] || "admin"
|
|
442
|
+
const globalPassword = env["PASSWORD_GLOBAL"]
|
|
443
443
|
|
|
444
444
|
if (!globalPassword) return
|
|
445
445
|
|
|
@@ -479,7 +479,7 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
479
479
|
}
|
|
480
480
|
} else {
|
|
481
481
|
// Already initialized, try login with saved password if available
|
|
482
|
-
const portainerPassword = env["
|
|
482
|
+
const portainerPassword = env["PASSWORD_PORTAINER"] || globalPassword
|
|
483
483
|
await client.login(globalUsername, portainerPassword)
|
|
484
484
|
const apiKey = await client.generateApiKey(portainerPassword, "easiarr-api-key")
|
|
485
485
|
this.portainerCredentials = { apiKey }
|
|
@@ -83,9 +83,9 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
83
83
|
|
|
84
84
|
private loadSavedCredentials() {
|
|
85
85
|
const env = readEnvSync()
|
|
86
|
-
if (env.
|
|
87
|
-
if (env.
|
|
88
|
-
if (env.
|
|
86
|
+
if (env.USERNAME_GLOBAL) this.globalUsername = env.USERNAME_GLOBAL
|
|
87
|
+
if (env.PASSWORD_GLOBAL) this.globalPassword = env.PASSWORD_GLOBAL
|
|
88
|
+
if (env.PASSWORD_QBITTORRENT) this.qbPass = env.PASSWORD_QBITTORRENT
|
|
89
89
|
if (env.SABNZBD_API_KEY) this.sabApiKey = env.SABNZBD_API_KEY
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -204,8 +204,8 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
204
204
|
private async saveGlobalCredentialsToEnv() {
|
|
205
205
|
try {
|
|
206
206
|
const updates: Record<string, string> = {}
|
|
207
|
-
if (this.globalUsername) updates.
|
|
208
|
-
if (this.globalPassword) updates.
|
|
207
|
+
if (this.globalUsername) updates.USERNAME_GLOBAL = this.globalUsername
|
|
208
|
+
if (this.globalPassword) updates.PASSWORD_GLOBAL = this.globalPassword
|
|
209
209
|
await updateEnv(updates)
|
|
210
210
|
} catch {
|
|
211
211
|
// Ignore errors - not critical
|
|
@@ -610,7 +610,7 @@ export class AppConfigurator extends BoxRenderable {
|
|
|
610
610
|
try {
|
|
611
611
|
const updates: Record<string, string> = {}
|
|
612
612
|
if (type === "qbittorrent" && this.qbPass) {
|
|
613
|
-
updates.
|
|
613
|
+
updates.PASSWORD_QBITTORRENT = this.qbPass
|
|
614
614
|
} else if (type === "sabnzbd" && this.sabApiKey) {
|
|
615
615
|
updates.SABNZBD_API_KEY = this.sabApiKey
|
|
616
616
|
}
|
|
@@ -63,8 +63,8 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
63
63
|
this.pageContainer = pageContainer
|
|
64
64
|
|
|
65
65
|
this.env = readEnvSync()
|
|
66
|
-
this.globalUsername = this.env["
|
|
67
|
-
this.globalPassword = this.env["
|
|
66
|
+
this.globalUsername = this.env["USERNAME_GLOBAL"] || "admin"
|
|
67
|
+
this.globalPassword = this.env["PASSWORD_GLOBAL"] || ""
|
|
68
68
|
|
|
69
69
|
this.initKeyHandler()
|
|
70
70
|
this.initSteps()
|
|
@@ -176,7 +176,7 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
176
176
|
this.refreshContent()
|
|
177
177
|
|
|
178
178
|
if (!this.globalPassword) {
|
|
179
|
-
this.updateStep("Authentication", "skipped", "No
|
|
179
|
+
this.updateStep("Authentication", "skipped", "No PASSWORD_GLOBAL set")
|
|
180
180
|
this.refreshContent()
|
|
181
181
|
return
|
|
182
182
|
}
|
|
@@ -303,11 +303,11 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
303
303
|
try {
|
|
304
304
|
const host = "localhost"
|
|
305
305
|
const port = qbConfig.port || 8080
|
|
306
|
-
const user = this.env["
|
|
307
|
-
const pass = this.env["
|
|
306
|
+
const user = this.env["USERNAME_QBITTORRENT"] || "admin"
|
|
307
|
+
const pass = this.env["PASSWORD_QBITTORRENT"] || this.env["QBITTORRENT_PASS"] || ""
|
|
308
308
|
|
|
309
309
|
if (!pass) {
|
|
310
|
-
this.updateStep("qBittorrent", "skipped", "No
|
|
310
|
+
this.updateStep("qBittorrent", "skipped", "No PASSWORD_QBITTORRENT in .env")
|
|
311
311
|
this.refreshContent()
|
|
312
312
|
return
|
|
313
313
|
}
|
|
@@ -347,7 +347,7 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
if (!this.globalPassword) {
|
|
350
|
-
this.updateStep("Portainer", "skipped", "No
|
|
350
|
+
this.updateStep("Portainer", "skipped", "No PASSWORD_GLOBAL set")
|
|
351
351
|
this.refreshContent()
|
|
352
352
|
return
|
|
353
353
|
}
|
|
@@ -377,7 +377,7 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
377
377
|
|
|
378
378
|
// Save password if it was padded (different from global)
|
|
379
379
|
if (result.passwordWasPadded) {
|
|
380
|
-
envUpdates.
|
|
380
|
+
envUpdates.PASSWORD_PORTAINER = result.actualPassword
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
await updateEnv(envUpdates)
|
|
@@ -387,7 +387,7 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
387
387
|
if (!this.env["API_KEY_PORTAINER"]) {
|
|
388
388
|
try {
|
|
389
389
|
// Use saved Portainer password if available (may have been padded)
|
|
390
|
-
const portainerPassword = this.env["
|
|
390
|
+
const portainerPassword = this.env["PASSWORD_PORTAINER"] || this.globalPassword
|
|
391
391
|
await client.login(this.globalUsername, portainerPassword)
|
|
392
392
|
const apiKey = await client.generateApiKey(portainerPassword, "easiarr-api-key")
|
|
393
393
|
await updateEnv({ API_KEY_PORTAINER: apiKey })
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: Rename env variables to new format
|
|
3
|
+
*
|
|
4
|
+
* OLD: GLOBAL_PASSWORD, QBITTORRENT_USER, etc.
|
|
5
|
+
* NEW: PASSWORD_GLOBAL, USERNAME_QBITTORRENT, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
import { homedir } from "node:os"
|
|
11
|
+
import { parseEnvFile, serializeEnv } from "../env"
|
|
12
|
+
import { debugLog } from "../debug"
|
|
13
|
+
|
|
14
|
+
const ENV_FILE = join(homedir(), ".easiarr", ".env")
|
|
15
|
+
|
|
16
|
+
export const name = "rename-env-variables"
|
|
17
|
+
|
|
18
|
+
const renames: [string, string][] = [
|
|
19
|
+
// Global credentials
|
|
20
|
+
["GLOBAL_PASSWORD", "PASSWORD_GLOBAL"],
|
|
21
|
+
["GLOBAL_USERNAME", "USERNAME_GLOBAL"],
|
|
22
|
+
// qBittorrent
|
|
23
|
+
["QBITTORRENT_PASSWORD", "PASSWORD_QBITTORRENT"],
|
|
24
|
+
["QBITTORRENT_USER", "USERNAME_QBITTORRENT"],
|
|
25
|
+
["QBITTORRENT_PASS", "PASSWORD_QBITTORRENT"], // Legacy alias
|
|
26
|
+
// Portainer
|
|
27
|
+
["PORTAINER_PASSWORD", "PASSWORD_PORTAINER"],
|
|
28
|
+
// PostgreSQL
|
|
29
|
+
["POSTGRESQL_USERNAME", "USERNAME_POSTGRESQL"],
|
|
30
|
+
["POSTGRESQL_PASSWORD", "PASSWORD_POSTGRESQL"],
|
|
31
|
+
// VPN
|
|
32
|
+
["VPN_USERNAME", "USERNAME_VPN"],
|
|
33
|
+
["VPN_PASSWORD", "PASSWORD_VPN"],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export function up(): boolean {
|
|
37
|
+
if (!existsSync(ENV_FILE)) {
|
|
38
|
+
debugLog("Migrations", "No .env file found, skipping migration")
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const content = readFileSync(ENV_FILE, "utf-8")
|
|
44
|
+
const env = parseEnvFile(content)
|
|
45
|
+
let changed = false
|
|
46
|
+
|
|
47
|
+
for (const [oldKey, newKey] of renames) {
|
|
48
|
+
if (env[oldKey] !== undefined && env[newKey] === undefined) {
|
|
49
|
+
env[newKey] = env[oldKey]
|
|
50
|
+
delete env[oldKey]
|
|
51
|
+
changed = true
|
|
52
|
+
debugLog("Migrations", `Renamed ${oldKey} → ${newKey}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (changed) {
|
|
57
|
+
writeFileSync(ENV_FILE, serializeEnv(env), "utf-8")
|
|
58
|
+
debugLog("Migrations", "Migration completed: env variables renamed")
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
debugLog("Migrations", "No changes needed")
|
|
63
|
+
return false
|
|
64
|
+
} catch (e) {
|
|
65
|
+
debugLog("Migrations", `Migration error: ${e}`)
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function down(): boolean {
|
|
71
|
+
if (!existsSync(ENV_FILE)) {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const content = readFileSync(ENV_FILE, "utf-8")
|
|
77
|
+
const env = parseEnvFile(content)
|
|
78
|
+
let changed = false
|
|
79
|
+
|
|
80
|
+
for (const [oldKey, newKey] of renames) {
|
|
81
|
+
// Reverse operation: restore oldKey from newKey
|
|
82
|
+
if (env[newKey] !== undefined && env[oldKey] === undefined) {
|
|
83
|
+
env[oldKey] = env[newKey]
|
|
84
|
+
delete env[newKey]
|
|
85
|
+
changed = true
|
|
86
|
+
debugLog("Migrations", `Rolled back ${newKey} → ${oldKey}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (changed) {
|
|
91
|
+
writeFileSync(ENV_FILE, serializeEnv(env), "utf-8")
|
|
92
|
+
debugLog("Migrations", "Rollback completed: env variables restored")
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return false
|
|
97
|
+
} catch (e) {
|
|
98
|
+
debugLog("Migrations", `Rollback error: ${e}`)
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration Runner
|
|
3
|
+
* Sequelize-style migrations with Unix timestamp naming
|
|
4
|
+
*
|
|
5
|
+
* Migration files: src/utils/migrations/{timestamp}_{name}.ts
|
|
6
|
+
* Each migration exports: name, up()
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
10
|
+
import { join } from "node:path"
|
|
11
|
+
import { homedir } from "node:os"
|
|
12
|
+
import { debugLog } from "./debug"
|
|
13
|
+
|
|
14
|
+
const EASIARR_DIR = join(homedir(), ".easiarr")
|
|
15
|
+
const MIGRATIONS_FILE = join(EASIARR_DIR, ".migrations.json")
|
|
16
|
+
|
|
17
|
+
interface MigrationState {
|
|
18
|
+
applied: string[] // List of applied migration timestamps
|
|
19
|
+
lastRun: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Migration {
|
|
23
|
+
timestamp: string
|
|
24
|
+
name: string
|
|
25
|
+
up: () => boolean
|
|
26
|
+
down: () => boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the current migration state
|
|
31
|
+
*/
|
|
32
|
+
function getMigrationState(): MigrationState {
|
|
33
|
+
if (!existsSync(MIGRATIONS_FILE)) {
|
|
34
|
+
return { applied: [], lastRun: "" }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const content = readFileSync(MIGRATIONS_FILE, "utf-8")
|
|
39
|
+
return JSON.parse(content) as MigrationState
|
|
40
|
+
} catch {
|
|
41
|
+
return { applied: [], lastRun: "" }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Save the migration state
|
|
47
|
+
*/
|
|
48
|
+
function saveMigrationState(state: MigrationState): void {
|
|
49
|
+
state.lastRun = new Date().toISOString()
|
|
50
|
+
writeFileSync(MIGRATIONS_FILE, JSON.stringify(state, null, 2), "utf-8")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load all migration modules
|
|
55
|
+
*/
|
|
56
|
+
async function loadMigrations(): Promise<Migration[]> {
|
|
57
|
+
const migrations: Migration[] = []
|
|
58
|
+
|
|
59
|
+
// Import migrations directly - bundled at build time
|
|
60
|
+
try {
|
|
61
|
+
const m1 = await import("./migrations/1765626338_rename_env_variables")
|
|
62
|
+
migrations.push({
|
|
63
|
+
timestamp: "1765626338",
|
|
64
|
+
name: m1.name,
|
|
65
|
+
up: m1.up,
|
|
66
|
+
down: m1.down,
|
|
67
|
+
})
|
|
68
|
+
} catch (e) {
|
|
69
|
+
debugLog("Migrations", `Failed to load migration: ${e}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Add future migrations here:
|
|
73
|
+
// const m2 = await import("./migrations/1734xxxxxx_xxx")
|
|
74
|
+
// migrations.push({ timestamp: "...", name: m2.name, up: m2.up })
|
|
75
|
+
|
|
76
|
+
return migrations.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run all pending migrations
|
|
81
|
+
*/
|
|
82
|
+
export async function runMigrations(): Promise<void> {
|
|
83
|
+
const state = getMigrationState()
|
|
84
|
+
const migrations = await loadMigrations()
|
|
85
|
+
|
|
86
|
+
let hasChanges = false
|
|
87
|
+
|
|
88
|
+
for (const migration of migrations) {
|
|
89
|
+
if (state.applied.includes(migration.timestamp)) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
debugLog("Migrations", `Running migration ${migration.timestamp}_${migration.name}`)
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
migration.up()
|
|
97
|
+
state.applied.push(migration.timestamp)
|
|
98
|
+
hasChanges = true
|
|
99
|
+
debugLog("Migrations", `Migration ${migration.timestamp} completed`)
|
|
100
|
+
} catch (e) {
|
|
101
|
+
debugLog("Migrations", `Migration ${migration.timestamp} failed: ${e}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (hasChanges) {
|
|
106
|
+
saveMigrationState(state)
|
|
107
|
+
} else {
|
|
108
|
+
debugLog("Migrations", "No pending migrations")
|
|
109
|
+
}
|
|
110
|
+
}
|