diff --git a/.gitignore b/.gitignore index 644f0800..bd675bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ tmp/ qa-output .worktrees/ +.turbo diff --git a/app/client/components/backup-summary-card.tsx b/app/client/components/backup-summary-card.tsx index 5ceb554a..00aa40f4 100644 --- a/app/client/components/backup-summary-card.tsx +++ b/app/client/components/backup-summary-card.tsx @@ -1,6 +1,6 @@ import { Card, CardContent } from "~/client/components/ui/card"; import { ByteSize } from "~/client/components/bytes-size"; -import type { ResticSnapshotSummaryDto } from "~/schemas/restic-dto"; +import type { ResticSnapshotSummaryDto } from "@zerobyte/core/restic"; import { formatDuration } from "~/utils/utils"; type Props = { diff --git a/app/client/components/file-browsers/local-file-browser.tsx b/app/client/components/file-browsers/local-file-browser.tsx index a7cfae7e..b2213626 100644 --- a/app/client/components/file-browsers/local-file-browser.tsx +++ b/app/client/components/file-browsers/local-file-browser.tsx @@ -3,7 +3,7 @@ import { browseFilesystemOptions } from "~/client/api-client/@tanstack/react-que import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { parseError } from "~/client/lib/errors"; -import { normalizeAbsolutePath } from "~/utils/path"; +import { normalizeAbsolutePath } from "@zerobyte/core/utils"; type LocalFileBrowserProps = FileBrowserUiProps & { initialPath?: string; diff --git a/app/client/components/file-browsers/snapshot-tree-browser.tsx b/app/client/components/file-browsers/snapshot-tree-browser.tsx index d660c4c2..507b0002 100644 --- a/app/client/components/file-browsers/snapshot-tree-browser.tsx +++ b/app/client/components/file-browsers/snapshot-tree-browser.tsx @@ -4,7 +4,7 @@ import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-qu import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { parseError } from "~/client/lib/errors"; -import { normalizeAbsolutePath } from "~/utils/path"; +import { normalizeAbsolutePath } from "@zerobyte/core/utils"; type SnapshotTreeBrowserProps = FileBrowserUiProps & { repositoryId: string; diff --git a/app/client/components/repository-icon.tsx b/app/client/components/repository-icon.tsx index e28286cf..bb844a31 100644 --- a/app/client/components/repository-icon.tsx +++ b/app/client/components/repository-icon.tsx @@ -1,5 +1,5 @@ import { Database, HardDrive, Cloud, Server } from "lucide-react"; -import type { RepositoryBackend } from "~/schemas/restic"; +import type { RepositoryBackend } from "@zerobyte/core/restic"; type Props = { backend: RepositoryBackend; diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index c7c48615..56d75322 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -21,7 +21,7 @@ import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot- import { RestoreProgress } from "~/client/components/restore-progress"; import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events"; -import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; +import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic"; import type { Repository } from "~/client/lib/types"; import { handleRepositoryError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index 3bfcc7af..a8ce3c35 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -30,7 +30,7 @@ import { restRepositoryConfigSchema, s3RepositoryConfigSchema, sftpRepositoryConfigSchema, -} from "~/schemas/restic"; +} from "@zerobyte/core/restic"; import { Checkbox } from "../../../components/ui/checkbox"; import { LocalRepositoryForm, diff --git a/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx b/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx index 20e6ed9a..57eeddb6 100644 --- a/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx @@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible"; import type { RepositoryFormValues } from "../create-repository-form"; import { cn } from "~/client/lib/utils"; -import { BANDWIDTH_UNITS } from "~/schemas/restic"; +import { BANDWIDTH_UNITS } from "@zerobyte/core/restic"; type Props = { form: UseFormReturn; diff --git a/app/client/modules/repositories/routes/edit-repository.tsx b/app/client/modules/repositories/routes/edit-repository.tsx index d448f4a2..d45022b1 100644 --- a/app/client/modules/repositories/routes/edit-repository.tsx +++ b/app/client/modules/repositories/routes/edit-repository.tsx @@ -22,7 +22,7 @@ import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { parseError } from "~/client/lib/errors"; import { Alert, AlertDescription } from "~/client/components/ui/alert"; -import type { RepositoryConfig } from "~/schemas/restic"; +import type { RepositoryConfig } from "@zerobyte/core/restic"; import { useNavigate } from "@tanstack/react-router"; const riskyLocationFieldsByBackend = { diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx index b0b2ef8e..16668482 100644 --- a/app/client/modules/repositories/tabs/info.tsx +++ b/app/client/modules/repositories/tabs/info.tsx @@ -22,7 +22,7 @@ import { startDoctorMutation, unlockRepositoryMutation, } from "~/client/api-client/@tanstack/react-query.gen"; -import type { RepositoryConfig } from "~/schemas/restic"; +import type { RepositoryConfig } from "@zerobyte/core/restic"; import { DoctorReport } from "../components/doctor-report"; import { parseError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; diff --git a/app/schemas/events-dto.ts b/app/schemas/events-dto.ts index 02e892b8..8edc3385 100644 --- a/app/schemas/events-dto.ts +++ b/app/schemas/events-dto.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto"; +import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "@zerobyte/core/restic"; export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]); export const restoreEventStatusSchema = z.enum(["success", "error"]); diff --git a/app/schemas/server-events.ts b/app/schemas/server-events.ts index cd199d4d..7ec54f22 100644 --- a/app/schemas/server-events.ts +++ b/app/schemas/server-events.ts @@ -7,7 +7,7 @@ import type { ServerRestoreProgressEventDto, ServerRestoreStartedEventDto, } from "~/schemas/events-dto"; -import type { DoctorResult } from "~/schemas/restic"; +import type { DoctorResult } from "@zerobyte/core/restic"; const payload = () => undefined as unknown as T; diff --git a/app/server.ts b/app/server.ts index c097a4c4..34364053 100644 --- a/app/server.ts +++ b/app/server.ts @@ -1,4 +1,4 @@ -import { logger } from "./server/utils/logger"; +import { logger } from "@zerobyte/core/node"; import { shutdown } from "./server/modules/lifecycle/shutdown"; import { runCLI } from "./server/cli"; import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server"; diff --git a/app/server/app.ts b/app/server/app.ts index 56527d87..4431e9fe 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -16,7 +16,7 @@ import { backupScheduleController } from "./modules/backups/backups.controller"; import { eventsController } from "./modules/events/events.controller"; import { notificationsController } from "./modules/notifications/notifications.controller"; import { handleServiceError } from "./utils/errors"; -import { logger } from "./utils/logger"; +import { logger } from "@zerobyte/core/node"; import { config } from "./core/config"; import { auth } from "~/server/lib/auth"; import { db } from "./db/db"; diff --git a/app/server/core/__tests__/scheduler.test.ts b/app/server/core/__tests__/scheduler.test.ts index 7b06ddd7..f503a022 100644 --- a/app/server/core/__tests__/scheduler.test.ts +++ b/app/server/core/__tests__/scheduler.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, spyOn, test, vi } from "bun:test"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; import { Job, Scheduler } from "../scheduler"; const flushMicrotasks = async () => { diff --git a/app/server/core/capabilities.ts b/app/server/core/capabilities.ts index ca1efae6..ec1afc94 100644 --- a/app/server/core/capabilities.ts +++ b/app/server/core/capabilities.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; import { RCLONE_CONFIG_DIR } from "./constants"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; export type SystemCapabilities = { rclone: boolean; diff --git a/app/server/core/repository-mutex.ts b/app/server/core/repository-mutex.ts index 5047830b..2dee5779 100644 --- a/app/server/core/repository-mutex.ts +++ b/app/server/core/repository-mutex.ts @@ -1,4 +1,4 @@ -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; export type LockType = "shared" | "exclusive"; diff --git a/app/server/core/restic.ts b/app/server/core/restic.ts new file mode 100644 index 00000000..79f621d6 --- /dev/null +++ b/app/server/core/restic.ts @@ -0,0 +1,32 @@ +import { createRestic } from "@zerobyte/core/restic/server"; +import type { ResticDeps } from "@zerobyte/core/restic"; +import { DEFAULT_EXCLUDES, RESTIC_CACHE_DIR, RESTIC_PASS_FILE } from "./constants"; +import { config } from "./config"; +import { cryptoUtils } from "../utils/crypto"; +import { db } from "../db/db"; + +export const resticDeps: ResticDeps = { + resolveSecret: cryptoUtils.resolveSecret, + getOrganizationResticPassword: async (organizationId: string) => { + const org = await db.query.organization.findFirst({ + where: { id: organizationId }, + }); + + if (!org) { + throw new Error(`Organization ${organizationId} not found`); + } + + const metadata = org.metadata as { resticPassword?: string } | null; + if (!metadata?.resticPassword) { + throw new Error(`Restic password not configured for organization ${organizationId}`); + } + + return metadata.resticPassword; + }, + resticCacheDir: RESTIC_CACHE_DIR, + resticPassFile: RESTIC_PASS_FILE, + defaultExcludes: DEFAULT_EXCLUDES, + hostname: config.resticHostname, +}; + +export const restic = createRestic(resticDeps); diff --git a/app/server/core/scheduler.ts b/app/server/core/scheduler.ts index ea6e9eff..756785a0 100644 --- a/app/server/core/scheduler.ts +++ b/app/server/core/scheduler.ts @@ -1,5 +1,5 @@ import CronExpressionParser from "cron-parser"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; export abstract class Job { abstract run(): Promise; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index dc9e719a..38186b03 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -7,8 +7,8 @@ import type { RepositoryStatus, BandwidthUnit, DoctorResult, -} from "~/schemas/restic"; -import type { ResticStatsDto } from "~/schemas/restic-dto"; + ResticStatsDto, +} from "@zerobyte/core/restic"; import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes"; import type { NotificationConfig, NotificationType } from "~/schemas/notifications"; import type { ShortId } from "~/server/utils/branded"; diff --git a/app/server/jobs/auto-remount.ts b/app/server/jobs/auto-remount.ts index 308ab0af..758d0c70 100644 --- a/app/server/jobs/auto-remount.ts +++ b/app/server/jobs/auto-remount.ts @@ -1,6 +1,6 @@ import { Job } from "../core/scheduler"; import { volumeService } from "../modules/volumes/volume.service"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { db } from "../db/db"; import { withContext } from "../core/request-context"; diff --git a/app/server/jobs/backup-execution.ts b/app/server/jobs/backup-execution.ts index 7328fd05..ff72bd08 100644 --- a/app/server/jobs/backup-execution.ts +++ b/app/server/jobs/backup-execution.ts @@ -1,6 +1,6 @@ import { Job } from "../core/scheduler"; import { backupsExecutionService } from "../modules/backups/backups.execution"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { db } from "../db/db"; import { withContext } from "../core/request-context"; diff --git a/app/server/jobs/cleanup-dangling.ts b/app/server/jobs/cleanup-dangling.ts index ffa9d292..b89ba0bd 100644 --- a/app/server/jobs/cleanup-dangling.ts +++ b/app/server/jobs/cleanup-dangling.ts @@ -4,7 +4,7 @@ import fs from "node:fs/promises"; import { volumeService } from "../modules/volumes/volume.service"; import { readMountInfo } from "../utils/mountinfo"; import { getVolumePath } from "../modules/volumes/helpers"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { executeUnmount } from "../modules/backends/utils/backend-utils"; import { toMessage } from "../utils/errors"; import { VOLUME_MOUNT_BASE } from "../core/constants"; diff --git a/app/server/jobs/healthchecks.ts b/app/server/jobs/healthchecks.ts index 83d253c2..f119d520 100644 --- a/app/server/jobs/healthchecks.ts +++ b/app/server/jobs/healthchecks.ts @@ -1,6 +1,6 @@ import { Job } from "../core/scheduler"; import { volumeService } from "../modules/volumes/volume.service"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { db } from "../db/db"; import { withContext } from "../core/request-context"; diff --git a/app/server/jobs/repository-healthchecks.ts b/app/server/jobs/repository-healthchecks.ts index 8e3a3f9a..7cf1d88d 100644 --- a/app/server/jobs/repository-healthchecks.ts +++ b/app/server/jobs/repository-healthchecks.ts @@ -1,6 +1,6 @@ import { Job } from "../core/scheduler"; import { repositoriesService } from "../modules/repositories/repositories.service"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { db } from "../db/db"; import { withContext } from "../core/request-context"; diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 66c7451e..0e8549d4 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -12,7 +12,7 @@ import { createAuthMiddleware } from "better-auth/api"; import { config } from "../core/config"; import { db } from "../db/db"; import { cryptoUtils } from "../utils/crypto"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { authService } from "../modules/auth/auth.service"; import { tanstackStartCookies } from "better-auth/tanstack-start"; import { isValidUsername, normalizeUsername } from "~/lib/username"; diff --git a/app/server/lib/auth/helpers/create-default-org.ts b/app/server/lib/auth/helpers/create-default-org.ts index 87eb986a..3ca4ebf7 100644 --- a/app/server/lib/auth/helpers/create-default-org.ts +++ b/app/server/lib/auth/helpers/create-default-org.ts @@ -2,7 +2,7 @@ import { UnauthorizedError } from "http-errors-enhanced"; import { db } from "~/server/db/db"; import { member, organization, type User } from "~/server/db/schema"; import { cryptoUtils } from "~/server/utils/crypto"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; export async function findMembershipWithOrganization(userId: string, organizationId?: string) { const membership = await db.query.member.findFirst({ diff --git a/app/server/lib/auth/middlewares/only-one-user.ts b/app/server/lib/auth/middlewares/only-one-user.ts index d4fc2ca9..92b31d29 100644 --- a/app/server/lib/auth/middlewares/only-one-user.ts +++ b/app/server/lib/auth/middlewares/only-one-user.ts @@ -1,6 +1,6 @@ import { db } from "~/server/db/db"; import type { AuthMiddlewareContext } from "~/server/lib/auth"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; import { ForbiddenError } from "http-errors-enhanced"; import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants"; diff --git a/app/server/modules/backends/directory/directory-backend.ts b/app/server/modules/backends/directory/directory-backend.ts index 67d69cc9..8fa02ccf 100644 --- a/app/server/modules/backends/directory/directory-backend.ts +++ b/app/server/modules/backends/directory/directory-backend.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; import { toMessage } from "../../../utils/errors"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import type { VolumeBackend } from "../backend"; import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; diff --git a/app/server/modules/backends/nfs/nfs-backend.ts b/app/server/modules/backends/nfs/nfs-backend.ts index d41d2cfb..98ba3956 100644 --- a/app/server/modules/backends/nfs/nfs-backend.ts +++ b/app/server/modules/backends/nfs/nfs-backend.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { toMessage } from "../../../utils/errors"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { getMountForPath } from "../../../utils/mountinfo"; import { withTimeout } from "../../../utils/timeout"; import type { VolumeBackend } from "../backend"; diff --git a/app/server/modules/backends/rclone/rclone-backend.ts b/app/server/modules/backends/rclone/rclone-backend.ts index d6cb4eb5..73735d51 100644 --- a/app/server/modules/backends/rclone/rclone-backend.ts +++ b/app/server/modules/backends/rclone/rclone-backend.ts @@ -2,13 +2,13 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { toMessage } from "../../../utils/errors"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { getMountForPath } from "../../../utils/mountinfo"; import { withTimeout } from "../../../utils/timeout"; import type { VolumeBackend } from "../backend"; import { executeUnmount } from "../utils/backend-utils"; import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; -import { safeExec } from "~/server/utils/spawn"; +import { safeExec } from "@zerobyte/core/node"; import { config as zbConfig } from "~/server/core/config"; const mount = async (config: BackendConfig, path: string) => { diff --git a/app/server/modules/backends/sftp/sftp-backend.ts b/app/server/modules/backends/sftp/sftp-backend.ts index 82d9c004..25ebf7b5 100644 --- a/app/server/modules/backends/sftp/sftp-backend.ts +++ b/app/server/modules/backends/sftp/sftp-backend.ts @@ -5,7 +5,7 @@ import { spawn } from "node:child_process"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { cryptoUtils } from "../../../utils/crypto"; import { toMessage } from "../../../utils/errors"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { getMountForPath } from "../../../utils/mountinfo"; import { withTimeout } from "../../../utils/timeout"; import type { VolumeBackend } from "../backend"; diff --git a/app/server/modules/backends/smb/smb-backend.ts b/app/server/modules/backends/smb/smb-backend.ts index a6945907..05e5ae60 100644 --- a/app/server/modules/backends/smb/smb-backend.ts +++ b/app/server/modules/backends/smb/smb-backend.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { cryptoUtils } from "../../../utils/crypto"; import { toMessage } from "../../../utils/errors"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { getMountForPath } from "../../../utils/mountinfo"; import { withTimeout } from "../../../utils/timeout"; import type { VolumeBackend } from "../backend"; diff --git a/app/server/modules/backends/utils/backend-utils.ts b/app/server/modules/backends/utils/backend-utils.ts index 126cae34..d0f85cc1 100644 --- a/app/server/modules/backends/utils/backend-utils.ts +++ b/app/server/modules/backends/utils/backend-utils.ts @@ -1,8 +1,8 @@ import * as fs from "node:fs/promises"; import * as npath from "node:path"; import { toMessage } from "../../../utils/errors"; -import { logger } from "../../../utils/logger"; -import { safeExec } from "~/server/utils/spawn"; +import { logger } from "@zerobyte/core/node"; +import { safeExec } from "@zerobyte/core/node"; export const executeMount = async (args: string[]): Promise => { const shouldBeVerbose = process.env.LOG_LEVEL === "debug" || process.env.NODE_ENV !== "production"; diff --git a/app/server/modules/backends/webdav/webdav-backend.ts b/app/server/modules/backends/webdav/webdav-backend.ts index 4a950cd0..15f8c4c9 100644 --- a/app/server/modules/backends/webdav/webdav-backend.ts +++ b/app/server/modules/backends/webdav/webdav-backend.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { cryptoUtils } from "../../../utils/crypto"; import { toMessage } from "../../../utils/errors"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { getMountForPath } from "../../../utils/mountinfo"; import { withTimeout } from "../../../utils/timeout"; import type { VolumeBackend } from "../backend"; diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index 1bf8754f..fb563469 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -9,8 +9,8 @@ import { createTestBackupScheduleMirror } from "~/test/helpers/backup-mirror"; import { generateBackupOutput } from "~/test/helpers/restic"; import { TEST_ORG_ID } from "~/test/helpers/organization"; import * as context from "~/server/core/request-context"; -import * as spawnModule from "~/server/utils/spawn"; -import { restic } from "~/server/utils/restic"; +import * as spawnModule from "@zerobyte/core/node"; +import { restic } from "~/server/core/restic"; import { NotFoundError, BadRequestError } from "http-errors-enhanced"; import { scheduleQueries } from "../backups.queries"; import { fromAny } from "@total-typescript/shoehorn"; diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index 53a73be7..41c5a3af 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -7,7 +7,7 @@ import { createTestBackupSchedule } from "~/test/helpers/backup"; import { createTestRepository } from "~/test/helpers/repository"; import { generateBackupOutput } from "~/test/helpers/restic"; import { faker } from "@faker-js/faker"; -import * as spawnModule from "~/server/utils/spawn"; +import * as spawnModule from "@zerobyte/core/node"; import { db } from "~/server/db/db"; import { backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "~/server/db/schema"; import { TEST_ORG_ID } from "~/test/helpers/organization"; diff --git a/app/server/modules/backups/backup.helpers.ts b/app/server/modules/backups/backup.helpers.ts index 27dec79c..edf99ac3 100644 --- a/app/server/modules/backups/backup.helpers.ts +++ b/app/server/modules/backups/backup.helpers.ts @@ -2,7 +2,7 @@ import CronExpressionParser from "cron-parser"; import path from "node:path"; import type { BackupSchedule } from "~/server/db/schema"; import { toMessage } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; export const calculateNextRun = (cronExpression: string) => { try { diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index 5eb0a2b6..2b349ba0 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -45,7 +45,7 @@ import { import { notificationsService } from "../notifications/notifications.service"; import { requireAuth } from "../auth/auth.middleware"; import { backupsExecutionService } from "./backups.execution"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; import { asShortId } from "~/server/utils/branded"; export const backupScheduleController = new Hono() diff --git a/app/server/modules/backups/backups.execution.ts b/app/server/modules/backups/backups.execution.ts index d233fd37..e9389334 100644 --- a/app/server/modules/backups/backups.execution.ts +++ b/app/server/modules/backups/backups.execution.ts @@ -1,7 +1,7 @@ import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; import type { BackupSchedule, Volume, Repository } from "../../db/schema"; -import { restic } from "../../utils/restic"; -import { logger } from "../../utils/logger"; +import { restic } from "../../core/restic"; +import { logger } from "@zerobyte/core/node"; import { cache, cacheKeys } from "../../utils/cache"; import { getVolumePath } from "../volumes/helpers"; import { toMessage } from "../../utils/errors"; @@ -12,7 +12,7 @@ import { repositoriesService } from "../repositories/repositories.service"; import { getOrganizationId } from "~/server/core/request-context"; import { scheduleQueries, mirrorQueries, repositoryQueries } from "./backups.queries"; import { calculateNextRun, createBackupOptions } from "./backup.helpers"; -import type { ResticBackupOutputDto } from "~/schemas/restic-dto"; +import type { ResticBackupOutputDto } from "@zerobyte/core/restic"; import type { BackupProgressEventDto } from "~/schemas/events-dto"; const runningBackups = new Map(); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 52c6a8bb..45beec01 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -19,7 +19,7 @@ import { generateShortId } from "~/server/utils/id"; import { getOrganizationId } from "~/server/core/request-context"; import { calculateNextRun } from "./backup.helpers"; import { asShortId, type ShortId } from "~/server/utils/branded"; -import { validateCustomResticParams } from "~/server/utils/restic/helpers/validate-custom-params"; +import { validateCustomResticParams } from "@zerobyte/core/restic/server"; const listSchedules = async () => { const organizationId = getOrganizationId(); diff --git a/app/server/modules/events/events.controller.ts b/app/server/modules/events/events.controller.ts index 0bf092f3..9bf395be 100644 --- a/app/server/modules/events/events.controller.ts +++ b/app/server/modules/events/events.controller.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { serverEvents } from "../../core/events"; -import { logger } from "../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { requireAuth } from "../auth/auth.middleware"; import type { ServerEventPayloadMap } from "~/schemas/server-events"; diff --git a/app/server/modules/lifecycle/migrations.ts b/app/server/modules/lifecycle/migrations.ts index 91cb793c..9064b8ab 100644 --- a/app/server/modules/lifecycle/migrations.ts +++ b/app/server/modules/lifecycle/migrations.ts @@ -1,4 +1,4 @@ -import { logger } from "../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { v00001 } from "./migrations/00001-retag-snapshots"; import { v00002 } from "./migrations/00002-isolate-restic-passwords"; import { v00003 } from "./migrations/00003-assign-organization"; diff --git a/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts b/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts index 9aefd343..46afc38f 100644 --- a/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts +++ b/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts @@ -1,10 +1,11 @@ import { eq } from "drizzle-orm"; import { db } from "../../../db/db"; import { backupScheduleMirrorsTable, type Repository } from "../../../db/schema"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { toMessage } from "~/server/utils/errors"; -import { safeExec } from "~/server/utils/spawn"; -import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic"; +import { safeExec } from "@zerobyte/core/node"; +import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@zerobyte/core/restic/server"; +import { resticDeps } from "~/server/core/restic"; const migrateTag = async ( oldTag: string, @@ -13,7 +14,7 @@ const migrateTag = async ( scheduleName: string, ): Promise => { const repoUrl = buildRepoUrl(repository.config); - const env = await buildEnv(repository.config, repository.organizationId); + const env = await buildEnv(repository.config, repository.organizationId, resticDeps); const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag]; @@ -21,7 +22,7 @@ const migrateTag = async ( logger.info(`Migrating snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`); const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, resticDeps); if (res.exitCode !== 0) { logger.error(`Restic tag failed: ${res.stderr}`); diff --git a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts index b05b1301..742c34e2 100644 --- a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts +++ b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts @@ -9,10 +9,10 @@ import { notificationDestinationsTable, twoFactor, } from "../../../db/schema"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { toMessage } from "~/server/utils/errors"; import { cryptoUtils } from "~/server/utils/crypto"; -import type { RepositoryConfig } from "~/schemas/restic"; +import type { RepositoryConfig } from "@zerobyte/core/restic"; import type { BackendConfig } from "~/schemas/volumes"; import type { NotificationConfig } from "~/schemas/notifications"; import { RESTIC_PASS_FILE } from "~/server/core/constants"; diff --git a/app/server/modules/lifecycle/migrations/00003-assign-organization.ts b/app/server/modules/lifecycle/migrations/00003-assign-organization.ts index d35af947..70cd470f 100644 --- a/app/server/modules/lifecycle/migrations/00003-assign-organization.ts +++ b/app/server/modules/lifecycle/migrations/00003-assign-organization.ts @@ -1,6 +1,6 @@ import { db } from "../../../db/db"; import { member } from "../../../db/schema"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { toMessage } from "~/server/utils/errors"; const execute = async () => { diff --git a/app/server/modules/lifecycle/migrations/00004-concat-path-name.ts b/app/server/modules/lifecycle/migrations/00004-concat-path-name.ts index e9ec39af..1c17bcc3 100644 --- a/app/server/modules/lifecycle/migrations/00004-concat-path-name.ts +++ b/app/server/modules/lifecycle/migrations/00004-concat-path-name.ts @@ -1,10 +1,10 @@ import { and, eq } from "drizzle-orm"; import { db } from "../../../db/db"; import { repositoriesTable } from "../../../db/schema"; -import { logger } from "../../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { toMessage } from "~/server/utils/errors"; import { REPOSITORY_BASE } from "~/server/core/constants"; -import { repositoryConfigSchema } from "~/schemas/restic"; +import { repositoryConfigSchema } from "@zerobyte/core/restic"; type MigrationError = { name: string; error: string }; diff --git a/app/server/modules/lifecycle/shutdown.ts b/app/server/modules/lifecycle/shutdown.ts index 5d72e1a3..3bb76073 100644 --- a/app/server/modules/lifecycle/shutdown.ts +++ b/app/server/modules/lifecycle/shutdown.ts @@ -1,6 +1,6 @@ import { Scheduler } from "../../core/scheduler"; import { db } from "../../db/db"; -import { logger } from "../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { createVolumeBackend } from "../backends/backend"; export const shutdown = async () => { diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index fad0ae17..507a0db0 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -2,7 +2,7 @@ import { Scheduler } from "../../core/scheduler"; import { eq } from "drizzle-orm"; import { db } from "../../db/db"; import { backupSchedulesTable } from "../../db/schema"; -import { logger } from "../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { volumeService } from "../volumes/volume.service"; import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling"; import { VolumeHealthCheckJob } from "../../jobs/healthchecks"; diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index 31adb90f..b085472e 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -7,12 +7,12 @@ import { type NotificationDestination, } from "../../db/schema"; import { cryptoUtils } from "../../utils/crypto"; -import { logger } from "../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { sendNotification } from "../../utils/shoutrrr"; import { formatDuration } from "~/utils/utils"; import { buildShoutrrrUrl } from "./builders"; import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications"; -import type { ResticBackupRunSummaryDto } from "~/schemas/restic-dto"; +import type { ResticBackupRunSummaryDto } from "@zerobyte/core/restic"; import { toMessage } from "../../utils/errors"; import { getOrganizationId } from "~/server/core/request-context"; import { formatBytes } from "~/utils/format-bytes"; diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index 006c3569..50bff3a1 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -6,7 +6,7 @@ import { db } from "~/server/db/db"; import { repositoriesTable } from "~/server/db/schema"; import { generateShortId } from "~/server/utils/id"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; -import type { RepositoryConfig } from "~/schemas/restic"; +import type { RepositoryConfig } from "@zerobyte/core/restic"; const app = createApp(); @@ -279,8 +279,8 @@ describe("repositories updates", () => { const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(organizationId); - const { restic } = await import("~/server/utils/restic"); - const { ResticError } = await import("~/server/utils/errors"); + const { restic } = await import("~/server/core/restic"); + const { ResticError } = await import("@zerobyte/core/restic"); const deleteSnapshotSpy = spyOn(restic, "deleteSnapshot").mockImplementation(async () => { throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index 774e36a5..cf0d67ff 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -1,18 +1,18 @@ import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -import type { RepositoryConfig } from "~/schemas/restic"; +import type { RepositoryConfig } from "@zerobyte/core/restic"; import { REPOSITORY_BASE } from "~/server/core/constants"; import { serverEvents } from "~/server/core/events"; import { withContext } from "~/server/core/request-context"; import { db } from "~/server/db/db"; import { repositoriesTable } from "~/server/db/schema"; import { generateShortId } from "~/server/utils/id"; -import { restic } from "~/server/utils/restic"; +import { restic } from "~/server/core/restic"; import { createTestSession } from "~/test/helpers/auth"; import { createTestBackupSchedule } from "~/test/helpers/backup"; import { cache, cacheKeys } from "~/server/utils/cache"; -import { ResticError } from "~/server/utils/errors"; +import { ResticError } from "@zerobyte/core/restic/server"; import { repositoriesService } from "../repositories.service"; const createTestRepository = async (organizationId: string) => { diff --git a/app/server/modules/repositories/helpers/doctor.ts b/app/server/modules/repositories/helpers/doctor.ts index 70ceb3f5..c3659dac 100644 --- a/app/server/modules/repositories/helpers/doctor.ts +++ b/app/server/modules/repositories/helpers/doctor.ts @@ -1,11 +1,11 @@ import { eq } from "drizzle-orm"; -import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "~/schemas/restic"; +import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "@zerobyte/core/restic"; import { z } from "zod"; import { getOrganizationId } from "~/server/core/request-context"; -import { restic } from "~/server/utils/restic"; +import { restic } from "~/server/core/restic"; import { toMessage } from "~/server/utils/errors"; -import { safeJsonParse } from "~/server/utils/json"; -import { logger } from "~/server/utils/logger"; +import { safeJsonParse } from "@zerobyte/core/utils"; +import { logger } from "@zerobyte/core/node"; import { db } from "~/server/db/db"; import { repositoriesTable } from "~/server/db/schema"; import { repoMutex } from "~/server/core/repository-mutex"; diff --git a/app/server/modules/repositories/helpers/dump.ts b/app/server/modules/repositories/helpers/dump.ts index cbe85e96..5e8caa78 100644 --- a/app/server/modules/repositories/helpers/dump.ts +++ b/app/server/modules/repositories/helpers/dump.ts @@ -1,7 +1,6 @@ import { BadRequestError } from "http-errors-enhanced"; import path from "node:path"; -import { findCommonAncestor } from "~/utils/common-ancestor"; -import { normalizeAbsolutePath } from "~/utils/path"; +import { findCommonAncestor, normalizeAbsolutePath } from "@zerobyte/core/utils"; const sanitizeFilenamePart = (value: string): string => { const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+|_+$/g, ""); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index db2e563f..29b8432b 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -7,8 +7,9 @@ import { REPOSITORY_STATUS, repositoryConfigSchema, doctorResultSchema, -} from "~/schemas/restic"; -import { resticSnapshotSummarySchema, resticStatsSchema } from "~/schemas/restic-dto"; + resticSnapshotSummarySchema, + resticStatsSchema, +} from "@zerobyte/core/restic"; export const repositorySchema = z.object({ id: z.string(), diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index d79a916f..5e701f8f 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -6,13 +6,13 @@ import { type CompressionMode, type OverwriteMode, type RepositoryConfig, + type ResticStatsDto, repositoryConfigSchema, -} from "~/schemas/restic"; -import type { ResticStatsDto } from "~/schemas/restic-dto"; +} from "@zerobyte/core/restic"; import { config as appConfig } from "~/server/core/config"; import { serverEvents } from "~/server/core/events"; import { getOrganizationId } from "~/server/core/request-context"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; import { parseRetentionCategories, type RetentionCategory } from "~/server/utils/retention-categories"; import { repoMutex } from "../../core/repository-mutex"; import { db } from "../../db/db"; @@ -21,11 +21,12 @@ import { cache, cacheKeys } from "../../utils/cache"; import { cryptoUtils } from "../../utils/crypto"; import { toMessage } from "../../utils/errors"; import { generateShortId } from "../../utils/id"; -import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } from "../../utils/restic"; -import { safeSpawn } from "../../utils/spawn"; +import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@zerobyte/core/restic/server"; +import { restic, resticDeps } from "../../core/restic"; +import { safeSpawn } from "@zerobyte/core/node"; import { backupsService } from "../backups/backups.service"; import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto"; -import { findCommonAncestor } from "~/utils/common-ancestor"; +import { findCommonAncestor } from "@zerobyte/core/utils"; import { prepareSnapshotDump } from "./helpers/dump"; import { executeDoctor } from "./helpers/doctor"; import type { ShortId } from "~/server/utils/branded"; @@ -832,7 +833,7 @@ const execResticCommand = async ( } const repoUrl = buildRepoUrl(repository.config); - const env = await buildEnv(repository.config, organizationId); + const env = await buildEnv(repository.config, organizationId, resticDeps); const resticArgs: string[] = ["--repo", repoUrl, command]; if (args && args.length > 0) { @@ -842,7 +843,7 @@ const execResticCommand = async ( const result = await safeSpawn({ command: "restic", args: resticArgs, env, signal, onStdout, onStderr }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, resticDeps); return { exitCode: result.exitCode }; }; diff --git a/app/server/modules/sso/middlewares/require-invitation.ts b/app/server/modules/sso/middlewares/require-invitation.ts index 44c02c17..853daeee 100644 --- a/app/server/modules/sso/middlewares/require-invitation.ts +++ b/app/server/modules/sso/middlewares/require-invitation.ts @@ -1,6 +1,6 @@ import { APIError } from "better-auth/api"; import type { GenericEndpointContext } from "better-auth"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; import { extractProviderIdFromContext } from "~/server/modules/sso/utils/sso-context"; import { ssoService } from "~/server/modules/sso/sso.service"; diff --git a/app/server/modules/sso/sso.integration.ts b/app/server/modules/sso/sso.integration.ts index fedfc98e..b871e575 100644 --- a/app/server/modules/sso/sso.integration.ts +++ b/app/server/modules/sso/sso.integration.ts @@ -13,7 +13,7 @@ import { requireSsoInvitation } from "./middlewares/require-invitation"; import { resolveTrustedProvidersForRequest } from "./middlewares/trust-provider-for-linking"; import { isSsoCallbackRequest, extractProviderIdFromContext, normalizeEmail } from "./utils/sso-context"; import { findMembershipWithOrganization } from "~/server/lib/auth/helpers/create-default-org"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; async function resolveOrgMembership(userId: string, ctx: GenericEndpointContext | null) { const user = await db.query.usersTable.findFirst({ where: { id: userId } }); diff --git a/app/server/modules/system/system.service.ts b/app/server/modules/system/system.service.ts index 1933ba90..d591f303 100644 --- a/app/server/modules/system/system.service.ts +++ b/app/server/modules/system/system.service.ts @@ -3,7 +3,7 @@ import { config } from "../../core/config"; import type { UpdateInfoDto } from "./system.dto"; import semver from "semver"; import { cache, cacheKeys } from "../../utils/cache"; -import { logger } from "~/server/utils/logger"; +import { logger } from "@zerobyte/core/node"; import { db } from "../../db/db"; import { appMetadataTable } from "../../db/schema"; import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants"; diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index 76b2443c..dfd64777 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -13,7 +13,7 @@ import { withTimeout } from "../../utils/timeout"; import { createVolumeBackend } from "../backends/backend"; import type { UpdateVolumeBody } from "./volume.dto"; import { getVolumePath } from "./helpers"; -import { logger } from "../../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { serverEvents } from "../../core/events"; import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes"; import { getOrganizationId } from "~/server/core/request-context"; diff --git a/app/server/plugins/bootstrap.ts b/app/server/plugins/bootstrap.ts index f2980492..4dbc7c9e 100644 --- a/app/server/plugins/bootstrap.ts +++ b/app/server/plugins/bootstrap.ts @@ -1,6 +1,6 @@ import { definePlugin } from "nitro"; import { bootstrapApplication } from "../modules/lifecycle/bootstrap"; -import { logger } from "../utils/logger"; +import { logger } from "@zerobyte/core/node"; import { toMessage } from "../utils/errors"; export default definePlugin(async () => { diff --git a/app/server/utils/__tests__/spawn.test.ts b/app/server/utils/__tests__/spawn.test.ts index b86bc6f1..7758eea8 100644 --- a/app/server/utils/__tests__/spawn.test.ts +++ b/app/server/utils/__tests__/spawn.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { safeExec, safeSpawn } from "../spawn"; +import { safeExec, safeSpawn } from "@zerobyte/core/node"; describe("safeExec", () => { describe("successful commands", () => { diff --git a/app/server/utils/backend-compatibility.ts b/app/server/utils/backend-compatibility.ts index b9b61f63..e406076f 100644 --- a/app/server/utils/backend-compatibility.ts +++ b/app/server/utils/backend-compatibility.ts @@ -1,4 +1,4 @@ -import type { RepositoryConfig } from "~/schemas/restic"; +import type { RepositoryConfig } from "@zerobyte/core/restic"; import { cryptoUtils } from "./crypto"; type BackendConflictGroup = "s3" | "gcs" | "azure" | "rest" | "sftp" | null; diff --git a/app/server/utils/common-ancestor.test.ts b/app/server/utils/common-ancestor.test.ts index 9178cb9b..5790e5f6 100644 --- a/app/server/utils/common-ancestor.test.ts +++ b/app/server/utils/common-ancestor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { findCommonAncestor } from "~/utils/common-ancestor"; +import { findCommonAncestor } from "@zerobyte/core/utils"; describe("findCommonAncestor", () => { test("returns root for empty path lists", () => { diff --git a/app/server/utils/errors.ts b/app/server/utils/errors.ts index 3024d570..b592505e 100644 --- a/app/server/utils/errors.ts +++ b/app/server/utils/errors.ts @@ -1,5 +1,5 @@ import { HttpError } from "http-errors-enhanced"; -import { sanitizeSensitiveData } from "./sanitize"; +import { sanitizeSensitiveData } from "@zerobyte/core/node"; export const handleServiceError = (error: unknown) => { if (error instanceof HttpError) { @@ -13,25 +13,3 @@ export const toMessage = (err: unknown): string => { const message = err instanceof Error ? err.message : String(err); return sanitizeSensitiveData(message); }; - -const resticErrorCodes: Record = { - 1: "Command failed: An error occurred while executing the command.", - 2: "Go runtime error: A runtime error occurred in the Go program.", - 3: "Backup could not read all files: Some files could not be read during backup.", - 10: "Repository not found: The specified repository could not be found.", - 11: "Failed to lock repository: Unable to acquire a lock on the repository. Try to run doctor on the repository.", - 12: "Wrong repository password: The provided password for the repository is incorrect.", - 130: "Backup interrupted: The backup process was interrupted.", -}; - -export class ResticError extends Error { - code: number; - - constructor(code: number, stderr: string) { - const message = resticErrorCodes[code] || `Unknown restic error with code ${code}`; - super(`${message}\n${stderr}`); - - this.code = code; - this.name = "ResticError"; - } -} diff --git a/app/server/utils/rclone.ts b/app/server/utils/rclone.ts index 0d4cdea7..083b0740 100644 --- a/app/server/utils/rclone.ts +++ b/app/server/utils/rclone.ts @@ -1,6 +1,6 @@ -import { logger } from "./logger"; +import { logger } from "@zerobyte/core/node"; import { toMessage } from "./errors"; -import { safeExec } from "./spawn"; +import { safeExec } from "@zerobyte/core/node"; /** * List all configured rclone remotes diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts deleted file mode 100644 index 4fffcd87..00000000 --- a/app/server/utils/restic.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } from "./restic/index"; -export type { RestoreProgress } from "./restic/index"; -export type { ForgetGroup, ForgetReason, ResticDumpStream, ResticForgetResponse, Snapshot } from "./restic/index"; diff --git a/app/server/utils/restic/index.ts b/app/server/utils/restic/index.ts deleted file mode 100644 index 8dddb08c..00000000 --- a/app/server/utils/restic/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { backup } from "./commands/backup"; -import { check } from "./commands/check"; -import { copy } from "./commands/copy"; -import { deleteSnapshot, deleteSnapshots } from "./commands/delete-snapshots"; -import { dump } from "./commands/dump"; -import { forget } from "./commands/forget"; -import { init } from "./commands/init"; -import { keyAdd } from "./commands/key-add"; -import { ls } from "./commands/ls"; -import { repairIndex } from "./commands/repair-index"; -import { restore } from "./commands/restore"; -import { snapshots } from "./commands/snapshots"; -import { stats } from "./commands/stats"; -import { tagSnapshots } from "./commands/tag-snapshots"; -import { unlock } from "./commands/unlock"; - -export { addCommonArgs } from "./helpers/add-common-args"; -export { buildEnv } from "./helpers/build-env"; -export { buildRepoUrl } from "./helpers/build-repo-url"; -export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys"; - -export type { RestoreProgress } from "./commands/restore"; -export type { ForgetGroup, ForgetReason, ResticDumpStream, ResticForgetResponse, Snapshot } from "./types"; - -export const restic = { - init, - keyAdd, - backup, - restore, - dump, - snapshots, - stats, - forget, - deleteSnapshot, - deleteSnapshots, - tagSnapshots, - unlock, - ls, - check, - repairIndex, - copy, -}; diff --git a/app/server/utils/retention-categories.ts b/app/server/utils/retention-categories.ts index 755d3fd6..75457a40 100644 --- a/app/server/utils/retention-categories.ts +++ b/app/server/utils/retention-categories.ts @@ -1,4 +1,4 @@ -import type { ResticForgetResponse } from "./restic"; +import type { ResticForgetResponse } from "@zerobyte/core/restic"; export type RetentionCategory = "last" | "hourly" | "daily" | "weekly" | "monthly" | "yearly"; diff --git a/app/server/utils/shoutrrr.ts b/app/server/utils/shoutrrr.ts index 5bb22d2e..9a774fe2 100644 --- a/app/server/utils/shoutrrr.ts +++ b/app/server/utils/shoutrrr.ts @@ -1,5 +1,5 @@ -import { safeExec } from "./spawn"; -import { logger } from "./logger"; +import { safeExec } from "@zerobyte/core/node"; +import { logger } from "@zerobyte/core/node"; import { toMessage } from "./errors"; export interface SendNotificationParams { diff --git a/app/test/setup.ts b/app/test/setup.ts index 85ec2b11..0ae49a38 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -4,14 +4,19 @@ import path from "node:path"; import { cwd } from "node:process"; import { db } from "~/server/db/db"; -void mock.module("~/server/utils/logger", () => ({ - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, -})); +import * as utils from "@zerobyte/core/node"; + +void mock.module("@zerobyte/core/node", () => { + return { + ...utils, + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }; +}); void mock.module("~/server/utils/crypto", () => ({ cryptoUtils: { diff --git a/app/utils/__tests__/path.test.ts b/app/utils/__tests__/path.test.ts index 89d7d681..4035c7e6 100644 --- a/app/utils/__tests__/path.test.ts +++ b/app/utils/__tests__/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { normalizeAbsolutePath } from "../path"; +import { normalizeAbsolutePath } from "@zerobyte/core/utils"; describe("normalizeAbsolutePath", () => { test("handles undefined and empty inputs", () => { diff --git a/bun.lock b/bun.lock index ee2bcb21..3668dc2d 100644 --- a/bun.lock +++ b/bun.lock @@ -100,6 +100,7 @@ "oxlint-tsgolint": "^0.16.0", "tailwindcss": "^4.2.1", "tinyglobby": "^0.2.15", + "turbo": "^2.8.16", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "vite": "^7.3.1", @@ -108,6 +109,15 @@ "wait-for-expect": "^4.0.0", }, }, + "packages/core": { + "name": "@zerobyte/core", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, }, "overrides": { "esbuild": "^0.27.2", @@ -957,6 +967,8 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "@zerobyte/core": ["@zerobyte/core@workspace:packages/core"], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -1797,6 +1809,20 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "turbo": ["turbo@2.8.16", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.16", "turbo-darwin-arm64": "2.8.16", "turbo-linux-64": "2.8.16", "turbo-linux-arm64": "2.8.16", "turbo-windows-64": "2.8.16", "turbo-windows-arm64": "2.8.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-u6e9e3cTTpE2adQ1DYm3A3r8y3LAONEx1jYvJx6eIgSY4bMLxIxs0riWzI0Z/IK903ikiUzRPZ2c1Ph5lVLkhA=="], + + "turbo-darwin-64": ["turbo-darwin-64@2.8.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWa4hUMWrpADC6Q/wIHRkBLw6X6MV9nx6X7hSXbTrrMz0KdaKhmfudUZ3sS76bJFmgArBU25cSc0AUyyrswYxg=="], + + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NBgaqBDLQSZlJR4D5XCkQq6noaO0RvIgwm5eYFJYL3bH5dNu8o0UBpq7C5DYnQI8+ybyoHFjT5/icN4LeUYLow=="], + + "turbo-linux-64": ["turbo-linux-64@2.8.16", "", { "os": "linux", "cpu": "x64" }, "sha512-VYPdcCRevI9kR/hr1H1xwXy7QQt/jNKiim1e1mjANBXD2E9VZWMkIL74J1Huad5MbU3/jw7voHOqDPLJPC2p6w=="], + + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-beq8tgUVI3uwkQkXJMiOr/hfxQRw54M3elpBwqgYFfemiK5LhCjjcwO0DkE8GZZfElBIlk+saMAQOZy3885wNQ=="], + + "turbo-windows-64": ["turbo-windows-64@2.8.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Ig7b46iUgiOIkea/D3Z7H+zNzvzSnIJcLYFpZLA0RxbUTrbLhv9qIPwv3pT9p/abmu0LXVKHxaOo+p26SuDhzw=="], + + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-fOWjbEA2PiE2HEnFQrwNZKYEdjewyPc2no9GmrXklZnTCuMsxeCN39aVlKpKpim03Zq/ykIuvApGwq8ZbfS2Yw=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], diff --git a/package.json b/package.json index 49558919..38d9b1ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,10 @@ { "name": "zerobyte", "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], "type": "module", "scripts": { "lint": "oxlint --type-aware", @@ -10,7 +14,7 @@ "preview": "bunx --bun vite preview", "cli:dev": "bun run app/server/cli/main.ts", "cli": "ZEROBYTE_CLI=1 bun .output/server/_ssr/index.mjs", - "tsc": "tsc --noEmit", + "tsc": "tsc --noEmit && turbo run tsc", "start:dev": "docker compose down && docker compose up --build zerobyte-dev", "start:prod": "docker compose down && docker compose up --build zerobyte-prod", "start:e2e": "docker compose down && rm -rf playwright/data/* playwright/.auth/user.json playwright/restic.pass && mkdir -p playwright/temp && rm -rf playwright/temp/* && docker compose up --build zerobyte-e2e", @@ -20,7 +24,7 @@ "studio": "drizzle-kit studio", "test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts", "test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts", - "test": "bun run test:server && bun run test:client", + "test": "bun run test:server && bun run test:client && turbo run test", "test:e2e": "NODE_ENV=test dotenv -e .env.local -- playwright test", "test:e2e:ui": "NODE_ENV=test dotenv -e .env.local -- playwright test --ui", "test:codegen": "playwright codegen localhost:4096" @@ -55,6 +59,7 @@ "@tanstack/react-router": "^1.166.7", "@tanstack/react-router-ssr-query": "^1.166.7", "@tanstack/react-start": "^1.166.7", + "@zerobyte/core": "workspace:*", "better-auth": "^1.5.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -121,6 +126,7 @@ "oxlint-tsgolint": "^0.16.0", "tailwindcss": "^4.2.1", "tinyglobby": "^0.2.15", + "turbo": "^2.8.16", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "vite": "^7.3.1", diff --git a/packages/core/.gitignore b/packages/core/.gitignore new file mode 100644 index 00000000..a14702c4 --- /dev/null +++ b/packages/core/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..418ea653 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,15 @@ +# core + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.10. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/packages/core/bun.lock b/packages/core/bun.lock new file mode 100644 index 00000000..91dea17c --- /dev/null +++ b/packages/core/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "core", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + + "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..61319536 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,37 @@ +{ + "name": "@zerobyte/core", + "private": true, + "type": "module", + "exports": { + "./restic": { + "types": "./src/restic/index.ts", + "import": "./src/restic/index.ts", + "default": "./src/restic/index.ts" + }, + "./restic/server": { + "types": "./src/restic/server.ts", + "import": "./src/restic/server.ts", + "default": "./src/restic/server.ts" + }, + "./utils": { + "types": "./src/utils/index.ts", + "import": "./src/utils/index.ts", + "default": "./src/utils/index.ts" + }, + "./node": { + "types": "./src/node/index.ts", + "import": "./src/node/index.ts", + "default": "./src/node/index.ts" + } + }, + "scripts": { + "tsc": "tsc --noEmit", + "test": "bun test --preload ./test/setup.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/packages/core/src/node/index.ts b/packages/core/src/node/index.ts new file mode 100644 index 00000000..baf2b89c --- /dev/null +++ b/packages/core/src/node/index.ts @@ -0,0 +1,4 @@ +export { safeSpawn, safeExec } from "../utils/spawn.js"; +export type { SafeSpawnParams, SafeSpawnParamsLines, SafeSpawnParamsRaw, SpawnResult } from "../utils/spawn.js"; +export { logger } from "../utils/logger.js"; +export { sanitizeSensitiveData } from "../utils/sanitize.js"; diff --git a/app/server/utils/restic/commands/__tests__/backup.test.ts b/packages/core/src/restic/commands/__tests__/backup.test.ts similarity index 78% rename from app/server/utils/restic/commands/__tests__/backup.test.ts rename to packages/core/src/restic/commands/__tests__/backup.test.ts index c0f733e5..a4ba073f 100644 --- a/app/server/utils/restic/commands/__tests__/backup.test.ts +++ b/packages/core/src/restic/commands/__tests__/backup.test.ts @@ -1,8 +1,19 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; -import * as cleanupModule from "~/server/utils/restic/helpers/cleanup-temporary-keys"; -import * as spawnModule from "~/server/utils/spawn"; -import { ResticError } from "~/server/utils/errors"; +import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; +import * as spawnModule from "../../../utils/spawn"; +import { ResticError } from "../../error"; import { backup } from "../backup"; +import type { ResticDeps } from "../../types"; +import type { SafeSpawnParams, SpawnResult } from "../../../utils/spawn"; + +const mockDeps: ResticDeps = { + resolveSecret: async (s) => s, + getOrganizationResticPassword: async () => "org-restic-password", + resticCacheDir: "/tmp/restic-cache", + resticPassFile: "/tmp/restic.pass", + defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"], + hostname: "zerobyte", +}; const VALID_SUMMARY = JSON.stringify({ message_type: "summary", @@ -39,8 +50,8 @@ const config = { }; type SetupOptions = { - spawnResult?: Partial; - onSpawnCall?: (params: spawnModule.SafeSpawnParams) => void; + spawnResult?: Partial; + onSpawnCall?: (params: SafeSpawnParams) => void; }; /** @@ -81,7 +92,7 @@ describe("backup command", () => { describe("argument construction", () => { test("passes source path as positional arg when no include list is given", async () => { const { getArgs, hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1" }); + await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(getArgs()).toContain("/mnt/data"); expect(hasFlag("--files-from")).toBe(false); @@ -89,10 +100,15 @@ describe("backup command", () => { test("uses --files-from instead of source path when include list is provided", async () => { const { hasFlag, getArgs } = setup(); - await backup(config, "/mnt/data", { - organizationId: "org-1", - include: ["/mnt/data/docs", "/mnt/data/photos"], - }); + await backup( + config, + "/mnt/data", + { + organizationId: "org-1", + include: ["/mnt/data/docs", "/mnt/data/photos"], + }, + mockDeps, + ); expect(hasFlag("--files-from")).toBe(true); expect(getArgs()).not.toContain("/mnt/data"); @@ -100,86 +116,101 @@ describe("backup command", () => { test("adds --tag for each entry in options.tags", async () => { const { getOptionValues } = setup(); - await backup(config, "/mnt/data", { - organizationId: "org-1", - tags: ["tag-a", "tag-b"], - }); + await backup( + config, + "/mnt/data", + { + organizationId: "org-1", + tags: ["tag-a", "tag-b"], + }, + mockDeps, + ); expect(getOptionValues("--tag")).toEqual(["tag-a", "tag-b"]); }); test("omits --tag when tags list is empty", async () => { const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", tags: [] }); + await backup(config, "/mnt/data", { organizationId: "org-1", tags: [] }, mockDeps); expect(hasFlag("--tag")).toBe(false); }); test("passes provided compressionMode to --compression", async () => { const { getOptionValues } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", compressionMode: "max" }); + await backup(config, "/mnt/data", { organizationId: "org-1", compressionMode: "max" }, mockDeps); expect(getOptionValues("--compression")).toEqual(["max"]); }); test("defaults --compression to auto when compressionMode is omitted", async () => { const { getOptionValues } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1" }); + await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(getOptionValues("--compression")).toEqual(["auto"]); }); test("adds --one-file-system when oneFileSystem is true", async () => { const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: true }); + await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: true }, mockDeps); expect(hasFlag("--one-file-system")).toBe(true); }); test("omits --one-file-system when oneFileSystem is false", async () => { const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: false }); + await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: false }, mockDeps); expect(hasFlag("--one-file-system")).toBe(false); }); test("adds --exclude-file when exclude list is provided", async () => { const { hasFlag } = setup(); - await backup(config, "/mnt/data", { - organizationId: "org-1", - exclude: ["/mnt/data/tmp", "/mnt/data/cache"], - }); + await backup( + config, + "/mnt/data", + { + organizationId: "org-1", + exclude: ["/mnt/data/tmp", "/mnt/data/cache"], + }, + mockDeps, + ); expect(hasFlag("--exclude-file")).toBe(true); }); test("omits --exclude-file when exclude list is empty", async () => { const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", exclude: [] }); + await backup(config, "/mnt/data", { organizationId: "org-1", exclude: [] }, mockDeps); expect(hasFlag("--exclude-file")).toBe(false); }); test("adds --exclude-if-present for each entry in excludeIfPresent", async () => { const { getOptionValues } = setup(); - await backup(config, "/mnt/data", { - organizationId: "org-1", - excludeIfPresent: [".nobackup", ".gitignore"], - }); + await backup( + config, + "/mnt/data", + { + organizationId: "org-1", + excludeIfPresent: [".nobackup", ".gitignore"], + }, + mockDeps, + ); expect(getOptionValues("--exclude-if-present")).toEqual([".nobackup", ".gitignore"]); }); test("always includes DEFAULT_EXCLUDES as --exclude args", async () => { const { getOptionValues } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1" }); + await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(getOptionValues("--exclude").length).toBeGreaterThan(0); }); test("includes --host arg from config", async () => { const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1" }); + await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(hasFlag("--host")).toBe(true); }); @@ -188,7 +219,7 @@ describe("backup command", () => { describe("exit code handling", () => { test("returns parsed result on exit code 0", async () => { setup(); - const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(exitCode).toBe(0); expect(result?.snapshot_id).toBe("abcd1234"); @@ -196,7 +227,7 @@ describe("backup command", () => { test("returns result without throwing on exit code 3 (partial read errors)", async () => { setup({ spawnResult: { exitCode: 3 } }); - const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(exitCode).toBe(3); expect(result).not.toBeNull(); @@ -205,13 +236,15 @@ describe("backup command", () => { test("throws ResticError on non-zero, non-3 exit codes", async () => { setup({ spawnResult: { exitCode: 1, summary: "", error: "fatal error" } }); - await expect(backup(config, "/mnt/data", { organizationId: "org-1" })).rejects.toBeInstanceOf(ResticError); + await expect(backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps)).rejects.toBeInstanceOf( + ResticError, + ); }); test("preserves the exit code inside the thrown ResticError", async () => { setup({ spawnResult: { exitCode: 12, summary: "", error: "wrong password" } }); - const error = await backup(config, "/mnt/data", { organizationId: "org-1" }).catch((e) => e); + const error = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps).catch((e) => e); expect(error).toBeInstanceOf(ResticError); expect((error as ResticError).code).toBe(12); }); @@ -223,10 +256,15 @@ describe("backup command", () => { spawnResult: { exitCode: 130, summary: "", error: "" }, }); - const { result, exitCode } = await backup(config, "/mnt/data", { - organizationId: "org-1", - signal: controller.signal, - }); + const { result, exitCode } = await backup( + config, + "/mnt/data", + { + organizationId: "org-1", + signal: controller.signal, + }, + mockDeps, + ); expect(result).toBeNull(); expect(exitCode).toBe(130); @@ -236,7 +274,7 @@ describe("backup command", () => { describe("output parsing", () => { test("returns a fully parsed summary object on valid output", async () => { setup(); - const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(result).toMatchObject({ message_type: "summary", @@ -247,14 +285,14 @@ describe("backup command", () => { test("returns { result: null } when summary line is not valid JSON", async () => { setup({ spawnResult: { summary: "not-json" } }); - const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(result).toBeNull(); }); test("returns { result: null } when summary JSON does not satisfy the schema", async () => { setup({ spawnResult: { summary: JSON.stringify({ message_type: "summary" }) } }); - const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(result).toBeNull(); }); @@ -265,10 +303,15 @@ describe("backup command", () => { const progressUpdates: unknown[] = []; setup({ onSpawnCall: (params) => params.onStdout?.(VALID_PROGRESS_LINE) }); - await backup(config, "/mnt/data", { - organizationId: "org-1", - onProgress: (p) => progressUpdates.push(p), - }); + await backup( + config, + "/mnt/data", + { + organizationId: "org-1", + onProgress: (p) => progressUpdates.push(p), + }, + mockDeps, + ); expect(progressUpdates.length).toBeGreaterThan(0); expect(progressUpdates[0]).toMatchObject({ @@ -287,7 +330,7 @@ describe("backup command", () => { }); await expect( - backup(config, "/mnt/data", { organizationId: "org-1", onProgress: () => {} }), + backup(config, "/mnt/data", { organizationId: "org-1", onProgress: () => {} }, mockDeps), ).resolves.toBeDefined(); }); @@ -297,10 +340,15 @@ describe("backup command", () => { onSpawnCall: (params) => params.onStdout?.(JSON.stringify({ message_type: "verbose_status", action: "scan" })), }); - await backup(config, "/mnt/data", { - organizationId: "org-1", - onProgress: (p) => progressUpdates.push(p), - }); + await backup( + config, + "/mnt/data", + { + organizationId: "org-1", + onProgress: (p) => progressUpdates.push(p), + }, + mockDeps, + ); expect(progressUpdates).toHaveLength(0); }); @@ -309,14 +357,14 @@ describe("backup command", () => { describe("cleanup", () => { test("calls cleanupTemporaryKeys after a successful backup", async () => { const { cleanupSpy } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1" }); + await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(cleanupSpy).toHaveBeenCalledTimes(1); }); test("calls cleanupTemporaryKeys even when the command fails", async () => { const { cleanupSpy } = setup({ spawnResult: { exitCode: 1, summary: "", error: "fail" } }); - await backup(config, "/mnt/data", { organizationId: "org-1" }).catch(() => {}); + await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps).catch(() => {}); expect(cleanupSpy).toHaveBeenCalledTimes(1); }); diff --git a/app/server/utils/restic/commands/__tests__/restore.test.ts b/packages/core/src/restic/commands/__tests__/restore.test.ts similarity index 69% rename from app/server/utils/restic/commands/__tests__/restore.test.ts rename to packages/core/src/restic/commands/__tests__/restore.test.ts index a4167649..3908c468 100644 --- a/app/server/utils/restic/commands/__tests__/restore.test.ts +++ b/packages/core/src/restic/commands/__tests__/restore.test.ts @@ -1,6 +1,16 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; -import * as spawnModule from "~/server/utils/spawn"; +import * as spawnModule from "../../../utils/spawn"; import { restore } from "../restore"; +import type { ResticDeps } from "../../types"; +import type { SafeSpawnParams } from "../../../utils/spawn"; + +const mockDeps: ResticDeps = { + resolveSecret: async (s) => s, + getOrganizationResticPassword: async () => "org-restic-password", + resticCacheDir: "/tmp/restic-cache", + resticPassFile: "/tmp/restic.pass", + defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"], +}; const successfulRestoreSummary = JSON.stringify({ message_type: "summary", @@ -23,7 +33,7 @@ const config = { const setup = () => { let capturedArgs: string[] = []; - spyOn(spawnModule, "safeSpawn").mockImplementation((params) => { + spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => { capturedArgs = params.args; return Promise.resolve({ exitCode: 0, summary: successfulRestoreSummary, error: "" }); }); @@ -60,13 +70,19 @@ afterEach(() => { describe("restore command", () => { test("keeps snapshot restore arg and absolute include paths when target is root", async () => { const { getRestoreArg, getOptionValues } = setup(); - await restore(config, "snapshot-123", "/", { - organizationId: "org-1", - include: [ - "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", - "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", - ], - }); + await restore( + config, + "snapshot-123", + "/", + { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }, + mockDeps, + ); expect(getRestoreArg()).toBe("snapshot-123"); expect(getOptionValues("--include")).toEqual([ @@ -77,13 +93,19 @@ describe("restore command", () => { test("restores from common ancestor and strips include paths for non-root targets", async () => { const { getRestoreArg, getOptionValues } = setup(); - await restore(config, "snapshot-456", "/tmp/restore-target", { - organizationId: "org-1", - include: [ - "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", - "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", - ], - }); + await restore( + config, + "snapshot-456", + "/tmp/restore-target", + { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }, + mockDeps, + ); expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]); @@ -91,10 +113,16 @@ describe("restore command", () => { test("uses base path for non-root restore when includes are omitted", async () => { const { getRestoreArg, getOptionValues } = setup(); - await restore(config, "snapshot-789", "/tmp/restore-target", { - organizationId: "org-1", - basePath: "/var/lib/zerobyte/volumes/vol123/_data", - }); + await restore( + config, + "snapshot-789", + "/tmp/restore-target", + { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + }, + mockDeps, + ); expect(getRestoreArg()).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data"); expect(getOptionValues("--include")).toEqual([]); @@ -108,7 +136,7 @@ describe("restore command", () => { selectedItemKind: "file", }; - await restore(config, "snapshot-single-file", "/tmp/restore-target", options); + await restore(config, "snapshot-single-file", "/tmp/restore-target", options, mockDeps); expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive"); expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]); @@ -116,11 +144,17 @@ describe("restore command", () => { test("does not pass an empty include when include equals restore root", async () => { const { getArgs, getRestoreArg, getOptionValues } = setup(); - await restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", { - organizationId: "org-1", - include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"], - overwrite: "always", - }); + await restore( + config, + "snapshot-7202d8cc", + "/Users/nicolas/Documents/restore", + { + organizationId: "org-1", + include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"], + overwrite: "always", + }, + mockDeps, + ); expect(getRestoreArg()).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"); expect(getOptionValues("--include")).toEqual([]); diff --git a/app/server/utils/restic/commands/backup.ts b/packages/core/src/restic/commands/backup.ts similarity index 86% rename from app/server/utils/restic/commands/backup.ts rename to packages/core/src/restic/commands/backup.ts index 89d92405..4465938b 100644 --- a/app/server/utils/restic/commands/backup.ts +++ b/packages/core/src/restic/commands/backup.ts @@ -2,22 +2,16 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { throttle } from "es-toolkit"; -import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; -import { - type ResticBackupProgressDto, - resticBackupOutputSchema, - resticBackupProgressSchema, -} from "~/schemas/restic-dto"; -import { DEFAULT_EXCLUDES } from "~/server/core/constants"; -import { ResticError } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; -import { safeSpawn } from "~/server/utils/spawn"; -import { config as appConfig } from "~/server/core/config"; +import type { CompressionMode, RepositoryConfig } from "../schemas"; +import { type ResticBackupProgressDto, resticBackupOutputSchema, resticBackupProgressSchema } from "../restic-dto"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import { validateCustomResticParams } from "../helpers/validate-custom-params"; +import { ResticError } from "../error"; +import { logger, safeSpawn } from "../../node"; +import type { ResticDeps } from "../types"; export const backup = async ( config: RepositoryConfig, @@ -34,9 +28,10 @@ export const backup = async ( onProgress?: (progress: ResticBackupProgressDto) => void; customResticParams?: string[]; }, + deps: ResticDeps, ) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); + const env = await buildEnv(config, options.organizationId, deps); const args: string[] = ["--repo", repoUrl, "backup", "--compression", options.compressionMode ?? "auto"]; @@ -44,8 +39,8 @@ export const backup = async ( args.push("--one-file-system"); } - if (appConfig.resticHostname) { - args.push("--host", appConfig.resticHostname); + if (deps.hostname) { + args.push("--host", deps.hostname); } if (options.tags && options.tags.length > 0) { @@ -66,7 +61,7 @@ export const backup = async ( args.push(source); } - for (const exclude of DEFAULT_EXCLUDES) { + for (const exclude of deps.defaultExcludes) { args.push("--exclude", exclude); } @@ -143,7 +138,7 @@ export const backup = async ( if (excludeFile) { await fs.unlink(excludeFile).catch(() => {}); } - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (options.signal?.aborted) { logger.warn("Restic backup was aborted by signal."); diff --git a/app/server/utils/restic/commands/check.ts b/packages/core/src/restic/commands/check.ts similarity index 83% rename from app/server/utils/restic/commands/check.ts rename to packages/core/src/restic/commands/check.ts index 2576b9fa..36280a1c 100644 --- a/app/server/utils/restic/commands/check.ts +++ b/packages/core/src/restic/commands/check.ts @@ -1,10 +1,10 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; +import { logger, safeExec } from "../../node"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import type { ResticDeps } from "../types"; export const check = async ( config: RepositoryConfig, @@ -13,9 +13,10 @@ export const check = async ( signal?: AbortSignal; organizationId: string; }, + deps: ResticDeps, ) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); + const env = await buildEnv(config, options.organizationId, deps); const args: string[] = ["--repo", repoUrl, "check"]; @@ -31,7 +32,7 @@ export const check = async ( env, signal: options.signal, }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (options.signal?.aborted) { logger.warn("Restic check was aborted by signal."); diff --git a/app/server/utils/restic/commands/copy.ts b/packages/core/src/restic/commands/copy.ts similarity index 83% rename from app/server/utils/restic/commands/copy.ts rename to packages/core/src/restic/commands/copy.ts index 97d90740..1f793eec 100644 --- a/app/server/utils/restic/commands/copy.ts +++ b/packages/core/src/restic/commands/copy.ts @@ -1,28 +1,29 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { ResticError } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; import { formatBandwidthLimit } from "../helpers/bandwidth"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import { ResticError } from "../error"; +import { logger, safeExec } from "../../node"; +import type { ResticDeps } from "../types"; export const copy = async ( sourceConfig: RepositoryConfig, destConfig: RepositoryConfig, options: { organizationId: string; tag?: string; snapshotId?: string }, + deps: ResticDeps, ) => { const sourceRepoUrl = buildRepoUrl(sourceConfig); const destRepoUrl = buildRepoUrl(destConfig); - const sourceEnv = await buildEnv(sourceConfig, options.organizationId); - const destEnv = await buildEnv(destConfig, options.organizationId); + const sourceEnv = await buildEnv(sourceConfig, options.organizationId, deps); + const destEnv = await buildEnv(destConfig, options.organizationId, deps); const env: Record = { ...sourceEnv, ...destEnv, - RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE, + RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE!, }; const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl]; @@ -55,8 +56,8 @@ export const copy = async ( const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(sourceEnv); - await cleanupTemporaryKeys(destEnv); + await cleanupTemporaryKeys(sourceEnv, deps); + await cleanupTemporaryKeys(destEnv, deps); const { stdout, stderr } = res; diff --git a/app/server/utils/restic/commands/delete-snapshots.ts b/packages/core/src/restic/commands/delete-snapshots.ts similarity index 54% rename from app/server/utils/restic/commands/delete-snapshots.ts rename to packages/core/src/restic/commands/delete-snapshots.ts index 856df691..5368391b 100644 --- a/app/server/utils/restic/commands/delete-snapshots.ts +++ b/packages/core/src/restic/commands/delete-snapshots.ts @@ -1,15 +1,20 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { ResticError } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; +import { logger, safeExec } from "../../node"; +import { ResticError } from "../error"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import type { ResticDeps } from "../types"; -export const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[], organizationId: string) => { +export const deleteSnapshots = async ( + config: RepositoryConfig, + snapshotIds: string[], + organizationId: string, + deps: ResticDeps, +) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId); + const env = await buildEnv(config, organizationId, deps); if (snapshotIds.length === 0) { throw new Error("No snapshot IDs provided for deletion."); @@ -19,7 +24,7 @@ export const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: str addCommonArgs(args, env, config); const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic snapshot deletion failed: ${res.stderr}`); @@ -29,6 +34,11 @@ export const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: str return { success: true }; }; -export const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string, organizationId: string) => { - return deleteSnapshots(config, [snapshotId], organizationId); +export const deleteSnapshot = async ( + config: RepositoryConfig, + snapshotId: string, + organizationId: string, + deps: ResticDeps, +) => { + return deleteSnapshots(config, [snapshotId], organizationId, deps); }; diff --git a/app/server/utils/restic/commands/dump.ts b/packages/core/src/restic/commands/dump.ts similarity index 85% rename from app/server/utils/restic/commands/dump.ts rename to packages/core/src/restic/commands/dump.ts index 7b3b71b5..f4ac9b6a 100644 --- a/app/server/utils/restic/commands/dump.ts +++ b/packages/core/src/restic/commands/dump.ts @@ -1,14 +1,13 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { normalizeAbsolutePath } from "~/utils/path"; +import { normalizeAbsolutePath } from "../../utils/path"; import type { Readable } from "node:stream"; -import { logger } from "~/server/utils/logger"; -import { ResticError } from "~/server/utils/errors"; -import { safeSpawn } from "~/server/utils/spawn"; -import type { ResticDumpStream } from "../types"; +import type { ResticDeps, ResticDumpStream } from "../types"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import { logger, safeSpawn } from "../../node"; +import { ResticError } from "../error"; const normalizeDumpPath = (pathToDump?: string): string => { const trimmedPath = pathToDump?.trim(); @@ -27,9 +26,10 @@ export const dump = async ( path?: string; archive?: false; }, + deps: ResticDeps, ): Promise => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); + const env = await buildEnv(config, options.organizationId, deps); const pathToDump = normalizeDumpPath(options.path); const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump]; @@ -49,7 +49,7 @@ export const dump = async ( } didCleanup = true; - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); }; let stream: Readable | null = null; diff --git a/app/server/utils/restic/commands/forget.ts b/packages/core/src/restic/commands/forget.ts similarity index 78% rename from app/server/utils/restic/commands/forget.ts rename to packages/core/src/restic/commands/forget.ts index a2bc98e2..c907c7f6 100644 --- a/app/server/utils/restic/commands/forget.ts +++ b/packages/core/src/restic/commands/forget.ts @@ -1,22 +1,21 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import type { RetentionPolicy } from "~/server/modules/backups/backups.dto"; -import { ResticError } from "~/server/utils/errors"; -import { safeJsonParse } from "~/server/utils/json"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; -import type { ResticForgetResponse } from "../types"; +import type { ResticDeps, RetentionPolicy, ResticForgetResponse } from "../types"; +import { safeJsonParse } from "../../utils/json"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import { logger, safeExec } from "../../node"; +import { ResticError } from "../error"; export const forget = async ( config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string; organizationId: string; dryRun?: boolean }, + deps: ResticDeps, ) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, extra.organizationId); + const env = await buildEnv(config, extra.organizationId, deps); const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; @@ -53,7 +52,7 @@ export const forget = async ( addCommonArgs(args, env, config); const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic forget failed: ${res.stderr}`); diff --git a/app/server/utils/restic/commands/init.ts b/packages/core/src/restic/commands/init.ts similarity index 55% rename from app/server/utils/restic/commands/init.ts rename to packages/core/src/restic/commands/init.ts index ea89683d..bfe82665 100644 --- a/app/server/utils/restic/commands/init.ts +++ b/packages/core/src/restic/commands/init.ts @@ -1,19 +1,28 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { config as appConfig } from "~/server/core/config"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import { keyAdd } from "./key-add"; +import type { RepositoryConfig } from "../schemas"; +import { logger, safeExec } from "../../node"; +import type { ResticDeps } from "../types"; -const addDefaultKey = async (config: RepositoryConfig, organizationId: string, options?: { timeoutMs?: number }) => { - if (appConfig.resticHostname) { - const keyResult = await keyAdd(config, organizationId, { - host: appConfig.resticHostname, - timeoutMs: options?.timeoutMs, - }); +const addDefaultKey = async ( + config: RepositoryConfig, + organizationId: string, + options: { timeoutMs?: number }, + deps: ResticDeps, +) => { + if (deps?.hostname) { + const keyResult = await keyAdd( + config, + organizationId, + { + host: deps.hostname, + timeoutMs: options?.timeoutMs, + }, + deps, + ); if (!keyResult.success) { logger.warn(`Repository initialized but failed to add key with hostname: ${keyResult.error}`); @@ -21,18 +30,23 @@ const addDefaultKey = async (config: RepositoryConfig, organizationId: string, o } }; -export const init = async (config: RepositoryConfig, organizationId: string, options?: { timeoutMs?: number }) => { +export const init = async ( + config: RepositoryConfig, + organizationId: string, + options: { timeoutMs?: number } | undefined, + deps: ResticDeps, +) => { const repoUrl = buildRepoUrl(config); logger.info(`Initializing restic repository at ${repoUrl}...`); - const env = await buildEnv(config, organizationId); + const env = await buildEnv(config, organizationId, deps); const args = ["init", "--repo", repoUrl]; addCommonArgs(args, env, config); const res = await safeExec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 60000 }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic init failed: ${res.stderr}`); @@ -41,7 +55,14 @@ export const init = async (config: RepositoryConfig, organizationId: string, opt logger.info(`Restic repository initialized: ${repoUrl}`); - void addDefaultKey(config, organizationId, { timeoutMs: options?.timeoutMs }); + void addDefaultKey( + config, + organizationId, + { + timeoutMs: options?.timeoutMs, + }, + deps, + ); return { success: true, error: null }; }; diff --git a/app/server/utils/restic/commands/key-add.ts b/packages/core/src/restic/commands/key-add.ts similarity index 78% rename from app/server/utils/restic/commands/key-add.ts rename to packages/core/src/restic/commands/key-add.ts index b5056665..81be7016 100644 --- a/app/server/utils/restic/commands/key-add.ts +++ b/packages/core/src/restic/commands/key-add.ts @@ -1,21 +1,22 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; +import { logger, safeExec } from "../../node"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import type { ResticDeps } from "../types"; export const keyAdd = async ( config: RepositoryConfig, organizationId: string, options: { host: string; timeoutMs?: number }, + deps: ResticDeps, ) => { const repoUrl = buildRepoUrl(config); logger.info(`Adding restic key with host "${options.host}" for repository at ${repoUrl}...`); - const env = await buildEnv(config, organizationId); + const env = await buildEnv(config, organizationId, deps); const args = [ "key", @@ -26,11 +27,12 @@ export const keyAdd = async ( options.host, "--new-password-file", env.RESTIC_PASSWORD_FILE, - ]; + ].filter((e) => e !== undefined); + addCommonArgs(args, env, config); const res = await safeExec({ command: "restic", args, env, timeout: options.timeoutMs ?? 60000 }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic key add failed: ${res.stderr}`); diff --git a/app/server/utils/restic/commands/ls.ts b/packages/core/src/restic/commands/ls.ts similarity index 88% rename from app/server/utils/restic/commands/ls.ts rename to packages/core/src/restic/commands/ls.ts index 3493b802..5d8860a6 100644 --- a/app/server/utils/restic/commands/ls.ts +++ b/packages/core/src/restic/commands/ls.ts @@ -1,12 +1,12 @@ import { z } from "zod"; -import type { RepositoryConfig } from "~/schemas/restic"; -import { ResticError } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; -import { safeSpawn } from "~/server/utils/spawn"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import { logger, safeSpawn } from "../../node"; +import { ResticError } from "../error"; +import type { ResticDeps } from "../types"; const lsNodeSchema = z.object({ name: z.string(), @@ -53,11 +53,12 @@ export const ls = async ( config: RepositoryConfig, snapshotId: string, organizationId: string, - path?: string, - options?: { offset?: number; limit?: number }, + path: string | undefined, + options: { offset?: number; limit?: number } | undefined, + deps: ResticDeps, ): Promise => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId); + const env = await buildEnv(config, organizationId, deps); const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"]; @@ -118,7 +119,7 @@ export const ls = async ( }, }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic ls failed: ${res.error}`); diff --git a/app/server/utils/restic/commands/repair-index.ts b/packages/core/src/restic/commands/repair-index.ts similarity index 78% rename from app/server/utils/restic/commands/repair-index.ts rename to packages/core/src/restic/commands/repair-index.ts index 6dbd2ad4..cdefea0e 100644 --- a/app/server/utils/restic/commands/repair-index.ts +++ b/packages/core/src/restic/commands/repair-index.ts @@ -1,18 +1,19 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { ResticError } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; +import { logger, safeExec } from "../../node"; +import { ResticError } from "../error"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import type { ResticDeps } from "../types"; export const repairIndex = async ( config: RepositoryConfig, options: { signal?: AbortSignal; organizationId: string }, + deps: ResticDeps, ) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); + const env = await buildEnv(config, options.organizationId, deps); const args = ["repair", "index", "--repo", repoUrl]; addCommonArgs(args, env, config); @@ -23,7 +24,7 @@ export const repairIndex = async ( env, signal: options.signal, }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (options.signal?.aborted) { logger.warn("Restic repair index was aborted by signal."); diff --git a/app/server/utils/restic/commands/restore.ts b/packages/core/src/restic/commands/restore.ts similarity index 89% rename from app/server/utils/restic/commands/restore.ts rename to packages/core/src/restic/commands/restore.ts index 16d5d16c..9539122a 100644 --- a/app/server/utils/restic/commands/restore.ts +++ b/packages/core/src/restic/commands/restore.ts @@ -1,17 +1,16 @@ import path from "node:path"; import { z } from "zod"; import { throttle } from "es-toolkit"; -import type { OverwriteMode, RepositoryConfig } from "~/schemas/restic"; -import type { ResticRestoreOutputDto } from "~/schemas/restic-dto"; -import { resticRestoreOutputSchema } from "~/schemas/restic-dto"; -import { findCommonAncestor } from "~/utils/common-ancestor"; -import { ResticError } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; -import { safeSpawn } from "~/server/utils/spawn"; +import { findCommonAncestor } from "../../utils/common-ancestor"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import { type RepositoryConfig, type OverwriteMode } from "../schemas"; +import { logger, safeSpawn } from "../../node"; +import { ResticError } from "../error"; +import { resticRestoreOutputSchema, type ResticRestoreOutputDto } from "../restic-dto"; +import type { ResticDeps } from "../types"; const restoreProgressSchema = z.object({ message_type: z.enum(["status", "summary"]), @@ -41,9 +40,10 @@ export const restore = async ( onProgress?: (progress: RestoreProgress) => void; signal?: AbortSignal; }, + deps: ResticDeps, ) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); + const env = await buildEnv(config, options.organizationId, deps); let restoreArg = snapshotId; @@ -129,7 +129,7 @@ export const restore = async ( }, }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic restore failed: ${res.error}`); diff --git a/app/server/utils/restic/commands/snapshots.ts b/packages/core/src/restic/commands/snapshots.ts similarity index 76% rename from app/server/utils/restic/commands/snapshots.ts rename to packages/core/src/restic/commands/snapshots.ts index f5b43d15..ec3d5e8f 100644 --- a/app/server/utils/restic/commands/snapshots.ts +++ b/packages/core/src/restic/commands/snapshots.ts @@ -1,12 +1,12 @@ import { z } from "zod"; -import type { RepositoryConfig } from "~/schemas/restic"; -import { resticSnapshotSummarySchema } from "~/schemas/restic-dto"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import { resticSnapshotSummarySchema } from "../restic-dto"; +import type { RepositoryConfig } from "../schemas"; +import { logger, safeExec } from "../../node"; +import type { ResticDeps } from "../types"; const snapshotInfoSchema = z.object({ gid: z.number().optional(), @@ -23,11 +23,15 @@ const snapshotInfoSchema = z.object({ summary: resticSnapshotSummarySchema.optional(), }); -export const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; organizationId: string }) => { +export const snapshots = async ( + config: RepositoryConfig, + options: { tags?: string[]; organizationId: string }, + deps: ResticDeps, +) => { const { tags, organizationId } = options; const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId); + const env = await buildEnv(config, organizationId, deps); const args = ["--repo", repoUrl, "snapshots"]; @@ -40,7 +44,7 @@ export const snapshots = async (config: RepositoryConfig, options: { tags?: stri addCommonArgs(args, env, config); const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic snapshots retrieval failed: ${res.stderr}`); diff --git a/app/server/utils/restic/commands/stats.ts b/packages/core/src/restic/commands/stats.ts similarity index 68% rename from app/server/utils/restic/commands/stats.ts rename to packages/core/src/restic/commands/stats.ts index 02acab3e..f1f8750c 100644 --- a/app/server/utils/restic/commands/stats.ts +++ b/packages/core/src/restic/commands/stats.ts @@ -1,23 +1,23 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { resticStatsSchema } from "~/schemas/restic-dto"; -import { safeJsonParse } from "~/server/utils/json"; -import { logger } from "~/server/utils/logger"; -import { ResticError } from "~/server/utils/errors"; -import { safeExec } from "~/server/utils/spawn"; +import { safeJsonParse } from "../../utils/json"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import { logger, safeExec } from "../../node"; +import { ResticError } from "../error"; +import { resticStatsSchema } from "../restic-dto"; +import type { ResticDeps } from "../types"; -export const stats = async (config: RepositoryConfig, options: { organizationId: string }) => { +export const stats = async (config: RepositoryConfig, options: { organizationId: string }, deps: ResticDeps) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); + const env = await buildEnv(config, options.organizationId, deps); const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"]; addCommonArgs(args, env, config); const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic stats retrieval failed: ${res.stderr}`); diff --git a/app/server/utils/restic/commands/tag-snapshots.ts b/packages/core/src/restic/commands/tag-snapshots.ts similarity index 79% rename from app/server/utils/restic/commands/tag-snapshots.ts rename to packages/core/src/restic/commands/tag-snapshots.ts index b5e4bfcd..997c8add 100644 --- a/app/server/utils/restic/commands/tag-snapshots.ts +++ b/packages/core/src/restic/commands/tag-snapshots.ts @@ -1,24 +1,25 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { ResticError } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; +import { logger, safeExec } from "../../node"; +import { ResticError } from "../error"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import type { ResticDeps } from "../types"; export const tagSnapshots = async ( config: RepositoryConfig, snapshotIds: string[], tags: { add?: string[]; remove?: string[]; set?: string[] }, organizationId: string, + deps: ResticDeps, ) => { if (snapshotIds.length === 0) { throw new Error("No snapshot IDs provided for tagging."); } const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId); + const env = await buildEnv(config, organizationId, deps); const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds]; @@ -43,7 +44,7 @@ export const tagSnapshots = async ( addCommonArgs(args, env, config); const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (res.exitCode !== 0) { logger.error(`Restic snapshot tagging failed: ${res.stderr}`); diff --git a/app/server/utils/restic/commands/unlock.ts b/packages/core/src/restic/commands/unlock.ts similarity index 67% rename from app/server/utils/restic/commands/unlock.ts rename to packages/core/src/restic/commands/unlock.ts index 13fb81e7..54d54b9f 100644 --- a/app/server/utils/restic/commands/unlock.ts +++ b/packages/core/src/restic/commands/unlock.ts @@ -1,15 +1,19 @@ -import type { RepositoryConfig } from "~/schemas/restic"; -import { ResticError } from "~/server/utils/errors"; -import { logger } from "~/server/utils/logger"; -import { safeExec } from "~/server/utils/spawn"; +import { logger, safeExec } from "../../node"; +import { ResticError } from "../error"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "../schemas"; +import type { ResticDeps } from "../types"; -export const unlock = async (config: RepositoryConfig, options: { signal?: AbortSignal; organizationId: string }) => { +export const unlock = async ( + config: RepositoryConfig, + options: { signal?: AbortSignal; organizationId: string }, + deps: ResticDeps, +) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); + const env = await buildEnv(config, options.organizationId, deps); const args = ["unlock", "--repo", repoUrl, "--remove-all"]; addCommonArgs(args, env, config); @@ -20,7 +24,7 @@ export const unlock = async (config: RepositoryConfig, options: { signal?: Abort env, signal: options.signal, }); - await cleanupTemporaryKeys(env); + await cleanupTemporaryKeys(env, deps); if (options.signal?.aborted) { logger.warn("Restic unlock was aborted by signal."); diff --git a/packages/core/src/restic/error.ts b/packages/core/src/restic/error.ts new file mode 100644 index 00000000..60e039b7 --- /dev/null +++ b/packages/core/src/restic/error.ts @@ -0,0 +1,21 @@ +const resticErrorCodes: Record = { + 1: "Command failed: An error occurred while executing the command.", + 2: "Go runtime error: A runtime error occurred in the Go program.", + 3: "Backup could not read all files: Some files could not be read during backup.", + 10: "Repository not found: The specified repository could not be found.", + 11: "Failed to lock repository: Unable to acquire a lock on the repository. Try to run doctor on the repository.", + 12: "Wrong repository password: The provided password for the repository is incorrect.", + 130: "Backup interrupted: The backup process was interrupted.", +}; + +export class ResticError extends Error { + code: number; + + constructor(code: number, stderr: string) { + const message = resticErrorCodes[code] || `Unknown restic error with code ${code}`; + super(`${message}\n${stderr}`); + + this.code = code; + this.name = "ResticError"; + } +} diff --git a/app/server/utils/restic/helpers/__tests__/build-env.test.ts b/packages/core/src/restic/helpers/__tests__/build-env.test.ts similarity index 86% rename from app/server/utils/restic/helpers/__tests__/build-env.test.ts rename to packages/core/src/restic/helpers/__tests__/build-env.test.ts index d9c9724a..8f8bf5a4 100644 --- a/app/server/utils/restic/helpers/__tests__/build-env.test.ts +++ b/packages/core/src/restic/helpers/__tests__/build-env.test.ts @@ -1,10 +1,19 @@ -import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import { afterEach, describe, expect, test } from "bun:test"; -import { db } from "~/server/db/db"; -import { organization } from "~/server/db/schema"; -import { RESTIC_CACHE_DIR } from "~/server/core/constants"; import { buildEnv } from "../build-env"; +import type { ResticDeps } from "../../types"; + +const RESTIC_CACHE_DIR = "/tmp/restic-cache"; +const RESTIC_PASS_FILE = "/tmp/restic.pass"; + +const makeDeps = (overrides: Partial = {}): ResticDeps => ({ + resolveSecret: async (s) => s, + getOrganizationResticPassword: async () => "org-restic-password", + resticCacheDir: RESTIC_CACHE_DIR, + resticPassFile: RESTIC_PASS_FILE, + defaultExcludes: [RESTIC_PASS_FILE, "/var/lib/zerobyte/repositories"], + ...overrides, +}); const withCustomPassword = (config: T) => ({ ...config, @@ -12,19 +21,6 @@ const withCustomPassword = (config: T) => ({ customPassword: "test-password", }); -const createTestOrg = async (overrides: Partial = {}) => { - const id = randomUUID(); - await db.insert(organization).values({ - id, - name: "Build-env test org", - slug: `build-env-test-${id}`, - createdAt: new Date(), - metadata: { resticPassword: "org-restic-password" }, - ...overrides, - }); - return id; -}; - const PLAIN_PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----"; @@ -37,10 +33,11 @@ const trackTempFile = (filePath: string | undefined) => { }; const buildEnvForTest = async ( - config: Parameters[0], + config: Parameters[0], organizationId: string, -): Promise> => { - const env = await buildEnv(config, organizationId); + deps: ResticDeps = makeDeps(), +) => { + const env = await buildEnv(config, organizationId, deps); // Automatically track all temp file paths created by buildEnv trackTempFile(env.RESTIC_PASSWORD_FILE); @@ -94,9 +91,11 @@ describe("buildEnv", () => { }); test("writes a password file from the organization's resticPassword when no customPassword is given", async () => { - const orgId = await createTestOrg(); + const deps = makeDeps({ + getOrganizationResticPassword: async () => "org-restic-password", + }); - const env = await buildEnvForTest({ backend: "local" as const, path: "/tmp/repo" }, orgId); + const env = await buildEnvForTest({ backend: "local" as const, path: "/tmp/repo" }, "org-1", deps); const passwordFilePath = env.RESTIC_PASSWORD_FILE; expect(passwordFilePath).toBeDefined(); @@ -108,17 +107,27 @@ describe("buildEnv", () => { expect(fileContent).toBe("org-restic-password"); }); - test("throws when the organization does not exist", async () => { - await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org")).rejects.toThrow( - "Organization non-existent-org not found", - ); + test("throws when getOrganizationResticPassword throws (organization not found)", async () => { + const deps = makeDeps({ + getOrganizationResticPassword: async () => { + throw new Error("Organization non-existent-org not found"); + }, + }); + + await expect( + buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org", deps), + ).rejects.toThrow("Organization non-existent-org not found"); }); - test("throws when the organization has no resticPassword configured", async () => { - const orgId = await createTestOrg({ metadata: null }); + test("throws when getOrganizationResticPassword throws (no password configured)", async () => { + const deps = makeDeps({ + getOrganizationResticPassword: async (id) => { + throw new Error(`Restic password not configured for organization ${id}`); + }, + }); - await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, orgId)).rejects.toThrow( - `Restic password not configured for organization ${orgId}`, + await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "org-no-pass", deps)).rejects.toThrow( + "Restic password not configured for organization org-no-pass", ); }); }); @@ -264,6 +273,7 @@ describe("buildEnv", () => { }); test("throws for passphrase-protected private keys", async () => { + const deps = makeDeps(); await expect( buildEnv( { @@ -271,14 +281,16 @@ describe("buildEnv", () => { privateKey: "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n-----END RSA PRIVATE KEY-----", }, "org-1", + deps, ), ).rejects.toThrow("Passphrase-protected SSH keys are not supported"); }); test("succeeds when the private key has CRLF line endings", async () => { const crlfKey = PLAIN_PRIVATE_KEY.replace(/\n/g, "\r\n"); + const deps = makeDeps(); - await expect(buildEnv({ ...baseSftpConfig, privateKey: crlfKey }, "org-1")).resolves.toBeDefined(); + await expect(buildEnv({ ...baseSftpConfig, privateKey: crlfKey }, "org-1", deps)).resolves.toBeDefined(); }); test("uses StrictHostKeyChecking=no when skipHostKeyCheck is true", async () => { diff --git a/app/server/utils/restic/helpers/__tests__/build-repo-url.test.ts b/packages/core/src/restic/helpers/__tests__/build-repo-url.test.ts similarity index 100% rename from app/server/utils/restic/helpers/__tests__/build-repo-url.test.ts rename to packages/core/src/restic/helpers/__tests__/build-repo-url.test.ts diff --git a/app/server/utils/restic/helpers/__tests__/validate-custom-params.test.ts b/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts similarity index 100% rename from app/server/utils/restic/helpers/__tests__/validate-custom-params.test.ts rename to packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts diff --git a/app/server/utils/restic/helpers/add-common-args.ts b/packages/core/src/restic/helpers/add-common-args.ts similarity index 94% rename from app/server/utils/restic/helpers/add-common-args.ts rename to packages/core/src/restic/helpers/add-common-args.ts index 1fd59477..5ed72177 100644 --- a/app/server/utils/restic/helpers/add-common-args.ts +++ b/packages/core/src/restic/helpers/add-common-args.ts @@ -1,6 +1,6 @@ -import type { RepositoryConfig } from "~/schemas/restic"; import { formatBandwidthLimit } from "./bandwidth"; import type { ResticEnv } from "../types"; +import type { RepositoryConfig } from "../schemas"; export const addCommonArgs = ( args: string[], diff --git a/app/server/utils/restic/helpers/bandwidth.ts b/packages/core/src/restic/helpers/bandwidth.ts similarity index 91% rename from app/server/utils/restic/helpers/bandwidth.ts rename to packages/core/src/restic/helpers/bandwidth.ts index 7dbdda7f..6acd9472 100644 --- a/app/server/utils/restic/helpers/bandwidth.ts +++ b/packages/core/src/restic/helpers/bandwidth.ts @@ -1,4 +1,4 @@ -import type { BandwidthLimit } from "~/schemas/restic"; +import type { BandwidthLimit } from "../schemas"; export const formatBandwidthLimit = (limit?: BandwidthLimit): string => { if (!limit || !limit.enabled || limit.value <= 0) { diff --git a/app/server/utils/restic/helpers/build-env.ts b/packages/core/src/restic/helpers/build-env.ts similarity index 68% rename from app/server/utils/restic/helpers/build-env.ts rename to packages/core/src/restic/helpers/build-env.ts index 0396f38d..4b9a2ffd 100644 --- a/app/server/utils/restic/helpers/build-env.ts +++ b/packages/core/src/restic/helpers/build-env.ts @@ -1,21 +1,22 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import type { RepositoryConfig } from "~/schemas/restic"; -import { RESTIC_CACHE_DIR } from "~/server/core/constants"; -import { db } from "~/server/db/db"; -import { cryptoUtils } from "~/server/utils/crypto"; -import { logger } from "~/server/utils/logger"; -import type { ResticEnv } from "../types"; +import type { ResticDeps, ResticEnv } from "../types"; +import type { RepositoryConfig } from "../schemas"; +import { logger } from "../../node"; -export const buildEnv = async (config: RepositoryConfig, organizationId: string): Promise => { +export const buildEnv = async ( + config: RepositoryConfig, + organizationId: string, + deps: ResticDeps, +): Promise => { const env: ResticEnv = { - RESTIC_CACHE_DIR, + RESTIC_CACHE_DIR: deps.resticCacheDir, PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", }; if (config.isExistingRepository && config.customPassword) { - const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword); + const decryptedPassword = await deps.resolveSecret(config.customPassword); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); await fs.writeFile(passwordFilePath, decryptedPassword, { @@ -23,20 +24,8 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string) }); env.RESTIC_PASSWORD_FILE = passwordFilePath; } else { - const org = await db.query.organization.findFirst({ - where: { id: organizationId }, - }); - - if (!org) { - throw new Error(`Organization ${organizationId} not found`); - } - - const metadata = org.metadata; - if (!metadata?.resticPassword) { - throw new Error(`Restic password not configured for organization ${organizationId}`); - } - - const decryptedPassword = await cryptoUtils.resolveSecret(metadata.resticPassword); + const encryptedPassword = await deps.getOrganizationResticPassword(organizationId); + const decryptedPassword = await deps.resolveSecret(encryptedPassword); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600, @@ -46,21 +35,21 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string) switch (config.backend) { case "s3": - env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); + env.AWS_ACCESS_KEY_ID = await deps.resolveSecret(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await deps.resolveSecret(config.secretAccessKey); if (config.endpoint.includes("myhuaweicloud")) { env.AWS_S3_BUCKET_LOOKUP = "dns"; } break; case "r2": - env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); + env.AWS_ACCESS_KEY_ID = await deps.resolveSecret(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await deps.resolveSecret(config.secretAccessKey); env.AWS_REGION = "auto"; env.AWS_S3_FORCE_PATH_STYLE = "true"; break; case "gcs": { - const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson); + const decryptedCredentials = await deps.resolveSecret(config.credentialsJson); const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600, @@ -71,7 +60,7 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string) } case "azure": { env.AZURE_ACCOUNT_NAME = config.accountName; - env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey); + env.AZURE_ACCOUNT_KEY = await deps.resolveSecret(config.accountKey); if (config.endpointSuffix) { env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; } @@ -79,15 +68,15 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string) } case "rest": { if (config.username) { - env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username); + env.RESTIC_REST_USERNAME = await deps.resolveSecret(config.username); } if (config.password) { - env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password); + env.RESTIC_REST_PASSWORD = await deps.resolveSecret(config.password); } break; } case "sftp": { - const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey); + const decryptedKey = await deps.resolveSecret(config.privateKey); const keyPath = path.join("/tmp", `zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`); let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); @@ -153,7 +142,7 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string) } if (config.cacert) { - const decryptedCert = await cryptoUtils.resolveSecret(config.cacert); + const decryptedCert = await deps.resolveSecret(config.cacert); const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`); await fs.writeFile(certPath, decryptedCert, { mode: 0o600 }); env.RESTIC_CACERT = certPath; diff --git a/app/server/utils/restic/helpers/build-repo-url.ts b/packages/core/src/restic/helpers/build-repo-url.ts similarity index 94% rename from app/server/utils/restic/helpers/build-repo-url.ts rename to packages/core/src/restic/helpers/build-repo-url.ts index c7b4cb9b..ba896040 100644 --- a/app/server/utils/restic/helpers/build-repo-url.ts +++ b/packages/core/src/restic/helpers/build-repo-url.ts @@ -1,4 +1,4 @@ -import type { RepositoryConfig } from "~/schemas/restic"; +import type { RepositoryConfig } from "../schemas"; export const buildRepoUrl = (config: RepositoryConfig): string => { switch (config.backend) { diff --git a/app/server/utils/restic/helpers/cleanup-temporary-keys.ts b/packages/core/src/restic/helpers/cleanup-temporary-keys.ts similarity index 56% rename from app/server/utils/restic/helpers/cleanup-temporary-keys.ts rename to packages/core/src/restic/helpers/cleanup-temporary-keys.ts index 3f5cebf9..b0443f95 100644 --- a/app/server/utils/restic/helpers/cleanup-temporary-keys.ts +++ b/packages/core/src/restic/helpers/cleanup-temporary-keys.ts @@ -1,8 +1,7 @@ import fs from "node:fs/promises"; -import { RESTIC_PASS_FILE } from "~/server/core/constants"; -import type { ResticEnv } from "../types"; +import type { ResticDeps, ResticEnv } from "../types"; -export const cleanupTemporaryKeys = async (env: ResticEnv) => { +export const cleanupTemporaryKeys = async (env: ResticEnv, deps: ResticDeps) => { const keysToClean = ["_SFTP_KEY_PATH", "_SFTP_KNOWN_HOSTS_PATH", "RESTIC_CACERT", "GOOGLE_APPLICATION_CREDENTIALS"]; for (const key of keysToClean) { @@ -11,7 +10,7 @@ export const cleanupTemporaryKeys = async (env: ResticEnv) => { } } - if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) { + if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== deps.resticPassFile) { await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {}); } }; diff --git a/app/server/utils/restic/helpers/validate-custom-params.ts b/packages/core/src/restic/helpers/validate-custom-params.ts similarity index 98% rename from app/server/utils/restic/helpers/validate-custom-params.ts rename to packages/core/src/restic/helpers/validate-custom-params.ts index e17aec76..20ab1884 100644 --- a/app/server/utils/restic/helpers/validate-custom-params.ts +++ b/packages/core/src/restic/helpers/validate-custom-params.ts @@ -73,7 +73,7 @@ export function validateCustomResticParams(params: string[]): string | null { for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; - if (!token.startsWith("-")) { + if (!token?.startsWith("-")) { return `Unexpected positional argument "${token}" in customResticParams`; } diff --git a/packages/core/src/restic/index.ts b/packages/core/src/restic/index.ts new file mode 100644 index 00000000..e7928da9 --- /dev/null +++ b/packages/core/src/restic/index.ts @@ -0,0 +1,15 @@ +export * from "./schemas"; +export * from "./restic-dto"; +export { ResticError } from "./error"; + +export type { RestoreProgress } from "./commands/restore"; +export type { + ResticDeps, + ResticEnv, + RetentionPolicy, + ForgetGroup, + ForgetReason, + ResticDumpStream, + ResticForgetResponse, + Snapshot, +} from "./types"; diff --git a/app/schemas/restic-dto.ts b/packages/core/src/restic/restic-dto.ts similarity index 100% rename from app/schemas/restic-dto.ts rename to packages/core/src/restic/restic-dto.ts diff --git a/app/schemas/restic.ts b/packages/core/src/restic/schemas.ts similarity index 100% rename from app/schemas/restic.ts rename to packages/core/src/restic/schemas.ts diff --git a/packages/core/src/restic/server.ts b/packages/core/src/restic/server.ts new file mode 100644 index 00000000..88cb7841 --- /dev/null +++ b/packages/core/src/restic/server.ts @@ -0,0 +1,49 @@ +import { backup } from "./commands/backup"; +import { check } from "./commands/check"; +import { copy } from "./commands/copy"; +import { deleteSnapshot, deleteSnapshots } from "./commands/delete-snapshots"; +import { dump } from "./commands/dump"; +import { forget } from "./commands/forget"; +import { init } from "./commands/init"; +import { keyAdd } from "./commands/key-add"; +import { ls } from "./commands/ls"; +import { repairIndex } from "./commands/repair-index"; +import { restore } from "./commands/restore"; +import { snapshots } from "./commands/snapshots"; +import { stats } from "./commands/stats"; +import { tagSnapshots } from "./commands/tag-snapshots"; +import { unlock } from "./commands/unlock"; +import type { ResticDeps } from "./types"; + +export { addCommonArgs } from "./helpers/add-common-args"; +export { buildEnv } from "./helpers/build-env"; +export { buildRepoUrl } from "./helpers/build-repo-url"; +export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys"; +export { validateCustomResticParams } from "./helpers/validate-custom-params"; +export { ResticError } from "./error"; + +function withDeps( + command: (...args: [...Args, ResticDeps]) => Result, + deps: ResticDeps, +): (...args: Args) => Result { + return (...args: Args) => command(...args, deps); +} + +export const createRestic = (deps: ResticDeps) => ({ + init: withDeps(init, deps), + keyAdd: withDeps(keyAdd, deps), + backup: withDeps(backup, deps), + restore: withDeps(restore, deps), + dump: withDeps(dump, deps), + snapshots: withDeps(snapshots, deps), + stats: withDeps(stats, deps), + forget: withDeps(forget, deps), + deleteSnapshot: withDeps(deleteSnapshot, deps), + deleteSnapshots: withDeps(deleteSnapshots, deps), + tagSnapshots: withDeps(tagSnapshots, deps), + unlock: withDeps(unlock, deps), + ls: withDeps(ls, deps), + check: withDeps(check, deps), + repairIndex: withDeps(repairIndex, deps), + copy: withDeps(copy, deps), +}); diff --git a/app/server/utils/restic/types.ts b/packages/core/src/restic/types.ts similarity index 60% rename from app/server/utils/restic/types.ts rename to packages/core/src/restic/types.ts index d9a4d26e..cf68d862 100644 --- a/app/server/utils/restic/types.ts +++ b/packages/core/src/restic/types.ts @@ -1,5 +1,24 @@ import type { Readable } from "node:stream"; -import type { ResticSnapshotSummaryDto } from "~/schemas/restic-dto"; +import type { ResticSnapshotSummaryDto } from "./restic-dto"; + +export interface ResticDeps { + resolveSecret: (encrypted: string) => Promise; + getOrganizationResticPassword: (organizationId: string) => Promise; + resticCacheDir: string; + resticPassFile: string; + defaultExcludes: string[]; + hostname?: string; +} + +export interface RetentionPolicy { + keepLast?: number; + keepHourly?: number; + keepDaily?: number; + keepWeekly?: number; + keepMonthly?: number; + keepYearly?: number; + keepWithinDuration?: string; +} export type ResticEnv = Record; diff --git a/app/utils/common-ancestor.ts b/packages/core/src/utils/common-ancestor.ts similarity index 85% rename from app/utils/common-ancestor.ts rename to packages/core/src/utils/common-ancestor.ts index 0f966db6..cfe81ce3 100644 --- a/app/utils/common-ancestor.ts +++ b/packages/core/src/utils/common-ancestor.ts @@ -14,8 +14,9 @@ export const findCommonAncestor = (paths: string[]): string => { const commonParts: string[] = []; for (let i = 0; i < minLength; i++) { const partSet = new Set(splitPaths.map((parts) => parts[i])); - if (partSet.size === 1) { - commonParts.push(splitPaths[0][i]); + const toPush = splitPaths[0]?.[i]; + if (partSet.size === 1 && toPush) { + commonParts.push(toPush); } else { break; } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 00000000..46aac9c1 --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,3 @@ +export { safeJsonParse } from "./json.js"; +export { normalizeAbsolutePath } from "./path.js"; +export { findCommonAncestor } from "./common-ancestor.js"; diff --git a/app/server/utils/json.ts b/packages/core/src/utils/json.ts similarity index 100% rename from app/server/utils/json.ts rename to packages/core/src/utils/json.ts diff --git a/app/server/utils/logger.ts b/packages/core/src/utils/logger.ts similarity index 100% rename from app/server/utils/logger.ts rename to packages/core/src/utils/logger.ts diff --git a/app/utils/path.ts b/packages/core/src/utils/path.ts similarity index 100% rename from app/utils/path.ts rename to packages/core/src/utils/path.ts diff --git a/app/server/utils/sanitize.ts b/packages/core/src/utils/sanitize.ts similarity index 100% rename from app/server/utils/sanitize.ts rename to packages/core/src/utils/sanitize.ts diff --git a/app/server/utils/spawn.ts b/packages/core/src/utils/spawn.ts similarity index 100% rename from app/server/utils/spawn.ts rename to packages/core/src/utils/spawn.ts diff --git a/packages/core/test/setup.ts b/packages/core/test/setup.ts new file mode 100644 index 00000000..99dd0829 --- /dev/null +++ b/packages/core/test/setup.ts @@ -0,0 +1,10 @@ +import { mock } from "bun:test"; + +void mock.module("../src/utils/logger.ts", () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, +})); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..146fe4ed --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..40b588dc --- /dev/null +++ b/turbo.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://turborepo.dev/schema.json", + "tasks": { + "build": { + "outputs": ["dist/**"] + }, + "tsc": { + "dependsOn": ["^tsc"] + }, + "test": { + "dependsOn": ["^test"] + }, + "dev": { + "persistent": true, + "cache": false + } + } +}