@muhammedaksam/easiarr 0.8.5 → 0.9.1

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.
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Settings Screen
3
+ * Edit Traefik, VPN, and system configuration
4
+ */
5
+
6
+ import type { CliRenderer, KeyEvent } from "@opentui/core"
7
+ import {
8
+ BoxRenderable,
9
+ TextRenderable,
10
+ SelectRenderable,
11
+ SelectRenderableEvents,
12
+ InputRenderable,
13
+ InputRenderableEvents,
14
+ } from "@opentui/core"
15
+ import type { EasiarrConfig, VpnMode } from "../../config/schema"
16
+ import { saveConfig } from "../../config"
17
+ import { saveCompose } from "../../compose"
18
+ import { createPageLayout } from "../components/PageLayout"
19
+
20
+ type SettingsSection = "traefik" | "vpn" | "system"
21
+
22
+ export class SettingsScreen extends BoxRenderable {
23
+ private config: EasiarrConfig
24
+ private onBack: () => void
25
+ private keyHandler: ((key: KeyEvent) => void) | null = null
26
+ private activeSection: SettingsSection = "traefik"
27
+ private page: BoxRenderable | null = null
28
+ private cliRenderer: CliRenderer
29
+
30
+ // Traefik settings (local copies to edit)
31
+ private traefikDomain: string
32
+ private traefikEntrypoint: string
33
+ private traefikMiddlewares: string
34
+
35
+ // VPN settings
36
+ private vpnMode: VpnMode
37
+
38
+ // System settings
39
+ private rootDir: string
40
+ private puid: string
41
+ private pgid: string
42
+ private timezone: string
43
+ private umask: string
44
+
45
+ constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
46
+ super(renderer, {
47
+ id: "settings-screen",
48
+ width: "100%",
49
+ height: "100%",
50
+ flexDirection: "column",
51
+ })
52
+
53
+ this.cliRenderer = renderer
54
+ this.config = config
55
+ this.onBack = onBack
56
+
57
+ // Initialize local copies from config
58
+ this.traefikDomain = config.traefik?.domain || "${CLOUDFLARE_DNS_ZONE}"
59
+ this.traefikEntrypoint = config.traefik?.entrypoint || "web"
60
+ this.traefikMiddlewares = config.traefik?.middlewares?.join(",") || ""
61
+
62
+ this.vpnMode = config.vpn?.mode || "none"
63
+
64
+ this.rootDir = config.rootDir
65
+ this.puid = config.uid.toString()
66
+ this.pgid = config.gid.toString()
67
+ this.timezone = config.timezone
68
+ this.umask = config.umask
69
+
70
+ this.renderContent()
71
+ }
72
+
73
+ private renderContent(): void {
74
+ // Clear previous
75
+ if (this.keyHandler) {
76
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
77
+ this.keyHandler = null
78
+ }
79
+
80
+ const children = this.getChildren()
81
+ for (const child of children) {
82
+ this.remove(child.id)
83
+ }
84
+
85
+ const { container: page, content } = createPageLayout(this.cliRenderer as CliRenderer, {
86
+ title: "Settings",
87
+ stepInfo: "Edit Traefik, VPN, and System configuration",
88
+ footerHint: [
89
+ { type: "key", key: "Tab", value: "Next" },
90
+ { type: "key", key: "Enter", value: "Select/Continue" },
91
+ { type: "key", key: "s", value: "Save" },
92
+ { type: "key", key: "Esc", value: "Back" },
93
+ ],
94
+ })
95
+ this.page = page
96
+
97
+ // Section tabs
98
+ const tabsBox = new BoxRenderable(this.cliRenderer, {
99
+ width: "100%",
100
+ height: 1,
101
+ flexDirection: "row",
102
+ marginBottom: 1,
103
+ })
104
+
105
+ const sections: { id: SettingsSection; label: string }[] = [
106
+ { id: "traefik", label: "Traefik" },
107
+ { id: "vpn", label: "VPN" },
108
+ { id: "system", label: "System" },
109
+ ]
110
+
111
+ for (const section of sections) {
112
+ const isActive = this.activeSection === section.id
113
+ tabsBox.add(
114
+ new TextRenderable(this.cliRenderer, {
115
+ content: ` ${section.label} `,
116
+ fg: isActive ? "#4a9eff" : "#666666",
117
+ marginRight: 2,
118
+ })
119
+ )
120
+ }
121
+
122
+ content.add(tabsBox)
123
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
124
+
125
+ // Render active section
126
+ switch (this.activeSection) {
127
+ case "traefik":
128
+ this.renderTraefikSection(content)
129
+ break
130
+ case "vpn":
131
+ this.renderVpnSection(content)
132
+ break
133
+ case "system":
134
+ this.renderSystemSection(content)
135
+ break
136
+ }
137
+
138
+ this.add(page)
139
+ }
140
+
141
+ private renderTraefikSection(content: BoxRenderable): void {
142
+ if (!this.config.traefik?.enabled) {
143
+ content.add(
144
+ new TextRenderable(this.cliRenderer, {
145
+ content: "Traefik is not enabled. Enable it in App Manager first.",
146
+ fg: "#ff6666",
147
+ })
148
+ )
149
+ this.setupBackHandler()
150
+ return
151
+ }
152
+
153
+ content.add(
154
+ new TextRenderable(this.cliRenderer, {
155
+ content: "Configure Traefik reverse proxy settings:",
156
+ fg: "#888888",
157
+ })
158
+ )
159
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
160
+
161
+ // Domain
162
+ const domainRow = new BoxRenderable(this.cliRenderer, {
163
+ width: "100%",
164
+ height: 1,
165
+ flexDirection: "row",
166
+ marginBottom: 1,
167
+ })
168
+ domainRow.add(
169
+ new TextRenderable(this.cliRenderer, {
170
+ content: "Domain:".padEnd(16),
171
+ fg: "#aaaaaa",
172
+ })
173
+ )
174
+ const domainInput = new InputRenderable(this.cliRenderer, {
175
+ id: "settings-traefik-domain",
176
+ width: 40,
177
+ placeholder: "${CLOUDFLARE_DNS_ZONE}",
178
+ backgroundColor: "#2a2a3e",
179
+ textColor: "#ffffff",
180
+ focusedBackgroundColor: "#3a3a4e",
181
+ })
182
+ domainInput.value = this.traefikDomain
183
+ domainInput.on(InputRenderableEvents.CHANGE, (v) => (this.traefikDomain = v))
184
+ domainRow.add(domainInput)
185
+ content.add(domainRow)
186
+
187
+ // Entrypoint
188
+ const epRow = new BoxRenderable(this.cliRenderer, {
189
+ width: "100%",
190
+ height: 1,
191
+ flexDirection: "row",
192
+ marginBottom: 1,
193
+ })
194
+ epRow.add(
195
+ new TextRenderable(this.cliRenderer, {
196
+ content: "Entrypoint:".padEnd(16),
197
+ fg: "#aaaaaa",
198
+ })
199
+ )
200
+ const epInput = new InputRenderable(this.cliRenderer, {
201
+ id: "settings-traefik-entrypoint",
202
+ width: 20,
203
+ placeholder: "web",
204
+ backgroundColor: "#2a2a3e",
205
+ textColor: "#ffffff",
206
+ focusedBackgroundColor: "#3a3a4e",
207
+ })
208
+ epInput.value = this.traefikEntrypoint
209
+ epInput.on(InputRenderableEvents.CHANGE, (v) => (this.traefikEntrypoint = v))
210
+ epRow.add(epInput)
211
+
212
+ // Hint for cloudflared
213
+ const cloudflaredEnabled = this.config.apps.some((a) => a.id === "cloudflared" && a.enabled)
214
+ if (cloudflaredEnabled && this.traefikEntrypoint === "websecure") {
215
+ epRow.add(
216
+ new TextRenderable(this.cliRenderer, {
217
+ content: " ⚠️ Use 'web' for Cloudflare Tunnel",
218
+ fg: "#ffcc00",
219
+ })
220
+ )
221
+ }
222
+ content.add(epRow)
223
+
224
+ // Middlewares
225
+ const mwRow = new BoxRenderable(this.cliRenderer, {
226
+ width: "100%",
227
+ height: 1,
228
+ flexDirection: "row",
229
+ marginBottom: 1,
230
+ })
231
+ mwRow.add(
232
+ new TextRenderable(this.cliRenderer, {
233
+ content: "Middlewares:".padEnd(16),
234
+ fg: "#aaaaaa",
235
+ })
236
+ )
237
+ const mwInput = new InputRenderable(this.cliRenderer, {
238
+ id: "settings-traefik-middlewares",
239
+ width: 50,
240
+ placeholder: "security-headers@file",
241
+ backgroundColor: "#2a2a3e",
242
+ textColor: "#ffffff",
243
+ focusedBackgroundColor: "#3a3a4e",
244
+ })
245
+ mwInput.value = this.traefikMiddlewares
246
+ mwInput.on(InputRenderableEvents.CHANGE, (v) => (this.traefikMiddlewares = v))
247
+ mwRow.add(mwInput)
248
+ content.add(mwRow)
249
+
250
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
251
+
252
+ // Navigation
253
+ const navMenu = new SelectRenderable(this.cliRenderer, {
254
+ id: "settings-traefik-nav",
255
+ width: "100%",
256
+ height: 4,
257
+ options: [
258
+ { name: "💾 Save Changes", description: "Save and regenerate docker-compose.yml" },
259
+ { name: "➡️ Next: VPN", description: "Go to VPN settings" },
260
+ { name: "◀ Back", description: "Return to main menu" },
261
+ ],
262
+ })
263
+
264
+ navMenu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
265
+ if (index === 0) {
266
+ await this.save()
267
+ } else if (index === 1) {
268
+ this.activeSection = "vpn"
269
+ this.renderContent()
270
+ } else {
271
+ this.cleanup()
272
+ this.onBack()
273
+ }
274
+ })
275
+
276
+ content.add(navMenu)
277
+
278
+ // Focus management
279
+ const inputs = [domainInput, epInput, mwInput, navMenu]
280
+ let focusIndex = 0
281
+ inputs[0].focus()
282
+
283
+ this.keyHandler = (key: KeyEvent) => {
284
+ if (key.name === "escape") {
285
+ this.cleanup()
286
+ this.onBack()
287
+ } else if (key.name === "s" && !key.ctrl) {
288
+ this.save()
289
+ } else if (key.name === "tab" || key.name === "enter") {
290
+ if (key.name === "enter" && focusIndex === inputs.length - 1) return
291
+ inputs[focusIndex].blur()
292
+ focusIndex = key.shift ? (focusIndex - 1 + inputs.length) % inputs.length : (focusIndex + 1) % inputs.length
293
+ inputs[focusIndex].focus()
294
+ }
295
+ }
296
+ ;(this.cliRenderer as CliRenderer).keyInput.on("keypress", this.keyHandler)
297
+ }
298
+
299
+ private renderVpnSection(content: BoxRenderable): void {
300
+ content.add(
301
+ new TextRenderable(this.cliRenderer, {
302
+ content: "Configure VPN routing through Gluetun:",
303
+ fg: "#888888",
304
+ })
305
+ )
306
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
307
+
308
+ const gluetunEnabled = this.config.apps.some((a) => a.id === "gluetun" && a.enabled)
309
+ if (!gluetunEnabled) {
310
+ content.add(
311
+ new TextRenderable(this.cliRenderer, {
312
+ content: "Gluetun VPN is not enabled. Enable it in App Manager first.",
313
+ fg: "#ff6666",
314
+ })
315
+ )
316
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
317
+ }
318
+
319
+ // Current mode display
320
+ const modeLabels: Record<VpnMode, string> = {
321
+ full: "🛡️ Full VPN",
322
+ mini: "⚡ Mini VPN",
323
+ none: "❌ No VPN Routing",
324
+ }
325
+ content.add(
326
+ new TextRenderable(this.cliRenderer, {
327
+ content: `Current mode: ${modeLabels[this.vpnMode]}`,
328
+ fg: "#50fa7b",
329
+ })
330
+ )
331
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
332
+
333
+ // Combined menu with VPN options + navigation
334
+ const menu = new SelectRenderable(this.cliRenderer, {
335
+ id: "settings-vpn-menu",
336
+ width: "100%",
337
+ height: 8,
338
+ options: [
339
+ { name: "🛡️ Full VPN", description: "Route Downloaders, Indexers, and Media Servers through VPN" },
340
+ { name: "⚡ Mini VPN", description: "Route ONLY Downloaders through VPN (Recommended)" },
341
+ { name: "❌ No VPN Routing", description: "Run container but don't route traffic" },
342
+ { name: "◀ Previous: Traefik", description: "Go to Traefik settings" },
343
+ { name: "➡️ Next: System", description: "Go to System settings" },
344
+ { name: "💾 Save Changes", description: "Save and regenerate docker-compose.yml" },
345
+ { name: "✕ Back", description: "Return to main menu" },
346
+ ],
347
+ })
348
+
349
+ const modes: VpnMode[] = ["full", "mini", "none"]
350
+
351
+ menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
352
+ if (index < 3) {
353
+ // VPN mode selection
354
+ this.vpnMode = modes[index]
355
+ this.renderContent() // Re-render to show selection
356
+ } else if (index === 3) {
357
+ this.activeSection = "traefik"
358
+ this.renderContent()
359
+ } else if (index === 4) {
360
+ this.activeSection = "system"
361
+ this.renderContent()
362
+ } else if (index === 5) {
363
+ await this.save()
364
+ } else {
365
+ this.cleanup()
366
+ this.onBack()
367
+ }
368
+ })
369
+
370
+ content.add(menu)
371
+ menu.focus()
372
+
373
+ this.keyHandler = (key: KeyEvent) => {
374
+ if (key.name === "escape") {
375
+ this.cleanup()
376
+ this.onBack()
377
+ }
378
+ }
379
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
380
+ }
381
+
382
+ private renderSystemSection(content: BoxRenderable): void {
383
+ content.add(
384
+ new TextRenderable(this.cliRenderer, {
385
+ content: "Configure system-wide settings:",
386
+ fg: "#888888",
387
+ })
388
+ )
389
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
390
+
391
+ const createField = (id: string, label: string, value: string, placeholder: string, width: number = 40) => {
392
+ const row = new BoxRenderable(this.cliRenderer, {
393
+ width: "100%",
394
+ height: 1,
395
+ flexDirection: "row",
396
+ marginBottom: 1,
397
+ })
398
+ row.add(
399
+ new TextRenderable(this.cliRenderer, {
400
+ content: label.padEnd(16),
401
+ fg: "#aaaaaa",
402
+ })
403
+ )
404
+ const input = new InputRenderable(this.cliRenderer, {
405
+ id,
406
+ width,
407
+ placeholder,
408
+ backgroundColor: "#2a2a3e",
409
+ textColor: "#ffffff",
410
+ focusedBackgroundColor: "#3a3a4e",
411
+ })
412
+ input.value = value
413
+ row.add(input)
414
+ content.add(row)
415
+ return input
416
+ }
417
+
418
+ const rootInput = createField("settings-sys-root", "Root Path:", this.rootDir, "/home/user/media", 50)
419
+ const puidInput = createField("settings-sys-puid", "PUID:", this.puid, "1000", 10)
420
+ const pgidInput = createField("settings-sys-pgid", "PGID:", this.pgid, "1000", 10)
421
+ const tzInput = createField("settings-sys-tz", "Timezone:", this.timezone, "Europe/London", 30)
422
+ const umaskInput = createField("settings-sys-umask", "Umask:", this.umask, "002", 10)
423
+
424
+ rootInput.on(InputRenderableEvents.CHANGE, (v) => (this.rootDir = v))
425
+ puidInput.on(InputRenderableEvents.CHANGE, (v) => (this.puid = v))
426
+ pgidInput.on(InputRenderableEvents.CHANGE, (v) => (this.pgid = v))
427
+ tzInput.on(InputRenderableEvents.CHANGE, (v) => (this.timezone = v))
428
+ umaskInput.on(InputRenderableEvents.CHANGE, (v) => (this.umask = v))
429
+
430
+ content.add(new TextRenderable(this.cliRenderer, { content: " " }))
431
+
432
+ const navMenu = new SelectRenderable(this.cliRenderer, {
433
+ id: "settings-sys-nav",
434
+ width: "100%",
435
+ height: 4,
436
+ options: [
437
+ { name: "💾 Save All Changes", description: "Save config and regenerate docker-compose.yml" },
438
+ { name: "◀ Back", description: "Return to main menu" },
439
+ ],
440
+ })
441
+
442
+ navMenu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
443
+ if (index === 0) {
444
+ await this.save()
445
+ } else {
446
+ this.cleanup()
447
+ this.onBack()
448
+ }
449
+ })
450
+
451
+ content.add(navMenu)
452
+
453
+ const inputs = [rootInput, puidInput, pgidInput, tzInput, umaskInput, navMenu]
454
+ let focusIndex = 0
455
+ inputs[0].focus()
456
+
457
+ this.keyHandler = (key: KeyEvent) => {
458
+ if (key.name === "escape") {
459
+ this.cleanup()
460
+ this.onBack()
461
+ } else if (key.name === "s" && !key.ctrl) {
462
+ this.save()
463
+ } else if (key.name === "tab" || key.name === "enter") {
464
+ if (key.name === "enter" && focusIndex === inputs.length - 1) return
465
+ inputs[focusIndex].blur()
466
+ focusIndex = key.shift ? (focusIndex - 1 + inputs.length) % inputs.length : (focusIndex + 1) % inputs.length
467
+ inputs[focusIndex].focus()
468
+ }
469
+ }
470
+ ;(this.cliRenderer as CliRenderer).keyInput.on("keypress", this.keyHandler)
471
+ }
472
+
473
+ private setupSectionNav(content: BoxRenderable, prev: SettingsSection, next: SettingsSection): void {
474
+ const navMenu = new SelectRenderable(this.cliRenderer, {
475
+ id: "settings-section-nav",
476
+ width: "100%",
477
+ height: 4,
478
+ options: [
479
+ { name: "◀ Previous", description: `Go to ${prev} settings` },
480
+ { name: "➡️ Next", description: `Go to ${next} settings` },
481
+ { name: "💾 Save", description: "Save all changes" },
482
+ { name: "✕ Back", description: "Return to main menu" },
483
+ ],
484
+ })
485
+
486
+ navMenu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
487
+ if (index === 0) {
488
+ this.activeSection = prev
489
+ this.renderContent()
490
+ } else if (index === 1) {
491
+ this.activeSection = next
492
+ this.renderContent()
493
+ } else if (index === 2) {
494
+ await this.save()
495
+ } else {
496
+ this.cleanup()
497
+ this.onBack()
498
+ }
499
+ })
500
+
501
+ content.add(navMenu)
502
+ }
503
+
504
+ private setupBackHandler(): void {
505
+ const navMenu = new SelectRenderable(this.cliRenderer, {
506
+ id: "settings-back-nav",
507
+ width: "100%",
508
+ height: 2,
509
+ options: [{ name: "◀ Back", description: "Return to main menu" }],
510
+ })
511
+
512
+ navMenu.on(SelectRenderableEvents.ITEM_SELECTED, () => {
513
+ this.cleanup()
514
+ this.onBack()
515
+ })
516
+
517
+ if (this.page) {
518
+ const content = this.page.getChildren()[0] as BoxRenderable
519
+ content?.add(navMenu)
520
+ }
521
+ navMenu.focus()
522
+
523
+ this.keyHandler = (key: KeyEvent) => {
524
+ if (key.name === "escape") {
525
+ this.cleanup()
526
+ this.onBack()
527
+ }
528
+ }
529
+ ;(this.cliRenderer as CliRenderer).keyInput.on("keypress", this.keyHandler)
530
+ }
531
+
532
+ private async save(): Promise<void> {
533
+ // Update config with local values
534
+ if (this.config.traefik) {
535
+ this.config.traefik.domain = this.traefikDomain
536
+ this.config.traefik.entrypoint = this.traefikEntrypoint
537
+ this.config.traefik.middlewares = this.traefikMiddlewares
538
+ .split(",")
539
+ .map((m) => m.trim())
540
+ .filter((m) => m)
541
+ }
542
+
543
+ if (this.config.vpn) {
544
+ this.config.vpn.mode = this.vpnMode
545
+ }
546
+
547
+ this.config.rootDir = this.rootDir
548
+ this.config.uid = parseInt(this.puid, 10) || 1000
549
+ this.config.gid = parseInt(this.pgid, 10) || 1000
550
+ this.config.timezone = this.timezone
551
+ this.config.umask = this.umask
552
+ this.config.updatedAt = new Date().toISOString()
553
+
554
+ await saveConfig(this.config)
555
+ await saveCompose(this.config)
556
+
557
+ this.cleanup()
558
+ this.onBack()
559
+ }
560
+
561
+ private cleanup(): void {
562
+ if (this.keyHandler) {
563
+ ;(this.cliRenderer as CliRenderer).keyInput.off("keypress", this.keyHandler)
564
+ this.keyHandler = null
565
+ }
566
+ const parent = this.parent
567
+ if (parent) {
568
+ parent.remove(this.id)
569
+ }
570
+ }
571
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Migration: remove cloudflare dns api token
3
+ * Removes the unused CLOUDFLARE_DNS_API_TOKEN from .env
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
7
+ import { getEnvPath } from "../env"
8
+ import { debugLog } from "../debug"
9
+
10
+ export const name = "remove_cloudflare_dns_api_token"
11
+
12
+ export function up(): boolean {
13
+ debugLog("Migrations", "Running migration: remove_cloudflare_dns_api_token")
14
+
15
+ const envPath = getEnvPath()
16
+ if (!existsSync(envPath)) {
17
+ debugLog("Migrations", ".env file not found, skipping")
18
+ return true
19
+ }
20
+
21
+ const content = readFileSync(envPath, "utf-8")
22
+
23
+ // Check if CLOUDFLARE_DNS_API_TOKEN exists
24
+ if (!content.includes("CLOUDFLARE_DNS_API_TOKEN")) {
25
+ debugLog("Migrations", "CLOUDFLARE_DNS_API_TOKEN not found, skipping")
26
+ return true
27
+ }
28
+
29
+ // Remove the line containing CLOUDFLARE_DNS_API_TOKEN
30
+ const lines = content.split("\n")
31
+ const filtered = lines.filter((line) => !line.startsWith("CLOUDFLARE_DNS_API_TOKEN="))
32
+ const newContent = filtered.join("\n")
33
+
34
+ writeFileSync(envPath, newContent, "utf-8")
35
+ debugLog("Migrations", "Removed CLOUDFLARE_DNS_API_TOKEN from .env")
36
+
37
+ return true
38
+ }
39
+
40
+ export function down(): boolean {
41
+ debugLog("Migrations", "Reverting migration: remove_cloudflare_dns_api_token")
42
+ // No revert needed - the token was unused
43
+ return true
44
+ }
@@ -81,6 +81,18 @@ async function loadMigrations(): Promise<Migration[]> {
81
81
  debugLog("Migrations", `Failed to load migration: ${e}`)
82
82
  }
83
83
 
84
+ try {
85
+ const m1765732722 = await import("./migrations/1765732722_remove_cloudflare_dns_api_token")
86
+ migrations.push({
87
+ timestamp: "1765732722",
88
+ name: m1765732722.name,
89
+ up: m1765732722.up,
90
+ down: m1765732722.down,
91
+ })
92
+ } catch (e) {
93
+ debugLog("Migrations", `Failed to load migration: ${e}`)
94
+ }
95
+
84
96
  // Add future migrations here:
85
97
  // const m2 = await import("./migrations/1734xxxxxx_xxx")
86
98
  // migrations.push({ timestamp: "...", name: m2.name, up: m2.up })