diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..a8c805e0 --- /dev/null +++ b/.env.test @@ -0,0 +1 @@ +DATABASE_URL=:memory: diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index a59f558d..98a38724 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -1,7 +1,7 @@ export const OPERATION_TIMEOUT = 5000; export const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes"; export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories"; -export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db"; +export const DATABASE_URL = process.env.DATABASE_URL || "/var/lib/zerobyte/data/ironmount.db"; export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE]; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index cb94d29b..e35ca820 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -21,6 +21,7 @@ export const volumesTable = sqliteTable("volumes_table", { autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true), }); export type Volume = typeof volumesTable.$inferSelect; +export type VolumeInsert = typeof volumesTable.$inferInsert; /** * Users Table @@ -61,6 +62,7 @@ export const repositoriesTable = sqliteTable("repositories_table", { updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); export type Repository = typeof repositoriesTable.$inferSelect; +export type RepositoryInsert = typeof repositoriesTable.$inferInsert; /** * Backup Schedules Table @@ -96,6 +98,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); +export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert; export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({ volume: one(volumesTable, { diff --git a/app/server/jobs/backup-execution.ts b/app/server/jobs/backup-execution.ts index 821333ef..004a99cc 100644 --- a/app/server/jobs/backup-execution.ts +++ b/app/server/jobs/backup-execution.ts @@ -1,6 +1,5 @@ import { Job } from "../core/scheduler"; import { backupsService } from "../modules/backups/backups.service"; -import { toMessage } from "../utils/errors"; import { logger } from "../utils/logger"; export class BackupExecutionJob extends Job { @@ -17,9 +16,7 @@ export class BackupExecutionJob extends Job { logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`); for (const scheduleId of scheduleIds) { - backupsService.executeBackup(scheduleId).catch((error) => { - logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`); - }); + backupsService.executeBackup(scheduleId); } return { done: true, timestamp: new Date(), executed: scheduleIds.length }; diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts new file mode 100644 index 00000000..8fa5ecd7 --- /dev/null +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -0,0 +1,151 @@ +import { test, describe, mock, expect } from "bun:test"; +import { backupsService } from "../backups.service"; +import { createTestVolume } from "~/test/helpers/volume"; +import { createTestBackupSchedule } from "~/test/helpers/backup"; +import { createTestRepository } from "~/test/helpers/repository"; +import { generateBackupOutput } from "~/test/helpers/restic"; +import { beforeEach } from "bun:test"; + +const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0 })); + +mock.module("~/server/utils/spawn", () => ({ + safeSpawn: resticBackupMock, +})); + +beforeEach(() => { + resticBackupMock.mockClear(); +}); + +describe("execute backup", () => { + test("should correctly set next backup time", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + cronExpression: "*/5 * * * *", + }); + expect(schedule.nextBackupAt).toBeNull(); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.nextBackupAt).not.toBeNull(); + + const nextBackupAt = new Date(updatedSchedule.nextBackupAt ?? 0); + const now = new Date(); + + expect(nextBackupAt.getTime()).toBeGreaterThanOrEqual(now.getTime()); + expect(nextBackupAt.getTime() - now.getTime()).toBeLessThanOrEqual(5 * 60 * 1000); + }); + + test("should skip backup if schedule is disabled", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + }); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + expect(resticBackupMock).not.toHaveBeenCalled(); + }); + + test("should execute backup if schedule is disabled but the run is manual", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }), + ); + + // act + await backupsService.executeBackup(schedule.id, true); + + // assert + expect(resticBackupMock).toHaveBeenCalled(); + }); + + test("should skip the backup if the previous one is still running", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }); + }); + + // act + backupsService.executeBackup(schedule.id); + await new Promise((resolve) => setTimeout(resolve, 10)); + await backupsService.executeBackup(schedule.id); + + // assert + expect(resticBackupMock).toHaveBeenCalledTimes(1); + }); + + test("should set the backup status to failed if restic returns a 3 exit code", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 3, stdout: generateBackupOutput(), stderr: "Some error occurred" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("warning"); + }); + + test("should set the backup status to failed if restic returns a non zero exit code", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 1, stdout: generateBackupOutput(), stderr: "Some error occurred" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("error"); + }); +}); diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index 653cd659..810f168d 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -86,9 +86,7 @@ export const backupScheduleController = new Hono() .post("/:scheduleId/run", runBackupNowDto, async (c) => { const scheduleId = c.req.param("scheduleId"); - backupsService.executeBackup(Number(scheduleId), true).catch((error) => { - console.error("Backup execution failed:", error); - }); + backupsService.executeBackup(Number(scheduleId), true); return c.json({ success: true }, 200); }) diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 584fada7..bb28f29a 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -363,8 +363,6 @@ const executeBackup = async (scheduleId: number, manual = false) => { .catch((notifError) => { logger.error(`Failed to send backup failure notification: ${toMessage(notifError)}`); }); - - throw error; } finally { runningBackups.delete(scheduleId); } diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index ebd52e23..0e1a7330 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -29,6 +29,7 @@ const backupOutputSchema = type({ total_duration: "number", snapshot_id: "string", }); +export type BackupOutput = typeof backupOutputSchema.infer; const snapshotInfoSchema = type({ gid: "number?", @@ -344,7 +345,7 @@ const backup = async ( throw new ResticError(res.exitCode, res.stderr.toString()); } - const lastLine = stdout.trim(); + const lastLine = (stdout || res.stdout).trim(); let summaryLine = ""; try { const resSummary = JSON.parse(lastLine ?? "{}"); diff --git a/app/server/utils/spawn.ts b/app/server/utils/spawn.ts index 6ef86cba..c2b9246b 100644 --- a/app/server/utils/spawn.ts +++ b/app/server/utils/spawn.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; -interface Params { +export interface SafeSpawnParams { command: string; args: string[]; env?: NodeJS.ProcessEnv; @@ -18,7 +18,7 @@ type SpawnResult = { stderr: string; }; -export const safeSpawn = (params: Params) => { +export const safeSpawn = (params: SafeSpawnParams) => { const { command, args, env = {}, signal, ...callbacks } = params; return new Promise((resolve) => { diff --git a/app/test/helpers/backup.ts b/app/test/helpers/backup.ts new file mode 100644 index 00000000..4e8be3fe --- /dev/null +++ b/app/test/helpers/backup.ts @@ -0,0 +1,16 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { backupSchedulesTable, type BackupScheduleInsert } from "~/server/db/schema"; + +export const createTestBackupSchedule = async (overrides: Partial = {}) => { + const backup: BackupScheduleInsert = { + name: faker.system.fileName(), + cronExpression: "0 0 * * *", + repositoryId: "repo_123", + volumeId: 1, + ...overrides, + }; + + const data = await db.insert(backupSchedulesTable).values(backup).returning(); + return data[0]; +}; diff --git a/app/test/helpers/repository.ts b/app/test/helpers/repository.ts new file mode 100644 index 00000000..0a15c59e --- /dev/null +++ b/app/test/helpers/repository.ts @@ -0,0 +1,20 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { repositoriesTable, type RepositoryInsert } from "~/server/db/schema"; + +export const createTestRepository = async (overrides: Partial = {}) => { + const repository: RepositoryInsert = { + id: faker.string.alphanumeric(6), + name: faker.string.alphanumeric(10), + shortId: faker.string.alphanumeric(6), + config: { + name: "test-repo", + backend: "local", + }, + type: "local", + ...overrides, + }; + + const data = await db.insert(repositoriesTable).values(repository).returning(); + return data[0]; +}; diff --git a/app/test/helpers/restic.ts b/app/test/helpers/restic.ts new file mode 100644 index 00000000..b5732019 --- /dev/null +++ b/app/test/helpers/restic.ts @@ -0,0 +1,18 @@ +export const generateBackupOutput = () => { + return JSON.stringify({ + message_type: "summary", + files_new: 10, + files_changed: 5, + files_unmodified: 85, + dirs_new: 2, + dirs_changed: 1, + dirs_unmodified: 17, + data_blobs: 20, + tree_blobs: 5, + data_added: 1048576, + total_files_processed: 100, + total_bytes_processed: 2097152, + total_duration: 12.34, + snapshot_id: "abcd1234", + }); +}; diff --git a/app/test/helpers/volume.ts b/app/test/helpers/volume.ts new file mode 100644 index 00000000..0d63740d --- /dev/null +++ b/app/test/helpers/volume.ts @@ -0,0 +1,21 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { volumesTable, type VolumeInsert } from "~/server/db/schema"; + +export const createTestVolume = async (overrides: Partial = {}) => { + const volume: VolumeInsert = { + name: faker.system.fileName(), + config: { + backend: "directory", + path: `/mnt/volumes/${faker.system.fileName()}`, + }, + status: "mounted", + autoRemount: true, + shortId: faker.string.alphanumeric(6), + type: "directory", + ...overrides, + }; + + const data = await db.insert(volumesTable).values(volume).returning(); + return data[0]; +}; diff --git a/app/test/setup.ts b/app/test/setup.ts new file mode 100644 index 00000000..8791d42c --- /dev/null +++ b/app/test/setup.ts @@ -0,0 +1,19 @@ +import { beforeAll, mock } from "bun:test"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import path from "node:path"; +import { cwd } from "node:process"; +import { db } from "~/server/db/db"; + +mock.module("~/server/utils/logger", () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, +})); + +beforeAll(async () => { + const migrationsFolder = path.join(cwd(), "app", "drizzle"); + migrate(db, { migrationsFolder }); +}); diff --git a/bun.lock b/bun.lock index b430f4dc..f984dc16 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", + "@faker-js/faker": "^10.1.0", "@hey-api/openapi-ts": "^0.88.0", "@react-router/dev": "^7.10.0", "@tailwindcss/vite": "^4.1.17", @@ -66,6 +67,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "dotenv-cli": "^11.0.0", "drizzle-kit": "^0.31.7", "lightningcss": "^1.30.2", "tailwindcss": "^4.1.17", @@ -230,6 +232,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@faker-js/faker": ["@faker-js/faker@10.1.0", "", {}, "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], @@ -578,6 +582,8 @@ "cron-parser": ["cron-parser@5.4.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -748,6 +754,8 @@ "isbot": ["isbot@5.1.32", "", {}, "sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -812,6 +820,8 @@ "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -848,6 +858,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], @@ -936,6 +948,10 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], diff --git a/package.json b/package.json index 2dc931fc..c6e73e60 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "start:prod": "docker compose down && docker compose up --build zerobyte-prod", "gen:api-client": "openapi-ts", "gen:migrations": "drizzle-kit generate", - "studio": "drizzle-kit studio" + "studio": "drizzle-kit studio", + "test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts" }, "overrides": { "esbuild": "^0.27.2" @@ -73,6 +74,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", + "@faker-js/faker": "^10.1.0", "@hey-api/openapi-ts": "^0.88.0", "@react-router/dev": "^7.10.0", "@tailwindcss/vite": "^4.1.17", @@ -81,6 +83,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "dotenv-cli": "^11.0.0", "drizzle-kit": "^0.31.7", "lightningcss": "^1.30.2", "tailwindcss": "^4.1.17",