@muhammedaksam/easiarr 0.5.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.5.1",
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": {
@@ -254,13 +254,13 @@ export const APPS: Record<AppId, AppDefinition> = {
254
254
  environment: { WEBUI_PORT: "8080" },
255
255
  secrets: [
256
256
  {
257
- name: "QBITTORRENT_USER",
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: "QBITTORRENT_PASSWORD",
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: "${POSTGRESQL_USERNAME}",
520
- POSTGRESQL_PASSWORD: "${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: "POSTGRESQL_USERNAME",
525
+ name: "USERNAME_POSTGRESQL",
526
526
  description: "PostgreSQL Username",
527
527
  required: true,
528
528
  default: "postgres",
529
529
  },
530
530
  {
531
- name: "POSTGRESQL_PASSWORD",
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: "${VPN_USERNAME}",
580
- OPENVPN_PASSWORD: "${VPN_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: "VPN_USERNAME",
593
+ name: "USERNAME_VPN",
594
594
  description: "OpenVPN Username",
595
595
  required: false,
596
596
  },
597
597
  {
598
- name: "VPN_PASSWORD",
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: "${POSTGRESQL_USERNAME}",
781
- AUTHENTIK_POSTGRESQL__PASSWORD: "${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: "POSTGRESQL_USERNAME",
794
+ name: "USERNAME_POSTGRESQL",
795
795
  description: "Postgres Username",
796
796
  required: true,
797
797
  default: "postgres",
798
798
  },
799
799
  {
800
- name: "POSTGRESQL_PASSWORD",
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: "${POSTGRESQL_USERNAME}",
828
- AUTHENTIK_POSTGRESQL__PASSWORD: "${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: "${POSTGRESQL_USERNAME}",
846
- POSTGRES_PASSWORD: "${POSTGRESQL_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: "POSTGRESQL_USERNAME",
851
+ name: "USERNAME_POSTGRESQL",
852
852
  description: "PostgreSQL Username",
853
853
  required: true,
854
854
  default: "postgres",
855
855
  },
856
856
  {
857
- name: "POSTGRESQL_PASSWORD",
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["GLOBAL_PASSWORD"]
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 GLOBAL_PASSWORD set in .env",
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["PORTAINER_PASSWORD"] = this.portainerCredentials.password
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["GLOBAL_USERNAME"] || "admin"
442
- const globalPassword = env["GLOBAL_PASSWORD"]
441
+ const globalUsername = env["USERNAME_GLOBAL"] || "admin"
442
+ const globalPassword = env["PASSWORD_GLOBAL"]
443
443
 
444
444
  if (!globalPassword) return
445
445
 
@@ -478,9 +478,10 @@ export class ApiKeyViewer extends BoxRenderable {
478
478
  portainerEntry.status = "generated"
479
479
  }
480
480
  } else {
481
- // Already initialized, try login
482
- await client.login(globalUsername, globalPassword)
483
- const apiKey = await client.generateApiKey(globalPassword, "easiarr-api-key")
481
+ // Already initialized, try login with saved password if available
482
+ const portainerPassword = env["PASSWORD_PORTAINER"] || globalPassword
483
+ await client.login(globalUsername, portainerPassword)
484
+ const apiKey = await client.generateApiKey(portainerPassword, "easiarr-api-key")
484
485
  this.portainerCredentials = { apiKey }
485
486
 
486
487
  const portainerEntry = this.keys.find((k) => k.appId === "portainer")
@@ -83,9 +83,9 @@ export class AppConfigurator extends BoxRenderable {
83
83
 
84
84
  private loadSavedCredentials() {
85
85
  const env = readEnvSync()
86
- if (env.GLOBAL_USERNAME) this.globalUsername = env.GLOBAL_USERNAME
87
- if (env.GLOBAL_PASSWORD) this.globalPassword = env.GLOBAL_PASSWORD
88
- if (env.QBITTORRENT_PASSWORD) this.qbPass = env.QBITTORRENT_PASSWORD
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.GLOBAL_USERNAME = this.globalUsername
208
- if (this.globalPassword) updates.GLOBAL_PASSWORD = this.globalPassword
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.QBITTORRENT_PASSWORD = this.qbPass
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["GLOBAL_USERNAME"] || "admin"
67
- this.globalPassword = this.env["GLOBAL_PASSWORD"] || ""
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 GLOBAL_PASSWORD set")
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["QBITTORRENT_USER"] || "admin"
307
- const pass = this.env["QBITTORRENT_PASSWORD"] || this.env["QBITTORRENT_PASS"] || ""
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 QBITTORRENT_PASSWORD in .env")
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 GLOBAL_PASSWORD set")
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.PORTAINER_PASSWORD = result.actualPassword
380
+ envUpdates.PASSWORD_PORTAINER = result.actualPassword
381
381
  }
382
382
 
383
383
  await updateEnv(envUpdates)
@@ -386,8 +386,10 @@ export class FullAutoSetup extends BoxRenderable {
386
386
  // Already initialized, try to login and get API key if we don't have one
387
387
  if (!this.env["API_KEY_PORTAINER"]) {
388
388
  try {
389
- await client.login(this.globalUsername, this.globalPassword)
390
- const apiKey = await client.generateApiKey(this.globalPassword, "easiarr-api-key")
389
+ // Use saved Portainer password if available (may have been padded)
390
+ const portainerPassword = this.env["PASSWORD_PORTAINER"] || this.globalPassword
391
+ await client.login(this.globalUsername, portainerPassword)
392
+ const apiKey = await client.generateApiKey(portainerPassword, "easiarr-api-key")
391
393
  await updateEnv({ API_KEY_PORTAINER: apiKey })
392
394
  this.updateStep("Portainer", "success", "API key generated")
393
395
  } catch {
@@ -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
+ }