feat: handle native windows snapshot restore paths

Add support for snapshot restore on windows
This commit is contained in:
Nicolas Meienberger
2026-06-29 17:58:03 +02:00
parent f3f6a4e5f9
commit d098ffdbab
53 changed files with 1411 additions and 279 deletions

View File

@@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, createApiKey, createBackupSchedule, createDesktopSession, createNotificationDestination, createRepository, createVolume, deleteApiKey, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getApiKeys, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPasswordLoginStatus, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getUserSsoInvitations, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setPasswordLoginStatus, setRegistrationStatus, startDoctor, startInvitationSsoVerification, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateApiKeyData, CreateApiKeyResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateDesktopSessionData, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteApiKeyData, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetApiKeysData, GetApiKeysResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPasswordLoginStatusData, GetPasswordLoginStatusResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetUserSsoInvitationsData, GetUserSsoInvitationsResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetPasswordLoginStatusData, SetPasswordLoginStatusResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StartInvitationSsoVerificationData, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
import { browseFilesystem, cancelDoctor, createApiKey, createBackupSchedule, createDesktopSession, createNotificationDestination, createRepository, createVolume, deleteApiKey, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getApiKeys, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPasswordLoginStatus, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSnapshotRestorePlan, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getUserSsoInvitations, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setPasswordLoginStatus, setRegistrationStatus, startDoctor, startInvitationSsoVerification, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateApiKeyData, CreateApiKeyResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateDesktopSessionData, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteApiKeyData, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetApiKeysData, GetApiKeysResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPasswordLoginStatusData, GetPasswordLoginStatusResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSnapshotRestorePlanData, GetSnapshotRestorePlanResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetUserSsoInvitationsData, GetUserSsoInvitationsResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetPasswordLoginStatusData, SetPasswordLoginStatusResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StartInvitationSsoVerificationData, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@@ -811,6 +811,24 @@ export const getSnapshotDetailsOptions = (options: Options<GetSnapshotDetailsDat
queryKey: getSnapshotDetailsQueryKey(options)
});
export const getSnapshotRestorePlanQueryKey = (options: Options<GetSnapshotRestorePlanData>) => createQueryKey('getSnapshotRestorePlan', options);
/**
* Get a target-scoped restore plan for a snapshot
*/
export const getSnapshotRestorePlanOptions = (options: Options<GetSnapshotRestorePlanData>) => queryOptions<GetSnapshotRestorePlanResponse, DefaultError, GetSnapshotRestorePlanResponse, ReturnType<typeof getSnapshotRestorePlanQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSnapshotRestorePlan({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getSnapshotRestorePlanQueryKey(options)
});
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) => createQueryKey('listSnapshotFiles', options);
/**

View File

@@ -6,6 +6,8 @@ export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "../core/bodySerializer.gen";
export { buildClientParams } from "../core/params.gen";
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
export type { ServerSentEventsResult } from "../core/serverSentEvents.gen";
export type { ClientMeta } from "../core/types.gen";
export { createClient } from "./client.gen";
export type {
Client,

View File

@@ -15,8 +15,8 @@ import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
}: QuerySerializerOptions = {}): ((queryParams: T) => string) => {
const querySerializer = (queryParams: T): string => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {

View File

@@ -10,6 +10,13 @@ export interface Auth {
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* A unique identifier for the security scheme.
*
* Defined only when there are multiple security schemes whose `Auth`
* shape would otherwise be identical.
*/
key?: string;
/**
* Header or query parameter name.
*

View File

@@ -63,7 +63,7 @@ type KeyMap = Map<
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap {
if (!map) {
map = new Map();
}
@@ -86,7 +86,7 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
}
return map;
};
}
interface Params {
body: unknown;
@@ -95,16 +95,18 @@ interface Params {
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
type ParamsSlotMap = Record<Slot, unknown>;
function stripEmptySlots(params: ParamsSlotMap): void {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
}
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
export function buildClientParams(args: ReadonlyArray<unknown>, fields: FieldsConfig): Params {
const params: ParamsSlotMap = {
body: Object.create(null),
headers: Object.create(null),
path: Object.create(null),
@@ -166,5 +168,5 @@ export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsCo
stripEmptySlots(params);
return params;
};
return params as Params;
}

View File

@@ -26,7 +26,7 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => {
switch (style) {
case 'label':
return '.';
@@ -39,7 +39,7 @@ export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => {
switch (style) {
case 'form':
return ',';
@@ -52,7 +52,7 @@ export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => {
switch (style) {
case 'label':
return '.';
@@ -73,7 +73,7 @@ export const serializeArrayParam = ({
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
}): string => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
@@ -111,7 +111,7 @@ export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
}: SerializePrimitiveParam): string => {
if (value === undefined || value === null) {
return '';
}
@@ -135,7 +135,7 @@ export const serializeObjectParam = ({
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
}): string => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}

View File

@@ -15,7 +15,7 @@ export type JsonValue =
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => {
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}

View File

@@ -92,6 +92,12 @@ export interface Config {
responseValidator?: (data: unknown) => Promise<unknown>;
}
/**
* Arbitrary metadata passed through the `meta` request option.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ClientMeta {}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]

View File

@@ -14,9 +14,9 @@ export interface PathSerializer {
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
@@ -95,7 +95,7 @@ export const getUrl = ({
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
}): string => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
@@ -115,7 +115,7 @@ export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
}): unknown {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;

View File

@@ -41,6 +41,7 @@ export {
getScheduleMirrors,
getScheduleNotifications,
getSnapshotDetails,
getSnapshotRestorePlan,
getSsoSettings,
getStatus,
getSystemInfo,
@@ -209,6 +210,9 @@ export type {
GetSnapshotDetailsData,
GetSnapshotDetailsResponse,
GetSnapshotDetailsResponses,
GetSnapshotRestorePlanData,
GetSnapshotRestorePlanResponse,
GetSnapshotRestorePlanResponses,
GetSsoSettingsData,
GetSsoSettingsResponse,
GetSsoSettingsResponses,

View File

@@ -1,9 +1,9 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts
import type { Client, Options as Options2, TDataShape } from './client';
import type { Client, ClientMeta, Options as Options2, RequestResult, ServerSentEventsResult, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateDesktopSessionData, CreateDesktopSessionResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteApiKeyData, DeleteApiKeyErrors, DeleteApiKeyResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetApiKeysData, GetApiKeysResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPasswordLoginStatusData, GetPasswordLoginStatusResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetUserSsoInvitationsData, GetUserSsoInvitationsResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetPasswordLoginStatusData, SetPasswordLoginStatusResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StartInvitationSsoVerificationData, StartInvitationSsoVerificationErrors, StartInvitationSsoVerificationResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateDesktopSessionData, CreateDesktopSessionResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteApiKeyData, DeleteApiKeyErrors, DeleteApiKeyResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetApiKeysData, GetApiKeysResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPasswordLoginStatusData, GetPasswordLoginStatusResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSnapshotRestorePlanData, GetSnapshotRestorePlanResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetUserSsoInvitationsData, GetUserSsoInvitationsResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetPasswordLoginStatusData, SetPasswordLoginStatusResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StartInvitationSsoVerificationData, StartInvitationSsoVerificationErrors, StartInvitationSsoVerificationResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -16,38 +16,38 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
meta?: keyof ClientMeta extends never ? Record<string, unknown> : ClientMeta;
};
/**
* Get authentication system status
*/
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>): RequestResult<GetStatusResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
/**
* List admin users for settings management
*/
export const getAdminUsers = <ThrowOnError extends boolean = false>(options?: Options<GetAdminUsersData, ThrowOnError>) => (options?.client ?? client).get<GetAdminUsersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/admin-users', ...options });
export const getAdminUsers = <ThrowOnError extends boolean = false>(options?: Options<GetAdminUsersData, ThrowOnError>): RequestResult<GetAdminUsersResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetAdminUsersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/admin-users', ...options });
/**
* Delete an account linked to a user
*/
export const deleteUserAccount = <ThrowOnError extends boolean = false>(options: Options<DeleteUserAccountData, ThrowOnError>) => (options.client ?? client).delete<DeleteUserAccountResponses, DeleteUserAccountErrors, ThrowOnError>({ url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}', ...options });
export const deleteUserAccount = <ThrowOnError extends boolean = false>(options: Options<DeleteUserAccountData, ThrowOnError>): RequestResult<DeleteUserAccountResponses, DeleteUserAccountErrors, ThrowOnError> => (options.client ?? client).delete<DeleteUserAccountResponses, DeleteUserAccountErrors, ThrowOnError>({ url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}', ...options });
/**
* Get impact of deleting a user
*/
export const getUserDeletionImpact = <ThrowOnError extends boolean = false>(options: Options<GetUserDeletionImpactData, ThrowOnError>) => (options.client ?? client).get<GetUserDeletionImpactResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/deletion-impact/{userId}', ...options });
export const getUserDeletionImpact = <ThrowOnError extends boolean = false>(options: Options<GetUserDeletionImpactData, ThrowOnError>): RequestResult<GetUserDeletionImpactResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetUserDeletionImpactResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/deletion-impact/{userId}', ...options });
/**
* Get members of the active organization
*/
export const getOrgMembers = <ThrowOnError extends boolean = false>(options?: Options<GetOrgMembersData, ThrowOnError>) => (options?.client ?? client).get<GetOrgMembersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/org-members', ...options });
export const getOrgMembers = <ThrowOnError extends boolean = false>(options?: Options<GetOrgMembersData, ThrowOnError>): RequestResult<GetOrgMembersResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetOrgMembersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/org-members', ...options });
/**
* Update a member's role in the active organization
*/
export const updateMemberRole = <ThrowOnError extends boolean = false>(options: Options<UpdateMemberRoleData, ThrowOnError>) => (options.client ?? client).patch<UpdateMemberRoleResponses, UpdateMemberRoleErrors, ThrowOnError>({
export const updateMemberRole = <ThrowOnError extends boolean = false>(options: Options<UpdateMemberRoleData, ThrowOnError>): RequestResult<UpdateMemberRoleResponses, UpdateMemberRoleErrors, ThrowOnError> => (options.client ?? client).patch<UpdateMemberRoleResponses, UpdateMemberRoleErrors, ThrowOnError>({
url: '/api/v1/auth/org-members/{memberId}/role',
...options,
headers: {
@@ -59,17 +59,17 @@ export const updateMemberRole = <ThrowOnError extends boolean = false>(options:
/**
* Remove a member from the active organization
*/
export const removeOrgMember = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>) => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options });
export const removeOrgMember = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>): RequestResult<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError> => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options });
/**
* List API keys for the current user in the active organization
*/
export const getApiKeys = <ThrowOnError extends boolean = false>(options?: Options<GetApiKeysData, ThrowOnError>) => (options?.client ?? client).get<GetApiKeysResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/api-keys', ...options });
export const getApiKeys = <ThrowOnError extends boolean = false>(options?: Options<GetApiKeysData, ThrowOnError>): RequestResult<GetApiKeysResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetApiKeysResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/api-keys', ...options });
/**
* Create an API key for the current user in the active organization
*/
export const createApiKey = <ThrowOnError extends boolean = false>(options: Options<CreateApiKeyData, ThrowOnError>) => (options.client ?? client).post<CreateApiKeyResponses, CreateApiKeyErrors, ThrowOnError>({
export const createApiKey = <ThrowOnError extends boolean = false>(options: Options<CreateApiKeyData, ThrowOnError>): RequestResult<CreateApiKeyResponses, CreateApiKeyErrors, ThrowOnError> => (options.client ?? client).post<CreateApiKeyResponses, CreateApiKeyErrors, ThrowOnError>({
url: '/api/v1/auth/api-keys',
...options,
headers: {
@@ -81,27 +81,27 @@ export const createApiKey = <ThrowOnError extends boolean = false>(options: Opti
/**
* Revoke an API key for the current user in the active organization
*/
export const deleteApiKey = <ThrowOnError extends boolean = false>(options: Options<DeleteApiKeyData, ThrowOnError>) => (options.client ?? client).delete<DeleteApiKeyResponses, DeleteApiKeyErrors, ThrowOnError>({ url: '/api/v1/auth/api-keys/{keyId}', ...options });
export const deleteApiKey = <ThrowOnError extends boolean = false>(options: Options<DeleteApiKeyData, ThrowOnError>): RequestResult<DeleteApiKeyResponses, DeleteApiKeyErrors, ThrowOnError> => (options.client ?? client).delete<DeleteApiKeyResponses, DeleteApiKeyErrors, ThrowOnError>({ url: '/api/v1/auth/api-keys/{keyId}', ...options });
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>) => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>): RequestResult<GetPublicSsoProvidersResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>) => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>): RequestResult<GetSsoSettingsResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
/**
* Get pending SSO invitations for the authenticated user
*/
export const getUserSsoInvitations = <ThrowOnError extends boolean = false>(options?: Options<GetUserSsoInvitationsData, ThrowOnError>) => (options?.client ?? client).get<GetUserSsoInvitationsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-invitations', ...options });
export const getUserSsoInvitations = <ThrowOnError extends boolean = false>(options?: Options<GetUserSsoInvitationsData, ThrowOnError>): RequestResult<GetUserSsoInvitationsResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetUserSsoInvitationsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-invitations', ...options });
/**
* Start SSO verification for accepting an organization invitation
*/
export const startInvitationSsoVerification = <ThrowOnError extends boolean = false>(options: Options<StartInvitationSsoVerificationData, ThrowOnError>) => (options.client ?? client).post<StartInvitationSsoVerificationResponses, StartInvitationSsoVerificationErrors, ThrowOnError>({
export const startInvitationSsoVerification = <ThrowOnError extends boolean = false>(options: Options<StartInvitationSsoVerificationData, ThrowOnError>): RequestResult<StartInvitationSsoVerificationResponses, StartInvitationSsoVerificationErrors, ThrowOnError> => (options.client ?? client).post<StartInvitationSsoVerificationResponses, StartInvitationSsoVerificationErrors, ThrowOnError>({
url: '/api/v1/auth/sso-invitations/{invitationId}/verify',
...options,
headers: {
@@ -113,12 +113,12 @@ export const startInvitationSsoVerification = <ThrowOnError extends boolean = fa
/**
* Delete an SSO provider
*/
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>): RequestResult<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError> => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>): RequestResult<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError> => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
...options,
headers: {
@@ -130,17 +130,17 @@ export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = fals
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>): RequestResult<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError> => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
/**
* List all volumes
*/
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes', ...options });
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>): RequestResult<ListVolumesResponses, unknown, ThrowOnError> => (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes', ...options });
/**
* Create a new volume
*/
export const createVolume = <ThrowOnError extends boolean = false>(options: Options<CreateVolumeData, ThrowOnError>) => (options.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
export const createVolume = <ThrowOnError extends boolean = false>(options: Options<CreateVolumeData, ThrowOnError>): RequestResult<CreateVolumeResponses, unknown, ThrowOnError> => (options.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes',
...options,
headers: {
@@ -152,7 +152,7 @@ export const createVolume = <ThrowOnError extends boolean = false>(options: Opti
/**
* Test connection to backend
*/
export const testConnection = <ThrowOnError extends boolean = false>(options: Options<TestConnectionData, ThrowOnError>) => (options.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
export const testConnection = <ThrowOnError extends boolean = false>(options: Options<TestConnectionData, ThrowOnError>): RequestResult<TestConnectionResponses, unknown, ThrowOnError> => (options.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/test-connection',
...options,
headers: {
@@ -164,17 +164,17 @@ export const testConnection = <ThrowOnError extends boolean = false>(options: Op
/**
* Delete a volume
*/
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}', ...options });
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>): RequestResult<DeleteVolumeResponses, unknown, ThrowOnError> => (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}', ...options });
/**
* Get a volume by name
*/
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{shortId}', ...options });
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>): RequestResult<GetVolumeResponses, GetVolumeErrors, ThrowOnError> => (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{shortId}', ...options });
/**
* Update a volume's configuration
*/
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>): RequestResult<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError> => (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
url: '/api/v1/volumes/{shortId}',
...options,
headers: {
@@ -186,37 +186,37 @@ export const updateVolume = <ThrowOnError extends boolean = false>(options: Opti
/**
* Mount a volume
*/
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/mount', ...options });
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>): RequestResult<MountVolumeResponses, unknown, ThrowOnError> => (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/mount', ...options });
/**
* Unmount a volume
*/
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/unmount', ...options });
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>): RequestResult<UnmountVolumeResponses, unknown, ThrowOnError> => (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/unmount', ...options });
/**
* Perform a health check on a volume
*/
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/health-check', ...options });
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>): RequestResult<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError> => (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/health-check', ...options });
/**
* List files in a volume directory
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/files', ...options });
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>): RequestResult<ListFilesResponses, unknown, ThrowOnError> => (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/files', ...options });
/**
* Browse directories on the host filesystem
*/
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>) => (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/filesystem/browse', ...options });
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>): RequestResult<BrowseFilesystemResponses, unknown, ThrowOnError> => (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/filesystem/browse', ...options });
/**
* List all repositories
*/
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>) => (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories', ...options });
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>): RequestResult<ListRepositoriesResponses, unknown, ThrowOnError> => (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories', ...options });
/**
* Create a new restic repository
*/
export const createRepository = <ThrowOnError extends boolean = false>(options: Options<CreateRepositoryData, ThrowOnError>) => (options.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
export const createRepository = <ThrowOnError extends boolean = false>(options: Options<CreateRepositoryData, ThrowOnError>): RequestResult<CreateRepositoryResponses, unknown, ThrowOnError> => (options.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories',
...options,
headers: {
@@ -228,22 +228,22 @@ export const createRepository = <ThrowOnError extends boolean = false>(options:
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?: Options<ListRcloneRemotesData, ThrowOnError>) => (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/rclone-remotes', ...options });
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?: Options<ListRcloneRemotesData, ThrowOnError>): RequestResult<ListRcloneRemotesResponses, unknown, ThrowOnError> => (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/rclone-remotes', ...options });
/**
* Delete a repository
*/
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}', ...options });
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>): RequestResult<DeleteRepositoryResponses, unknown, ThrowOnError> => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}', ...options });
/**
* Get a single repository by ID
*/
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}', ...options });
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>): RequestResult<GetRepositoryResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}', ...options });
/**
* Update a repository's name or settings
*/
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>): RequestResult<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError> => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
url: '/api/v1/repositories/{shortId}',
...options,
headers: {
@@ -255,17 +255,17 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
/**
* Get repository storage and compression statistics
*/
export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options });
export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>): RequestResult<GetRepositoryStatsResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options });
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<RefreshRepositoryStatsData, ThrowOnError>) => (options.client ?? client).post<RefreshRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options });
export const refreshRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<RefreshRepositoryStatsData, ThrowOnError>): RequestResult<RefreshRepositoryStatsResponses, unknown, ThrowOnError> => (options.client ?? client).post<RefreshRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options });
/**
* Delete multiple snapshots from a repository
*/
export const deleteSnapshots = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotsData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotsResponses, unknown, ThrowOnError>({
export const deleteSnapshots = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotsData, ThrowOnError>): RequestResult<DeleteSnapshotsResponses, unknown, ThrowOnError> => (options.client ?? client).delete<DeleteSnapshotsResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{shortId}/snapshots',
...options,
headers: {
@@ -277,37 +277,42 @@ export const deleteSnapshots = <ThrowOnError extends boolean = false>(options: O
/**
* List all snapshots in a repository
*/
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots', ...options });
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>): RequestResult<ListSnapshotsResponses, unknown, ThrowOnError> => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots', ...options });
/**
* Clear snapshot cache and force refresh from repository
*/
export const refreshSnapshots = <ThrowOnError extends boolean = false>(options: Options<RefreshSnapshotsData, ThrowOnError>) => (options.client ?? client).post<RefreshSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/refresh', ...options });
export const refreshSnapshots = <ThrowOnError extends boolean = false>(options: Options<RefreshSnapshotsData, ThrowOnError>): RequestResult<RefreshSnapshotsResponses, unknown, ThrowOnError> => (options.client ?? client).post<RefreshSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/refresh', ...options });
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options });
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>): RequestResult<DeleteSnapshotResponses, unknown, ThrowOnError> => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options });
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options });
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>): RequestResult<GetSnapshotDetailsResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options });
/**
* Get a target-scoped restore plan for a snapshot
*/
export const getSnapshotRestorePlan = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotRestorePlanData, ThrowOnError>): RequestResult<GetSnapshotRestorePlanResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetSnapshotRestorePlanResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/restore-plan', ...options });
/**
* List files and directories in a snapshot
*/
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/files', ...options });
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>): RequestResult<ListSnapshotFilesResponses, unknown, ThrowOnError> => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/files', ...options });
/**
* Download a snapshot path as a tar archive (folders) or raw file stream (single files)
*/
export const dumpSnapshot = <ThrowOnError extends boolean = false>(options: Options<DumpSnapshotData, ThrowOnError>) => (options.client ?? client).get<DumpSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump', ...options });
export const dumpSnapshot = <ThrowOnError extends boolean = false>(options: Options<DumpSnapshotData, ThrowOnError>): RequestResult<DumpSnapshotResponses, unknown, ThrowOnError> => (options.client ?? client).get<DumpSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump', ...options });
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>): RequestResult<RestoreSnapshotResponses, unknown, ThrowOnError> => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{shortId}/restore',
...options,
headers: {
@@ -319,22 +324,22 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: O
/**
* Cancel a running doctor operation on a repository
*/
export const cancelDoctor = <ThrowOnError extends boolean = false>(options: Options<CancelDoctorData, ThrowOnError>) => (options.client ?? client).delete<CancelDoctorResponses, CancelDoctorErrors, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/doctor', ...options });
export const cancelDoctor = <ThrowOnError extends boolean = false>(options: Options<CancelDoctorData, ThrowOnError>): RequestResult<CancelDoctorResponses, CancelDoctorErrors, ThrowOnError> => (options.client ?? client).delete<CancelDoctorResponses, CancelDoctorErrors, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/doctor', ...options });
/**
* Start an asynchronous doctor operation on a repository to fix common issues (unlock, check, repair index). The operation runs in the background and sends results via SSE events.
*/
export const startDoctor = <ThrowOnError extends boolean = false>(options: Options<StartDoctorData, ThrowOnError>) => (options.client ?? client).post<StartDoctorResponses, StartDoctorErrors, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/doctor', ...options });
export const startDoctor = <ThrowOnError extends boolean = false>(options: Options<StartDoctorData, ThrowOnError>): RequestResult<StartDoctorResponses, StartDoctorErrors, ThrowOnError> => (options.client ?? client).post<StartDoctorResponses, StartDoctorErrors, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/doctor', ...options });
/**
* Unlock a repository by removing all stale locks
*/
export const unlockRepository = <ThrowOnError extends boolean = false>(options: Options<UnlockRepositoryData, ThrowOnError>) => (options.client ?? client).post<UnlockRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/unlock', ...options });
export const unlockRepository = <ThrowOnError extends boolean = false>(options: Options<UnlockRepositoryData, ThrowOnError>): RequestResult<UnlockRepositoryResponses, unknown, ThrowOnError> => (options.client ?? client).post<UnlockRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/unlock', ...options });
/**
* Tag multiple snapshots in a repository
*/
export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Options<TagSnapshotsData, ThrowOnError>) => (options.client ?? client).post<TagSnapshotsResponses, unknown, ThrowOnError>({
export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Options<TagSnapshotsData, ThrowOnError>): RequestResult<TagSnapshotsResponses, unknown, ThrowOnError> => (options.client ?? client).post<TagSnapshotsResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{shortId}/snapshots/tag',
...options,
headers: {
@@ -346,7 +351,7 @@ export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Opti
/**
* Execute a restic command against a repository (dev panel only)
*/
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError, DevPanelExecResponse>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError, DevPanelExecResponse>): Promise<ServerSentEventsResult<DevPanelExecResponses>> => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
url: '/api/v1/repositories/{shortId}/exec',
...options,
headers: {
@@ -358,12 +363,12 @@ export const devPanelExec = <ThrowOnError extends boolean = false>(options: Opti
/**
* List all backup schedules
*/
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({ url: '/api/v1/backups', ...options });
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>): RequestResult<ListBackupSchedulesResponses, unknown, ThrowOnError> => (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({ url: '/api/v1/backups', ...options });
/**
* Create a new backup schedule for a volume
*/
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateBackupScheduleData, ThrowOnError>) => (options.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateBackupScheduleData, ThrowOnError>): RequestResult<CreateBackupScheduleResponses, unknown, ThrowOnError> => (options.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups',
...options,
headers: {
@@ -375,17 +380,17 @@ export const createBackupSchedule = <ThrowOnError extends boolean = false>(optio
/**
* Delete a backup schedule
*/
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}', ...options });
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>): RequestResult<DeleteBackupScheduleResponses, unknown, ThrowOnError> => (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}', ...options });
/**
* Get a backup schedule by ID
*/
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}', ...options });
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>): RequestResult<GetBackupScheduleResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}', ...options });
/**
* Update a backup schedule
*/
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>): RequestResult<UpdateBackupScheduleResponses, unknown, ThrowOnError> => (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{shortId}',
...options,
headers: {
@@ -397,32 +402,32 @@ export const updateBackupSchedule = <ThrowOnError extends boolean = false>(optio
/**
* Get a backup schedule for a specific volume
*/
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/volume/{volumeShortId}', ...options });
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>): RequestResult<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/volume/{volumeShortId}', ...options });
/**
* Trigger a backup immediately for a schedule
*/
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/run', ...options });
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>): RequestResult<RunBackupNowResponses, unknown, ThrowOnError> => (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/run', ...options });
/**
* Stop a backup that is currently in progress
*/
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({ url: '/api/v1/backups/{shortId}/stop', ...options });
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>): RequestResult<StopBackupResponses, StopBackupErrors, ThrowOnError> => (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({ url: '/api/v1/backups/{shortId}/stop', ...options });
/**
* Manually apply retention policy to clean up old snapshots
*/
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/forget', ...options });
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>): RequestResult<RunForgetResponses, unknown, ThrowOnError> => (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/forget', ...options });
/**
* Get notification assignments for a backup schedule
*/
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/notifications', ...options });
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>): RequestResult<GetScheduleNotificationsResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/notifications', ...options });
/**
* Update notification assignments for a backup schedule
*/
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>): RequestResult<UpdateScheduleNotificationsResponses, unknown, ThrowOnError> => (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{shortId}/notifications',
...options,
headers: {
@@ -434,12 +439,12 @@ export const updateScheduleNotifications = <ThrowOnError extends boolean = false
/**
* Get mirror repository assignments for a backup schedule
*/
export const getScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors', ...options });
export const getScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>): RequestResult<GetScheduleMirrorsResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors', ...options });
/**
* Update mirror repository assignments for a backup schedule
*/
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>): RequestResult<UpdateScheduleMirrorsResponses, unknown, ThrowOnError> => (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{shortId}/mirrors',
...options,
headers: {
@@ -451,12 +456,12 @@ export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(opti
/**
* Get sync status for a specific mirror, including missing snapshots
*/
export const getMirrorSyncStatus = <ThrowOnError extends boolean = false>(options: Options<GetMirrorSyncStatusData, ThrowOnError>) => (options.client ?? client).get<GetMirrorSyncStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status', ...options });
export const getMirrorSyncStatus = <ThrowOnError extends boolean = false>(options: Options<GetMirrorSyncStatusData, ThrowOnError>): RequestResult<GetMirrorSyncStatusResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetMirrorSyncStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status', ...options });
/**
* Sync selected snapshots to a specific mirror repository
*/
export const syncMirror = <ThrowOnError extends boolean = false>(options: Options<SyncMirrorData, ThrowOnError>) => (options.client ?? client).post<SyncMirrorResponses, SyncMirrorErrors, ThrowOnError>({
export const syncMirror = <ThrowOnError extends boolean = false>(options: Options<SyncMirrorData, ThrowOnError>): RequestResult<SyncMirrorResponses, SyncMirrorErrors, ThrowOnError> => (options.client ?? client).post<SyncMirrorResponses, SyncMirrorErrors, ThrowOnError>({
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync',
...options,
headers: {
@@ -468,12 +473,12 @@ export const syncMirror = <ThrowOnError extends boolean = false>(options: Option
/**
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
*/
export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/compatibility', ...options });
export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>): RequestResult<GetMirrorCompatibilityResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/compatibility', ...options });
/**
* Reorder backup schedules by providing an array of schedule short IDs in the desired order
*/
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options: Options<ReorderBackupSchedulesData, ThrowOnError>): RequestResult<ReorderBackupSchedulesResponses, unknown, ThrowOnError> => (options.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/reorder',
...options,
headers: {
@@ -485,17 +490,17 @@ export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(opt
/**
* Get the last known progress for a currently running backup. Returns null if no progress has been reported yet.
*/
export const getBackupProgress = <ThrowOnError extends boolean = false>(options: Options<GetBackupProgressData, ThrowOnError>) => (options.client ?? client).get<GetBackupProgressResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/progress', ...options });
export const getBackupProgress = <ThrowOnError extends boolean = false>(options: Options<GetBackupProgressData, ThrowOnError>): RequestResult<GetBackupProgressResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetBackupProgressResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/progress', ...options });
/**
* List all notification destinations
*/
export const listNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ListNotificationDestinationsData, ThrowOnError>) => (options?.client ?? client).get<ListNotificationDestinationsResponses, unknown, ThrowOnError>({ url: '/api/v1/notifications/destinations', ...options });
export const listNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ListNotificationDestinationsData, ThrowOnError>): RequestResult<ListNotificationDestinationsResponses, unknown, ThrowOnError> => (options?.client ?? client).get<ListNotificationDestinationsResponses, unknown, ThrowOnError>({ url: '/api/v1/notifications/destinations', ...options });
/**
* Create a new notification destination
*/
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<CreateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<CreateNotificationDestinationData, ThrowOnError>): RequestResult<CreateNotificationDestinationResponses, unknown, ThrowOnError> => (options.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations',
...options,
headers: {
@@ -507,17 +512,17 @@ export const createNotificationDestination = <ThrowOnError extends boolean = fal
/**
* Delete a notification destination
*/
export const deleteNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<DeleteNotificationDestinationData, ThrowOnError>) => (options.client ?? client).delete<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
export const deleteNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<DeleteNotificationDestinationData, ThrowOnError>): RequestResult<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError> => (options.client ?? client).delete<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
/**
* Get a notification destination by ID
*/
export const getNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<GetNotificationDestinationData, ThrowOnError>) => (options.client ?? client).get<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
export const getNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<GetNotificationDestinationData, ThrowOnError>): RequestResult<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError> => (options.client ?? client).get<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
/**
* Update a notification destination
*/
export const updateNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).patch<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError>({
export const updateNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationDestinationData, ThrowOnError>): RequestResult<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError> => (options.client ?? client).patch<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}',
...options,
headers: {
@@ -529,27 +534,27 @@ export const updateNotificationDestination = <ThrowOnError extends boolean = fal
/**
* Test a notification destination by sending a test message
*/
export const testNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<TestNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}/test', ...options });
export const testNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<TestNotificationDestinationData, ThrowOnError>): RequestResult<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError> => (options.client ?? client).post<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}/test', ...options });
/**
* Get system information including available capabilities
*/
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>): RequestResult<GetSystemInfoResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
/**
* Check for application updates from GitHub
*/
export const getUpdates = <ThrowOnError extends boolean = false>(options?: Options<GetUpdatesData, ThrowOnError>) => (options?.client ?? client).get<GetUpdatesResponses, unknown, ThrowOnError>({ url: '/api/v1/system/updates', ...options });
export const getUpdates = <ThrowOnError extends boolean = false>(options?: Options<GetUpdatesData, ThrowOnError>): RequestResult<GetUpdatesResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetUpdatesResponses, unknown, ThrowOnError>({ url: '/api/v1/system/updates', ...options });
/**
* Get the current registration status for new users
*/
export const getRegistrationStatus = <ThrowOnError extends boolean = false>(options?: Options<GetRegistrationStatusData, ThrowOnError>) => (options?.client ?? client).get<GetRegistrationStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/system/registration-status', ...options });
export const getRegistrationStatus = <ThrowOnError extends boolean = false>(options?: Options<GetRegistrationStatusData, ThrowOnError>): RequestResult<GetRegistrationStatusResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetRegistrationStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/system/registration-status', ...options });
/**
* Update the registration status for new users. Requires global admin role.
*/
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options: Options<SetRegistrationStatusData, ThrowOnError>) => (options.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options: Options<SetRegistrationStatusData, ThrowOnError>): RequestResult<SetRegistrationStatusResponses, unknown, ThrowOnError> => (options.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
url: '/api/v1/system/registration-status',
...options,
headers: {
@@ -561,7 +566,7 @@ export const setRegistrationStatus = <ThrowOnError extends boolean = false>(opti
/**
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and may require password re-authentication.
*/
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options: Options<DownloadResticPasswordData, ThrowOnError>) => (options.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options: Options<DownloadResticPasswordData, ThrowOnError>): RequestResult<DownloadResticPasswordResponses, unknown, ThrowOnError> => (options.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/system/restic-password',
...options,
headers: {
@@ -573,12 +578,12 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(opt
/**
* Get whether password-based login is disabled
*/
export const getPasswordLoginStatus = <ThrowOnError extends boolean = false>(options?: Options<GetPasswordLoginStatusData, ThrowOnError>) => (options?.client ?? client).get<GetPasswordLoginStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/system/password-login-status', ...options });
export const getPasswordLoginStatus = <ThrowOnError extends boolean = false>(options?: Options<GetPasswordLoginStatusData, ThrowOnError>): RequestResult<GetPasswordLoginStatusResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetPasswordLoginStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/system/password-login-status', ...options });
/**
* Disable or re-enable password-based login. Requires global admin role.
*/
export const setPasswordLoginStatus = <ThrowOnError extends boolean = false>(options: Options<SetPasswordLoginStatusData, ThrowOnError>) => (options.client ?? client).put<SetPasswordLoginStatusResponses, unknown, ThrowOnError>({
export const setPasswordLoginStatus = <ThrowOnError extends boolean = false>(options: Options<SetPasswordLoginStatusData, ThrowOnError>): RequestResult<SetPasswordLoginStatusResponses, unknown, ThrowOnError> => (options.client ?? client).put<SetPasswordLoginStatusResponses, unknown, ThrowOnError>({
url: '/api/v1/system/password-login-status',
...options,
headers: {
@@ -590,12 +595,12 @@ export const setPasswordLoginStatus = <ThrowOnError extends boolean = false>(opt
/**
* Get the dev panel status
*/
export const getDevPanel = <ThrowOnError extends boolean = false>(options?: Options<GetDevPanelData, ThrowOnError>) => (options?.client ?? client).get<GetDevPanelResponses, unknown, ThrowOnError>({ url: '/api/v1/system/dev-panel', ...options });
export const getDevPanel = <ThrowOnError extends boolean = false>(options?: Options<GetDevPanelData, ThrowOnError>): RequestResult<GetDevPanelResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetDevPanelResponses, unknown, ThrowOnError>({ url: '/api/v1/system/dev-panel', ...options });
/**
* Create an authenticated desktop session
*/
export const createDesktopSession = <ThrowOnError extends boolean = false>(options: Options<CreateDesktopSessionData, ThrowOnError>) => (options.client ?? client).post<CreateDesktopSessionResponses, unknown, ThrowOnError>({
export const createDesktopSession = <ThrowOnError extends boolean = false>(options: Options<CreateDesktopSessionData, ThrowOnError>): RequestResult<CreateDesktopSessionResponses, unknown, ThrowOnError> => (options.client ?? client).post<CreateDesktopSessionResponses, unknown, ThrowOnError>({
url: '/api/v1/desktop/session',
...options,
headers: {

View File

@@ -2336,6 +2336,30 @@ export type GetSnapshotDetailsResponses = {
export type GetSnapshotDetailsResponse = GetSnapshotDetailsResponses[keyof GetSnapshotDetailsResponses];
export type GetSnapshotRestorePlanData = {
body?: never;
path: {
shortId: string;
snapshotId: string;
};
query?: {
targetAgentId?: string;
};
url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/restore-plan';
};
export type GetSnapshotRestorePlanResponses = {
/**
* Target-scoped snapshot restore plan
*/
200: {
queryBasePath: string;
requiresCustomTarget: boolean;
};
};
export type GetSnapshotRestorePlanResponse = GetSnapshotRestorePlanResponses[keyof GetSnapshotRestorePlanResponses];
export type ListSnapshotFilesData = {
body?: never;
path: {

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
import { fromAny } from "@total-typescript/shoehorn";
import { getSnapshotSourcePathPlan } from "@zerobyte/core/utils";
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
@@ -14,6 +15,9 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {
import { RestoreForm } from "../restore-form";
const sourcePathPlan = (snapshotPaths: string[], hostPathKind: "posix" | "windows" = "posix") =>
getSnapshotSourcePathPlan({ snapshotPaths, hostPathKind });
class MockEventSource {
addEventListener() {}
close() {}
@@ -26,6 +30,19 @@ const originalEventSource = globalThis.EventSource;
beforeEach(() => {
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
server.use(
http.get("/api/v1/system/info", () =>
HttpResponse.json({
runtime: "server",
capabilities: {
rclone: false,
sysAdmin: false,
volumeBackends: ["directory"],
repositoryBackends: ["local", "s3", "r2", "gcs", "azure", "sftp", "rest"],
},
}),
),
);
});
afterEach(() => {
@@ -62,7 +79,7 @@ describe("RestoreForm", () => {
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
snapshotId="snap-1"
returnPath="/repositories/repo-1/snap-1"
queryBasePath="/mnt/project/subdir"
snapshotSourcePathPlan={sourcePathPlan(["/mnt/project/subdir"])}
displayBasePath="/mnt"
/>,
);
@@ -81,7 +98,7 @@ describe("RestoreForm", () => {
});
});
test("restores the selected full path when the display root is unrelated", async () => {
test("requires a custom target when the display root is unrelated", async () => {
let restoreRequestBody: unknown;
server.use(
@@ -115,7 +132,7 @@ describe("RestoreForm", () => {
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
snapshotId="snap-1"
returnPath="/repositories/repo-1/snap-1"
queryBasePath="/mnt/project"
snapshotSourcePathPlan={sourcePathPlan(["/mnt/project"])}
displayBasePath="/other/root"
/>,
);
@@ -152,4 +169,25 @@ describe("RestoreForm", () => {
});
});
});
test("allows original restore when Windows snapshot paths match a Windows host", () => {
server.use(
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () =>
HttpResponse.json({ files: [] }),
),
);
render(
<RestoreForm
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
snapshotId="snap-1"
returnPath="/repositories/repo-1/snap-1"
snapshotSourcePathPlan={sourcePathPlan(["C:\\Users\\nicolas\\Photos"], "windows")}
displayBasePath="C:\\Users\\nicolas"
/>,
);
expect(screen.queryByText("Source paths do not match")).toBeNull();
expect(screen.getByRole("button", { name: "Original location" }).hasAttribute("disabled")).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
import { afterEach, describe, expect, test } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, waitFor } from "~/test/test-utils";
import { LocalFileBrowser } from "../local-file-browser";
afterEach(() => {
cleanup();
});
describe("LocalFileBrowser", () => {
test("keeps Windows drive roots as host paths", async () => {
const requests: string[] = [];
server.use(
http.get("/api/v1/volumes/filesystem/browse", ({ request }) => {
const url = new URL(request.url);
requests.push(url.searchParams.get("path") ?? "");
return HttpResponse.json({
directories: [],
path: "C:\\",
});
}),
);
render(<LocalFileBrowser initialPath={"C:\\"} />);
await waitFor(() => {
expect(requests).toEqual(["C:\\"]);
});
});
});

View File

@@ -208,6 +208,33 @@ describe("SnapshotTreeBrowser", () => {
expect(selectedKind === "dir").toBe(true);
});
test("maps Windows display roots to restic snapshot tree paths", async () => {
mockListSnapshotFiles({
files: [
{ name: "Downloads", path: "/C/Users/nicolas/Downloads", type: "dir" },
{ name: "a.txt", path: "/C/Users/nicolas/Downloads/a.txt", type: "file" },
],
});
let selectedPaths: Set<string> | undefined;
renderSnapshotTreeBrowser({
queryBasePath: "/C/Users/nicolas",
displayBasePath: "C:\\Users\\nicolas",
withCheckboxes: true,
onSelectionChange: (paths) => {
selectedPaths = paths;
},
});
const row = await screen.findByRole("button", { name: "Downloads" });
const checkbox = within(row).getByRole("checkbox");
await userEvent.click(checkbox);
expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/C/Users/nicolas/Downloads"]);
});
test("uses the query base path for the initial request when display base path is broader", async () => {
const requests = mockListSnapshotFiles();

View File

@@ -3,7 +3,6 @@ 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 "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";
type LocalFileBrowserProps = FileBrowserUiProps & {
@@ -13,10 +12,10 @@ type LocalFileBrowserProps = FileBrowserUiProps & {
export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps }: LocalFileBrowserProps) => {
const queryClient = useQueryClient();
const normalizedInitialPath = normalizeAbsolutePath(initialPath);
const browsePath = initialPath.trim() ? initialPath : "/";
const { data, isLoading, error } = useQuery({
...browseFilesystemOptions({ query: { path: normalizedInitialPath } }),
...browseFilesystemOptions({ query: { path: browsePath } }),
enabled,
});

View File

@@ -4,7 +4,11 @@ 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 { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
import { isPathWithin, normalizeAbsolutePath, windowsHostPathToResticSnapshotPath } from "@zerobyte/core/utils";
function normalizeSnapshotTreeBasePath(basePath?: string) {
return windowsHostPathToResticSnapshotPath(basePath ?? "") ?? normalizeAbsolutePath(basePath ?? "/");
}
function createPathPrefixFns(basePath: string) {
return {
@@ -46,7 +50,7 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
const queryClient = useQueryClient();
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath);
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? "/");
const normalizedDisplayBasePath = normalizeSnapshotTreeBasePath(displayBasePath);
const effectiveDisplayBasePath = isPathWithin(normalizedDisplayBasePath, normalizedQueryBasePath)
? normalizedDisplayBasePath
: "/";

View File

@@ -23,37 +23,40 @@ 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 "@zerobyte/core/restic";
import { isPathWithin } from "@zerobyte/core/utils";
import { isPathWithin, normalizeAbsolutePath, windowsHostPathToResticSnapshotPath } from "@zerobyte/core/utils";
import type { Repository } from "~/client/lib/types";
import { handleRepositoryError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
import { cn } from "~/client/lib/utils";
type RestoreLocation = "original" | "custom";
type SnapshotRestorePlan = { queryBasePath: string; requiresCustomTarget: boolean };
const normalizeSnapshotBasePath = (value?: string) =>
windowsHostPathToResticSnapshotPath(value ?? "") ?? normalizeAbsolutePath(value ?? "/");
interface RestoreFormProps {
repository: Repository;
snapshotId: string;
returnPath: string;
queryBasePath?: string;
snapshotSourcePathPlan: SnapshotRestorePlan;
displayBasePath?: string;
hasNonPosixSnapshotPaths?: boolean;
}
export function RestoreForm({
repository,
snapshotId,
returnPath,
queryBasePath,
snapshotSourcePathPlan,
displayBasePath,
hasNonPosixSnapshotPaths = false,
}: RestoreFormProps) {
const navigate = useNavigate();
const { addEventListener } = useServerEvents();
const snapshotBasePath = queryBasePath ?? "/";
const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath);
const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath;
const snapshotBasePath = snapshotSourcePathPlan.queryBasePath;
const hasMismatchedDisplayBasePath =
displayBasePath && !isPathWithin(normalizeSnapshotBasePath(displayBasePath), snapshotBasePath);
const restoreRequiresCustomTarget = snapshotSourcePathPlan.requiresCustomTarget || !!hasMismatchedDisplayBasePath;
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>(
restoreRequiresCustomTarget ? "custom" : "original",

View File

@@ -6,7 +6,7 @@ import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
import { Link } from "@tanstack/react-router";
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
import { findCommonAncestor } from "@zerobyte/core/utils";
import { getSnapshotSourcePathPlan, hostPathKindFromPath } from "@zerobyte/core/utils";
interface Props {
snapshot: Snapshot;
@@ -30,8 +30,10 @@ export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props;
const { formatDateTime } = useTimeFormat();
const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/"));
const queryBasePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths);
const snapshotSourcePathPlan = getSnapshotSourcePathPlan({
snapshotPaths: snapshot.paths,
hostPathKind: hostPathKindFromPath(displayBasePath),
});
return (
<div className="space-y-4">
@@ -80,7 +82,7 @@ export const SnapshotFileBrowser = (props: Props) => {
<SnapshotTreeBrowser
repositoryId={repositoryId}
snapshotId={snapshot.short_id}
queryBasePath={queryBasePath}
queryBasePath={snapshotSourcePathPlan.queryBasePath}
displayBasePath={displayBasePath}
{...treeProps}
/>

View File

@@ -1,26 +1,26 @@
import { RestoreForm } from "~/client/components/restore-form";
import type { Repository } from "~/client/lib/types";
type SnapshotRestorePlan = { queryBasePath: string; requiresCustomTarget: boolean };
type Props = {
repository: Repository;
snapshotId: string;
returnPath: string;
queryBasePath?: string;
snapshotSourcePathPlan: SnapshotRestorePlan;
displayBasePath?: string;
hasNonPosixSnapshotPaths?: boolean;
};
export function RestoreSnapshotPage(props: Props) {
const { returnPath, snapshotId, repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = props;
const { returnPath, snapshotId, repository, snapshotSourcePathPlan, displayBasePath } = props;
return (
<RestoreForm
repository={repository}
snapshotId={snapshotId}
returnPath={returnPath}
queryBasePath={queryBasePath}
snapshotSourcePathPlan={snapshotSourcePathPlan}
displayBasePath={displayBasePath}
hasNonPosixSnapshotPaths={hasNonPosixSnapshotPaths}
/>
);
}

View File

@@ -1,9 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { getBackupSchedule } from "~/client/api-client";
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import {
getRepositoryOptions,
getSnapshotDetailsOptions,
getSnapshotRestorePlanOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
import { getVolumeMountPath } from "~/client/lib/volume-path";
import { findCommonAncestor } from "@zerobyte/core/utils";
export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({
component: RouteComponent,
@@ -15,7 +18,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
throw new Response("Not Found", { status: 404 });
}
const [snapshot, repository] = await Promise.all([
const [snapshot, repository, restorePlan] = await Promise.all([
context.queryClient.ensureQueryData({
...getSnapshotDetailsOptions({
path: { shortId: schedule.data.repository.shortId, snapshotId: params.snapshotId },
@@ -24,17 +27,21 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { shortId: schedule.data.repository.shortId } }),
}),
context.queryClient.ensureQueryData({
...getSnapshotRestorePlanOptions({
path: { shortId: schedule.data.repository.shortId, snapshotId: params.snapshotId },
}),
}),
]);
const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/"));
const displayBasePath = getVolumeMountPath(schedule.data.volume);
return {
snapshot,
repository,
schedule: schedule.data,
queryBasePath: hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths),
displayBasePath: getVolumeMountPath(schedule.data.volume),
hasNonPosixSnapshotPaths,
displayBasePath,
snapshotSourcePathPlan: restorePlan,
};
},
head: ({ params }) => ({
@@ -58,16 +65,15 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
function RouteComponent() {
const { backupId, snapshotId } = Route.useParams();
const { repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = Route.useLoaderData();
const { repository, displayBasePath, snapshotSourcePathPlan } = Route.useLoaderData();
return (
<RestoreSnapshotPage
returnPath={`/backups/${backupId}`}
snapshotId={snapshotId}
repository={repository}
queryBasePath={queryBasePath}
snapshotSourcePathPlan={snapshotSourcePathPlan}
displayBasePath={displayBasePath}
hasNonPosixSnapshotPaths={hasNonPosixSnapshotPaths}
/>
);
}

View File

@@ -1,19 +1,29 @@
import { createFileRoute } from "@tanstack/react-router";
import { getBackupSchedule } from "~/client/api-client";
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import {
getRepositoryOptions,
getSnapshotDetailsOptions,
getSnapshotRestorePlanOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
import { getVolumeMountPath } from "~/client/lib/volume-path";
import { findCommonAncestor } from "@zerobyte/core/utils";
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
const [snapshot, repository] = await Promise.all([
const [snapshot, repository, restorePlan] = await Promise.all([
context.queryClient.ensureQueryData({
...getSnapshotDetailsOptions({ path: { shortId: params.repositoryId, snapshotId: params.snapshotId } }),
}),
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }),
context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { shortId: params.repositoryId } }),
}),
context.queryClient.ensureQueryData({
...getSnapshotRestorePlanOptions({
path: { shortId: params.repositoryId, snapshotId: params.snapshotId },
}),
}),
]);
let displayBasePath: string | undefined;
@@ -25,21 +35,24 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
}
}
const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/"));
return {
snapshot,
repository,
queryBasePath: hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths),
displayBasePath,
hasNonPosixSnapshotPaths,
snapshotSourcePathPlan: restorePlan,
};
},
staticData: {
breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repositoryId}` },
{ label: match.params.snapshotId, href: `/repositories/${match.params.repositoryId}/${match.params.snapshotId}` },
{
label: match.loaderData?.repository?.name || "Repository",
href: `/repositories/${match.params.repositoryId}`,
},
{
label: match.params.snapshotId,
href: `/repositories/${match.params.repositoryId}/${match.params.snapshotId}`,
},
{ label: "Restore" },
],
},
@@ -56,16 +69,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
function RouteComponent() {
const { repositoryId, snapshotId } = Route.useParams();
const { repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = Route.useLoaderData();
const { repository, displayBasePath, snapshotSourcePathPlan } = Route.useLoaderData();
return (
<RestoreSnapshotPage
returnPath={`/repositories/${repositoryId}/${snapshotId}`}
repository={repository}
snapshotId={snapshotId}
queryBasePath={queryBasePath}
snapshotSourcePathPlan={snapshotSourcePathPlan}
displayBasePath={displayBasePath}
hasNonPosixSnapshotPaths={hasNonPosixSnapshotPaths}
/>
);
}

View File

@@ -2,7 +2,11 @@ import { Effect } from "effect";
import { afterEach, expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect";
import { fromPartial } from "@total-typescript/shoehorn";
import { createAgentMessage } from "@zerobyte/contracts/agent-protocol";
import {
createAgentMessage,
AGENT_PROTOCOL_VERSION,
SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
} from "@zerobyte/contracts/agent-protocol";
import type { Volume } from "@zerobyte/contracts/volumes";
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
@@ -55,7 +59,7 @@ const backupVolume = {
const readyPayload = {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
protocolVersion: AGENT_PROTOCOL_VERSION,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
@@ -205,11 +209,11 @@ test("websocket lifecycle updates agent connection status", async () => {
agentKind: LOCAL_AGENT_KIND,
});
expect(agentsServiceMocks.markAgentOnline).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number), {
platform: "linux",
backup: true,
protocolVersion: 1,
protocolVersion: AGENT_PROTOCOL_VERSION,
protocolCompatible: true,
hostname: "host",
platform: "linux",
});
expect(agentsServiceMocks.markAgentSeen).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number));
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith(LOCAL_AGENT_ID);
@@ -230,7 +234,7 @@ test("websocket protocol rejection forwards the event and closes the connection"
JSON.stringify({
type: "agent.ready",
payload: {
protocolVersion: 2,
protocolVersion: SUPPORTED_AGENT_PROTOCOL_MAX_VERSION + 1,
hostname: "host",
platform: "linux",
},

View File

@@ -4,6 +4,7 @@ import waitForExpect from "wait-for-expect";
import { fromPartial } from "@total-typescript/shoehorn";
import {
createAgentMessage,
AGENT_PROTOCOL_VERSION,
SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
type AgentMessage,
} from "@zerobyte/contracts/agent-protocol";
@@ -141,7 +142,7 @@ test("invalid inbound messages are ignored", () => {
session.handleMessage(
createAgentMessage("agent.ready", {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
protocolVersion: AGENT_PROTOCOL_VERSION,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
@@ -166,7 +167,7 @@ test("agent.ready marks the session ready and forwards the event", () => {
session.handleMessage(
createAgentMessage("agent.ready", {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
protocolVersion: AGENT_PROTOCOL_VERSION,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
@@ -179,7 +180,7 @@ test("agent.ready marks the session ready and forwards the event", () => {
type: "agent.ready",
payload: {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
protocolVersion: AGENT_PROTOCOL_VERSION,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
@@ -214,7 +215,7 @@ test("backup agent messages are forwarded unchanged", () => {
session.handleMessage(
createAgentMessage("agent.ready", {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
protocolVersion: AGENT_PROTOCOL_VERSION,
hostname: "host",
platform: "linux",
capabilities: { backup: true },

View File

@@ -56,6 +56,7 @@ const ensureLocalAgent = async () => {
const markAgentConnecting = async (params: AgentConnectionRegistration) => {
const { agentId, organizationId, agentName, agentKind, capabilities, connectedAt = Date.now() } = params;
const nextCapabilities = capabilities ?? LOCAL_AGENT_CAPABILITIES;
await db
.insert(agentsTable)
@@ -65,7 +66,7 @@ const markAgentConnecting = async (params: AgentConnectionRegistration) => {
name: agentName,
kind: agentKind,
status: "connecting",
capabilities: capabilities ?? {},
capabilities: nextCapabilities,
lastSeenAt: connectedAt,
updatedAt: connectedAt,
})
@@ -78,7 +79,7 @@ const markAgentConnecting = async (params: AgentConnectionRegistration) => {
status: "connecting",
lastSeenAt: connectedAt,
updatedAt: connectedAt,
capabilities: capabilities ?? {},
capabilities: nextCapabilities,
},
});
@@ -95,7 +96,7 @@ const updateAgentRuntime = async (agentId: string, values: Partial<Agent>) => {
return updatedAgent;
};
const markAgentOnline = async (agentId: string, readyAt = Date.now(), metadata?: AgentCapabilities) => {
const markAgentOnline = async (agentId: string, readyAt = Date.now(), metadata = LOCAL_AGENT_CAPABILITIES) => {
return updateAgentRuntime(agentId, {
status: "online",
capabilities: metadata,

View File

@@ -121,6 +121,7 @@ describe("repositories security", () => {
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots" },
{ method: "POST", path: "/api/v1/repositories/test-repo/snapshots/refresh" },
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/restore-plan" },
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/files" },
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/dump" },
{ method: "POST", path: "/api/v1/repositories/test-repo/restore" },

View File

@@ -526,7 +526,7 @@ describe("repositoriesService.dumpSnapshot", () => {
);
});
test("downloads the full snapshot from root when source paths are non-posix", async () => {
test("downloads the full snapshot from root when source paths are native Windows paths", async () => {
const { organizationId, userId, shortId } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-windows",
basePath: "/tmp/repro/source",
@@ -574,6 +574,17 @@ describe("repositoriesService.restoreSnapshot", () => {
});
};
const withPlatform = async <T>(platform: NodeJS.Platform, run: () => Promise<T>) => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: platform });
try {
return await run();
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
};
beforeEach(() => {
originalEnableLocalAgent = config.flags.enableLocalAgent;
config.flags.enableLocalAgent = true;
@@ -647,11 +658,10 @@ describe("repositoriesService.restoreSnapshot", () => {
expect.objectContaining({
payload: expect.objectContaining({
snapshotId: "snapshot-restore",
target: targetPath,
snapshotPaths: ["/var/lib/zerobyte/volumes/vol123/_data"],
repositoryConfig: expect.objectContaining({ backend: "local" }),
options: expect.objectContaining({
organizationId,
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
request: expect.objectContaining({
location: { kind: "custom", targetPath },
}),
}),
}),
@@ -768,7 +778,7 @@ describe("repositoriesService.restoreSnapshot", () => {
name: "Remote Agent",
kind: "remote",
status: "online",
capabilities: {},
capabilities: { platform: "linux" },
updatedAt: Date.now(),
});
vi.spyOn(restic, "snapshots").mockReturnValue(
@@ -802,7 +812,9 @@ describe("repositoriesService.restoreSnapshot", () => {
agentId,
expect.objectContaining({
payload: expect.objectContaining({
target: targetPath,
request: expect.objectContaining({
location: { kind: "custom", targetPath },
}),
}),
}),
);
@@ -830,7 +842,7 @@ describe("repositoriesService.restoreSnapshot", () => {
name: "Other Org Agent",
kind: "remote",
status: "online",
capabilities: {},
capabilities: { platform: "linux" },
updatedAt: Date.now(),
});
vi.spyOn(restic, "snapshots").mockReturnValue(
@@ -895,7 +907,7 @@ describe("repositoriesService.restoreSnapshot", () => {
});
});
test("rejects original-location restore for snapshots with non-posix source paths", async () => {
test("rejects original-location restore for Windows snapshot paths on POSIX hosts", async () => {
const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([
"d:\\some\\path",
]);
@@ -912,7 +924,112 @@ describe("repositoriesService.restoreSnapshot", () => {
expect(restoreMock).not.toHaveBeenCalled();
});
test("allows restore-all to a custom target for snapshots with non-posix source paths", async () => {
test("allows original-location restore for Windows snapshot paths on Windows hosts", async () => {
const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([
"C:\\Users\\nicolas\\Photos",
"C:\\Users\\nicolas\\Documents",
]);
await withPlatform("win32", () =>
withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore"),
),
);
await waitForExpect(() => {
expect(restoreMock).toHaveBeenCalledWith(
"local",
expect.objectContaining({
payload: expect.objectContaining({
snapshotId: "snapshot-restore",
snapshotPaths: ["C:\\Users\\nicolas\\Photos", "C:\\Users\\nicolas\\Documents"],
request: expect.objectContaining({
location: { kind: "original" },
}),
}),
}),
);
});
});
test("keeps the selected Windows file in its original folder", async () => {
const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([
"C:\\Users\\Nicolas\\Downloads",
]);
await withPlatform("win32", () =>
withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
include: ["/C/Users/Nicolas/Downloads/DumpStack.log"],
selectedItemKind: "file",
}),
),
);
await waitForExpect(() => {
expect(restoreMock).toHaveBeenCalledWith(
"local",
expect.objectContaining({
payload: expect.objectContaining({
request: expect.objectContaining({
location: { kind: "original" },
include: ["/C/Users/Nicolas/Downloads/DumpStack.log"],
selectedItemKind: "file",
}),
}),
}),
);
});
});
test("uses the target agent platform for target-scoped restore plans", async () => {
const organizationId = session.organizationId;
const agentId = `agent-${randomUUID()}`;
const repository = await createTestRepository(organizationId, {
type: "s3",
config: {
backend: "s3",
endpoint: "https://s3.example.com",
bucket: "bucket",
accessKeyId: "access-key",
secretAccessKey: "secret-key",
},
});
await db.insert(agentsTable).values({
id: agentId,
organizationId,
name: "Windows Agent",
kind: "remote",
status: "online",
capabilities: { platform: "win32" },
updatedAt: Date.now(),
});
vi.spyOn(restic, "snapshots").mockReturnValue(
Effect.succeed([
{
id: "snapshot-restore",
short_id: "snapshot-restore",
time: new Date().toISOString(),
paths: ["C:\\Users\\nicolas\\Photos", "C:\\Users\\nicolas\\Documents"],
hostname: "host",
},
]),
);
const plan = await withPlatform("linux", () =>
withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.getSnapshotRestorePlan(repository.shortId, "snapshot-restore", {
targetAgentId: agentId,
}),
),
);
expect(plan).toEqual({
queryBasePath: "/C/Users/nicolas",
requiresCustomTarget: false,
});
});
test("allows restore-all to a custom target for snapshots with native Windows source paths", async () => {
const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([
"d:\\some\\path",
]);
@@ -934,11 +1051,10 @@ describe("repositoriesService.restoreSnapshot", () => {
expect.objectContaining({
payload: expect.objectContaining({
snapshotId: "snapshot-restore",
target: targetPath,
snapshotPaths: ["d:\\some\\path"],
repositoryConfig: expect.objectContaining({ backend: "local" }),
options: expect.objectContaining({
organizationId,
basePath: "/",
request: expect.objectContaining({
location: { kind: "custom", targetPath },
}),
}),
}),

View File

@@ -1,6 +1,6 @@
import { BadRequestError } from "http-errors-enhanced";
import path from "node:path";
import { findCommonAncestor, normalizeAbsolutePath } from "@zerobyte/core/utils";
import { getSnapshotSourcePathPlan, normalizeAbsolutePath } from "@zerobyte/core/utils";
const sanitizeFilenamePart = (value: string): string => {
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+|_+$/g, "");
@@ -16,8 +16,7 @@ export const prepareSnapshotDump = (params: {
const archiveFilename = `snapshot-${sanitizeFilenamePart(snapshotId)}.tar`;
const normalizedRequestedPath = normalizeAbsolutePath(requestedPath);
const hasNonPosixSnapshotPaths = snapshotPaths.some((snapshotPath) => !snapshotPath.startsWith("/"));
const basePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshotPaths);
const { queryBasePath: basePath } = getSnapshotSourcePathPlan({ snapshotPaths });
if (basePath === "/") {
return {

View File

@@ -16,6 +16,8 @@ import {
getRepositoryStatsDto,
refreshRepositoryStatsDto,
getSnapshotDetailsDto,
getSnapshotRestorePlanDto,
getSnapshotRestorePlanQuery,
refreshSnapshotsDto,
listRcloneRemotesDto,
listRepositoriesDto,
@@ -43,6 +45,7 @@ import {
type GetRepositoryStatsDto,
type RefreshRepositoryStatsDto,
type GetSnapshotDetailsDto,
type GetSnapshotRestorePlanDto,
type RefreshSnapshotsDto,
type ListRepositoriesDto,
type ListSnapshotFilesDto,
@@ -168,6 +171,19 @@ export const repositoriesController = new Hono()
return c.json<GetSnapshotDetailsDto>(response, 200);
})
.get(
"/:shortId/snapshots/:snapshotId/restore-plan",
getSnapshotRestorePlanDto,
validator("query", getSnapshotRestorePlanQuery),
async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const snapshotId = c.req.param("snapshotId");
const query = c.req.valid("query");
const result = await repositoriesService.getSnapshotRestorePlan(shortId, snapshotId, query);
return c.json<GetSnapshotRestorePlanDto>(result, 200);
},
)
.get(
"/:shortId/snapshots/:snapshotId/files",
listSnapshotFilesDto,

View File

@@ -205,6 +205,15 @@ const snapshotSchema = z.object({
summary: resticSnapshotSummarySchema.optional(),
});
const snapshotSourcePathPlanSchema = z.object({
queryBasePath: z.string(),
requiresCustomTarget: z.boolean(),
});
export const getSnapshotRestorePlanQuery = z.object({
targetAgentId: z.string().optional(),
});
const listSnapshotsResponse = snapshotSchema.array();
export type ListSnapshotsDto = z.infer<typeof listSnapshotsResponse>;
@@ -249,6 +258,35 @@ export const getSnapshotDetailsDto = describeRoute({
},
});
const getSnapshotRestorePlanResponse = snapshotSourcePathPlanSchema;
export type GetSnapshotRestorePlanDto = z.infer<typeof getSnapshotRestorePlanResponse>;
export const getSnapshotRestorePlanDto = describeRoute({
description: "Get a target-scoped restore plan for a snapshot",
tags: ["Repositories"],
operationId: "getSnapshotRestorePlan",
parameters: [
{
in: "query",
name: "targetAgentId",
required: false,
schema: { type: "string" },
description: "Agent that will execute the restore. Defaults to the local agent.",
},
],
responses: {
200: {
description: "Target-scoped snapshot restore plan",
content: {
"application/json": {
schema: resolver(getSnapshotRestorePlanResponse),
},
},
},
},
});
const snapshotFileNodeSchema = z.object({
name: z.string(),
type: z.string(),

View File

@@ -8,7 +8,11 @@ import {
type RepositoryConfig,
type ResticDumpStream,
type ResticStatsDto,
createSnapshotRestoreExecutionPlan,
getSnapshotRestoreTargetPlan,
isSnapshotRestorePlanningError,
repositoryConfigSchema,
type SnapshotRestoreRequest,
} from "@zerobyte/core/restic";
import { isPathWithin } from "@zerobyte/core/utils";
import { config as appConfig } from "~/server/core/config";
@@ -27,7 +31,6 @@ import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@ze
import { restic, resticDeps } from "../../core/restic";
import { safeSpawn } from "@zerobyte/core/node";
import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto";
import { findCommonAncestor } from "@zerobyte/core/utils";
import { prepareSnapshotDump } from "./helpers/dump";
import { executeDoctor } from "./helpers/doctor";
import { restoreExecutor } from "./restore-executor";
@@ -236,13 +239,70 @@ const finishRestoreExecution = async (restoreId: string, resultPromise: Promise<
const assertAllowedRestoreAgent = async (agentId: string, organizationId: string) => {
if (agentId === LOCAL_AGENT_ID) {
return;
return null;
}
const agent = await agentsService.getAgent(agentId);
if (!agent || agent.organizationId !== organizationId) {
throw new NotFoundError("Restore target agent not found");
}
return agent;
};
const getRestoreAgentPlatform = (agentId: string, agent: Awaited<ReturnType<typeof assertAllowedRestoreAgent>>) => {
if (agentId === LOCAL_AGENT_ID) {
return process.platform;
}
if (!agent) {
throw new Error("Remote restore agent should be available after restore-agent authorization");
}
const platform = agent.capabilities.platform;
if (typeof platform !== "string") {
throw new BadRequestError("Restore target agent platform is unavailable");
}
return platform;
};
const createSnapshotRestoreRequest = ({
targetPath,
...options
}: {
include?: string[];
selectedItemKind?: "file" | "dir";
exclude?: string[];
excludeXattr?: string[];
delete?: boolean;
targetPath?: string;
overwrite?: OverwriteMode;
}): SnapshotRestoreRequest => ({
location: targetPath ? { kind: "custom", targetPath } : { kind: "original" },
...(options.include ? { include: options.include } : {}),
...(options.selectedItemKind ? { selectedItemKind: options.selectedItemKind } : {}),
...(options.exclude ? { exclude: options.exclude } : {}),
...(options.excludeXattr ? { excludeXattr: options.excludeXattr } : {}),
...(options.delete !== undefined ? { delete: options.delete } : {}),
...(options.overwrite ? { overwrite: options.overwrite } : {}),
});
const assertAllowedControllerLocalRestoreRequest = (snapshotPaths: string[], request: SnapshotRestoreRequest) => {
try {
const plan = createSnapshotRestoreExecutionPlan({
snapshotPaths,
platform: process.platform,
request,
});
assertAllowedControllerLocalRestoreTarget(plan.target);
} catch (error) {
if (isSnapshotRestorePlanningError(error)) {
throw new BadRequestError(error.message);
}
throw error;
}
};
const findRepository = async (shortId: ShortId) => {
@@ -541,21 +601,16 @@ const restoreSnapshot = async (
}
const { targetAgentId, targetPath, ...restoreExecutionOptions } = options ?? {};
const target = targetPath || "/";
const snapshot = await getSnapshotDetails(repository.shortId, snapshotId);
const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/"));
if (hasNonPosixSnapshotPaths && !targetPath) {
throw new BadRequestError(
"Original location restore is unavailable for this snapshot. Restore it to a custom location instead.",
);
}
const basePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths);
const executionAgentId = targetAgentId ?? LOCAL_AGENT_ID;
const useControllerLocalRestoreFallback = executionAgentId === LOCAL_AGENT_ID && !appConfig.flags.enableLocalAgent;
await assertAllowedRestoreAgent(executionAgentId, organizationId);
const snapshot = await getSnapshotDetails(repository.shortId, snapshotId);
const restoreRequest = createSnapshotRestoreRequest({ targetPath, ...restoreExecutionOptions });
const useControllerLocalRestoreFallback = executionAgentId === LOCAL_AGENT_ID && !appConfig.flags.enableLocalAgent;
let taskTargetAgentId: string | null = executionAgentId;
if (useControllerLocalRestoreFallback) {
taskTargetAgentId = null;
}
if (!useControllerLocalRestoreFallback && repository.type === "local" && executionAgentId !== LOCAL_AGENT_ID) {
throw new BadRequestError(
@@ -564,7 +619,7 @@ const restoreSnapshot = async (
}
if (executionAgentId === LOCAL_AGENT_ID) {
assertAllowedControllerLocalRestoreTarget(target);
assertAllowedControllerLocalRestoreRequest(snapshot.paths, restoreRequest);
}
const activeRestore = findActiveRestoreTask(organizationId, repository.shortId, snapshotId);
@@ -576,8 +631,14 @@ const restoreSnapshot = async (
organizationId,
resourceType: RESTORE_TASK_RESOURCE_TYPE,
resourceId: repository.shortId,
targetAgentId: useControllerLocalRestoreFallback ? null : executionAgentId,
input: { kind: "restore", repositoryId: repository.shortId, snapshotId, target },
targetAgentId: taskTargetAgentId,
input: {
kind: "restore",
repositoryId: repository.shortId,
snapshotId,
restoreLocation: restoreRequest.location.kind,
...(restoreRequest.location.kind === "custom" ? { targetPath: restoreRequest.location.targetPath } : {}),
},
});
const restoreId = task.id;
try {
@@ -589,12 +650,9 @@ const restoreSnapshot = async (
repositoryShortId: repository.shortId,
repositoryConfig,
snapshotId,
target,
snapshotPaths: snapshot.paths,
restoreRequest,
executionAgentId,
options: {
basePath,
...restoreExecutionOptions,
},
onStarted: () => markRestoreStarted(restoreId),
onProgress: (progress) => updateRestoreProgress(restoreId, progress),
});
@@ -704,6 +762,17 @@ const getSnapshotDetails = async (shortId: ShortId, snapshotId: string) => {
return snapshot;
};
const getSnapshotRestorePlan = async (shortId: ShortId, snapshotId: string, options?: { targetAgentId?: string }) => {
const organizationId = getOrganizationId();
const executionAgentId = options?.targetAgentId ?? LOCAL_AGENT_ID;
const targetAgent = await assertAllowedRestoreAgent(executionAgentId, organizationId);
const platform = getRestoreAgentPlatform(executionAgentId, targetAgent);
const snapshot = await getSnapshotDetails(shortId, snapshotId);
return getSnapshotRestoreTargetPlan({ snapshotPaths: snapshot.paths, platform });
};
const checkHealth = async (shortId: ShortId) => {
const organizationId = getOrganizationId();
const repository = await findRepository(shortId);
@@ -1089,6 +1158,7 @@ export const repositoriesService = {
restoreSnapshot,
dumpSnapshot,
getSnapshotDetails,
getSnapshotRestorePlan,
checkHealth,
startDoctor,
cancelDoctor,

View File

@@ -1,4 +1,8 @@
import type { RepositoryConfig } from "@zerobyte/core/restic";
import {
createSnapshotRestoreExecutionPlan,
type RepositoryConfig,
type SnapshotRestoreRequest,
} from "@zerobyte/core/restic";
import type { RestoreRunPayload } from "@zerobyte/contracts/agent-protocol";
import { config as appConfig } from "~/server/core/config";
import { repoMutex } from "../../core/repository-mutex";
@@ -7,8 +11,6 @@ import { runEffectPromise, toMessage } from "../../utils/errors";
import { agentManager, type RestoreExecutionProgress, type RestoreExecutionResult } from "../agents/agents-manager";
import { LOCAL_AGENT_ID } from "../agents/constants";
type RestoreExecutionOptions = Omit<Parameters<typeof restic.restore>[3], "organizationId" | "signal" | "onProgress">;
type RestoreExecutionRequest = {
restoreId: string;
organizationId: string;
@@ -16,9 +18,9 @@ type RestoreExecutionRequest = {
repositoryShortId: string;
repositoryConfig: RepositoryConfig;
snapshotId: string;
target: string;
snapshotPaths: string[];
restoreRequest: SnapshotRestoreRequest;
executionAgentId: string;
options: RestoreExecutionOptions;
onStarted: () => void;
onProgress: (progress: RestoreExecutionProgress) => void;
};
@@ -38,13 +40,10 @@ const createRestoreRunPayload = async (request: RestoreExecutionRequest): Promis
organizationId: request.organizationId,
repositoryId: request.repositoryShortId,
snapshotId: request.snapshotId,
target: request.target,
snapshotPaths: request.snapshotPaths,
repositoryConfig: request.repositoryConfig,
runtime: { password: resticPassword },
options: {
...request.options,
organizationId: request.organizationId,
},
request: request.restoreRequest,
};
};
@@ -56,12 +55,18 @@ const executeControllerRestore = async (
return { status: "cancelled", message: "Restore was cancelled" };
}
request.onStarted();
try {
const plan = createSnapshotRestoreExecutionPlan({
snapshotPaths: request.snapshotPaths,
platform: process.platform,
request: request.restoreRequest,
});
request.onStarted();
const result = await runEffectPromise(
restic.restore(request.repositoryConfig, request.snapshotId, request.target, {
...request.options,
restic.restore(request.repositoryConfig, request.snapshotId, plan.target, {
...plan.options,
organizationId: request.organizationId,
signal,
onProgress: request.onProgress,

View File

@@ -55,7 +55,13 @@ const createRestoreTask = (overrides: Partial<Parameters<typeof taskStore.create
resourceType: "repository",
resourceId: "repo-short",
targetAgentId: "local",
input: { kind: "restore", repositoryId: "repo-short", snapshotId: "snapshot-1", target: "/tmp/restore" },
input: {
kind: "restore",
repositoryId: "repo-short",
snapshotId: "snapshot-1",
restoreLocation: "custom",
targetPath: "/tmp/restore",
},
...overrides,
});

View File

@@ -24,7 +24,8 @@ export const taskInputSchema = z.discriminatedUnion("kind", [
kind: z.literal("restore"),
repositoryId: z.string(),
snapshotId: z.string(),
target: z.string(),
restoreLocation: z.enum(["original", "custom"]).optional(),
targetPath: z.string().optional(),
}),
]);

View File

@@ -18,10 +18,10 @@ const createRunPayload = (overrides: Partial<RestoreRunPayload> = {}) =>
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
target: `${process.cwd()}/restore-target`,
snapshotPaths: [`${process.cwd()}/source`],
repositoryConfig: { backend: "local", path: "/tmp/repository" },
runtime: { password: "password" },
options: { organizationId: "org-1", basePath: "/" },
request: { location: { kind: "custom", targetPath: `${process.cwd()}/restore-target` } },
...overrides,
});

View File

@@ -58,7 +58,11 @@ test("runs backend-backed volume commands on the agent host", async () => {
commandId: "command-1",
command: {
name: "volume.mount",
volume: { id: 1, config: { backend: "directory", path: "/tmp/source" }, provisioningId: undefined },
volume: {
id: 1,
config: { backend: "directory", path: "/tmp/source" },
provisioningId: undefined,
},
},
}),
);

View File

@@ -2,6 +2,7 @@ import os from "node:os";
import path from "node:path";
import { Effect, Runtime } from "effect";
import { createAgentMessage, type RestoreRunPayload } from "@zerobyte/contracts/agent-protocol";
import { createSnapshotRestoreExecutionPlan } from "@zerobyte/core/restic";
import { createRestic } from "@zerobyte/core/restic/server";
import { isPathWithin, toMessage } from "@zerobyte/core/utils";
import { logger } from "@zerobyte/core/node";
@@ -52,15 +53,21 @@ export const handleRestoreRunCommand = (context: ControllerCommandContext, paylo
yield* Effect.fork(
Effect.gen(function* () {
assertAllowedRestoreTarget(payload.target);
const plan = createSnapshotRestoreExecutionPlan({
snapshotPaths: payload.snapshotPaths,
platform: process.platform,
request: payload.request,
});
assertAllowedRestoreTarget(plan.target);
const runtime = yield* Effect.runtime<never>();
const restic = createRestic(resticDeps(payload.runtime.password));
yield* context.offerOutbound(createAgentMessage("restore.started", restoreContext));
const result = yield* restic.restore(payload.repositoryConfig, payload.snapshotId, payload.target, {
...payload.options,
const result = yield* restic.restore(payload.repositoryConfig, payload.snapshotId, plan.target, {
...plan.options,
organizationId: payload.organizationId,
signal: abortController.signal,
onProgress: (progress) => {
void Runtime.runPromise(

View File

@@ -145,7 +145,12 @@ export const createControllerSession = (ws: WebSocket): ControllerSession => {
protocolVersion: AGENT_PROTOCOL_VERSION,
hostname: resolveResticHostname(),
platform: process.platform,
capabilities: { backup: true, restore: true, volume: true, restic: true },
capabilities: {
backup: true,
restore: true,
volume: true,
restic: true,
},
}),
),
).catch((error) => {

View File

@@ -62,5 +62,5 @@ test("listVolumeFiles rejects traversal outside the volume", async () => {
test("listVolumeFiles reports missing directories consistently", async () => {
const volume = await createDirectoryVolume();
await expect(listVolumeFiles(volume, "missing", 0, 10)).rejects.toThrow("Directory not found");
await expect(listVolumeFiles(volume, "/missing", 0, 10)).rejects.toThrow("Directory not found");
});

View File

@@ -2,13 +2,28 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { BackendConfig, Volume as AgentVolume } from "@zerobyte/contracts/volumes";
import { toMessage } from "@zerobyte/core/utils";
import { normalizeAbsolutePath, toMessage } from "@zerobyte/core/utils";
import { Data, Effect } from "effect";
import { createVolumeBackend, getVolumePath, isNodeJSErrnoException } from ".";
const DEFAULT_PAGE_SIZE = 500;
const MAX_PAGE_SIZE = 500;
const normalizeVolumePath = (value: string) => {
if (!value.startsWith("/")) {
throw new Error("Invalid path");
}
return normalizeAbsolutePath(value);
};
const resolveVolumePath = (volumePath: string, volumeRelativePath: string) => {
const segments = volumeRelativePath.slice(1).split("/").filter(Boolean);
return path.join(volumePath, ...segments);
};
const toVolumePath = (nativeRelativePath: string) => normalizeVolumePath(`/${nativeRelativePath.replace(/\\/g, "/")}`);
export const listVolumeFiles = async (
volume: AgentVolume,
subPath?: string,
@@ -16,7 +31,13 @@ export const listVolumeFiles = async (
limit: number = DEFAULT_PAGE_SIZE,
) => {
const volumePath = getVolumePath(volume);
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
let requestPath: string;
try {
requestPath = normalizeVolumePath(subPath ?? "/");
} catch {
throw new Error("Invalid path");
}
const requestedPath = resolveVolumePath(volumePath, requestPath);
const normalizedPath = path.normalize(requestedPath);
const requestedRelativePath = path.relative(volumePath, normalizedPath);
@@ -66,7 +87,7 @@ export const listVolumeFiles = async (
return {
name: dirent.name,
path: `/${relativePath}`,
path: toVolumePath(relativePath),
type: dirent.isDirectory() ? ("directory" as const) : ("file" as const),
size: dirent.isFile() ? stats.size : undefined,
modifiedAt: stats.mtimeMs,
@@ -80,7 +101,7 @@ export const listVolumeFiles = async (
return {
files: entries,
path: subPath || "/",
path: requestPath,
offset: startOffset,
limit: pageSize,
total,

View File

@@ -8,6 +8,7 @@ import {
resticRestoreOutputSchema,
restoreProgressSchema,
type CompressionMode,
type SnapshotRestoreRequest,
} from "@zerobyte/core/restic";
import {
browseFilesystemResponseSchema,
@@ -126,22 +127,28 @@ const restoreIdentitySchema = z.object({
snapshotId: z.string(),
});
const overwriteModeSchema = z.enum(["always", "if-changed", "if-newer", "never"]);
const snapshotRestoreRequestSchema = z.object({
location: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("original") }),
z.object({ kind: z.literal("custom"), targetPath: z.string() }),
]),
include: z.array(z.string()).optional(),
selectedItemKind: z.enum(["file", "dir"]).optional(),
exclude: z.array(z.string()).optional(),
excludeXattr: z.array(z.string()).optional(),
delete: z.boolean().optional(),
overwrite: overwriteModeSchema.optional(),
}) satisfies z.ZodType<SnapshotRestoreRequest>;
const restoreRunSchema = z.object({
type: z.literal("restore.run"),
payload: restoreIdentitySchema.extend({
target: z.string(),
snapshotPaths: z.array(z.string()),
repositoryConfig: repositoryConfigSchema,
runtime: commandRuntimeSchema,
options: z.object({
basePath: z.string().optional(),
organizationId: z.string(),
include: z.array(z.string()).optional(),
selectedItemKind: z.enum(["file", "dir"]).optional(),
exclude: z.array(z.string()).optional(),
excludeXattr: z.array(z.string()).optional(),
delete: z.boolean().optional(),
overwrite: z.enum(["always", "if-changed", "if-newer", "never"]).optional(),
}),
request: snapshotRestoreRequestSchema,
}),
});

View File

@@ -186,6 +186,80 @@ describe("restore command", () => {
expect(separatorIndex).toBeGreaterThan(-1);
expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data");
});
test("uses restic snapshot tree syntax for Windows base paths restored to drive roots", async () => {
const { getRestoreArg, getOptionValues } = setup();
await runRestore(
config,
"snapshot-windows",
"C:\\",
{
organizationId: "org-1",
basePath: "C:\\Users\\nicolas",
},
mockDeps,
);
expect(getOptionValues("--target")).toEqual(["C:\\"]);
expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/nicolas");
});
test("keeps the precomputed Windows original-location target for restore-all", async () => {
const { getRestoreArg, getOptionValues } = setup();
await runRestore(
config,
"snapshot-windows",
"C:\\Users",
{
organizationId: "org-1",
basePath: "/C/Users/nicolas",
},
mockDeps,
);
expect(getOptionValues("--target")).toEqual(["C:\\Users"]);
expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/nicolas");
});
test("keeps the precomputed Windows original-location target for selections", async () => {
const { getRestoreArg, getOptionValues } = setup();
await runRestore(
config,
"snapshot-windows",
"C:\\Users\\nicolas",
{
organizationId: "org-1",
include: ["/C/Users/nicolas/Downloads"],
selectedItemKind: "dir",
},
mockDeps,
);
expect(getOptionValues("--target")).toEqual(["C:\\Users\\nicolas"]);
expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/nicolas/Downloads");
});
test("strips selected Windows directories for custom drive-root targets", async () => {
const { getRestoreArg, getOptionValues } = setup();
await runRestore(
config,
"snapshot-windows",
"C:\\",
{
organizationId: "org-1",
include: ["C:\\Users\\nicolas\\Documents"],
selectedItemKind: "dir",
},
mockDeps,
);
expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/nicolas/Documents");
expect(getOptionValues("--include")).toEqual([]);
});
});
describe("output handling", () => {

View File

@@ -1,5 +1,3 @@
import path from "node:path";
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";
@@ -16,14 +14,13 @@ import {
import type { ResticDeps } from "../types";
import { Data, Effect } from "effect";
import { toMessage } from "../../utils";
import { createRestorePathArgs } from "../helpers/restore-paths";
class ResticRestoreCommandError extends Data.TaggedError("ResticRestoreCommandError")<{
cause: unknown;
message: string;
}> {}
const escapeResticIncludePattern = (pattern: string) => pattern.replace(/[\\*?[\]]/g, "\\$&");
export const restore = (
config: RepositoryConfig,
snapshotId: string,
@@ -50,12 +47,13 @@ export const restore = (
(env) => Effect.promise(() => cleanupTemporaryKeys(env, deps)),
);
const includes = options.include?.length ? options.include : [options.basePath ?? "/"];
const commonAncestor =
options.selectedItemKind === "file" && includes.length === 1
? path.posix.dirname(includes[0] ?? "/")
: findCommonAncestor(includes);
const restoreArg = target === "/" ? snapshotId : `${snapshotId}:${commonAncestor}`;
const pathArgs = createRestorePathArgs({
snapshotId,
target,
basePath: options.basePath,
include: options.include,
selectedItemKind: options.selectedItemKind,
});
const args = ["--repo", repoUrl, "restore", "--target", target];
@@ -63,25 +61,8 @@ export const restore = (
args.push("--overwrite", options.overwrite);
}
if (options.include?.length) {
if (target === "/") {
for (const pattern of options.include) {
args.push("--include", escapeResticIncludePattern(pattern));
}
} else {
const strippedIncludes = options.include.map((pattern) =>
path.posix.relative(commonAncestor, pattern),
);
const includesCoverRestoreRoot = strippedIncludes.some(
(pattern) => pattern === "" || pattern === ".",
);
if (!includesCoverRestoreRoot) {
for (const pattern of strippedIncludes) {
args.push("--include", escapeResticIncludePattern(pattern));
}
}
}
for (const pattern of pathArgs.includePatterns) {
args.push("--include", pattern);
}
if (options.exclude?.length) {
@@ -97,7 +78,7 @@ export const restore = (
}
addCommonArgs(args, env, config);
args.push("--", restoreArg);
args.push("--", pathArgs.restoreArg);
const onProgress = options.onProgress;
const resticProgressFps = process.env.RESTIC_PROGRESS_FPS ?? "1";

View File

@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { createSnapshotRestoreExecutionPlan, SnapshotRestorePlanningError } from "../snapshot-restore-plan";
describe("snapshot restore planning", () => {
test("plans a POSIX custom-target restore from the snapshot source base", () => {
const plan = createSnapshotRestoreExecutionPlan({
snapshotPaths: ["/var/lib/zerobyte/volumes/vol123/_data"],
platform: "linux",
request: { location: { kind: "custom", targetPath: "/restore-target" } },
});
expect(plan).toMatchObject({
target: "/restore-target",
options: { basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
});
});
test("plans Windows original-location restores on Windows hosts", () => {
const plan = createSnapshotRestoreExecutionPlan({
snapshotPaths: ["C:\\Users\\nicolas\\Photos", "C:\\Users\\nicolas\\Documents"],
platform: "win32",
request: { location: { kind: "original" } },
});
expect(plan).toMatchObject({
target: "C:\\Users\\nicolas",
options: { basePath: "/C/Users/nicolas" },
});
});
test("requires custom targets for Windows original-location restores on POSIX hosts", () => {
expect(() =>
createSnapshotRestoreExecutionPlan({
snapshotPaths: ["C:\\Users\\nicolas\\Photos"],
platform: "linux",
request: { location: { kind: "original" } },
}),
).toThrow(SnapshotRestorePlanningError);
});
});

View File

@@ -0,0 +1,99 @@
import path from "node:path";
import { findCommonAncestor } from "../../utils/common-ancestor";
import { windowsHostPathToResticSnapshotPath } from "../../utils/path";
type RestorePathInput = {
snapshotId: string;
target: string;
basePath?: string;
include?: string[];
selectedItemKind?: "file" | "dir";
};
type RestorePathArgs = {
restoreArg: string;
includePatterns: string[];
};
const escapeResticIncludePattern = (pattern: string): string => pattern.replace(/[\\*?[\]]/g, "\\$&");
const toResticSnapshotPath = (snapshotPath: string): string => {
return windowsHostPathToResticSnapshotPath(snapshotPath) ?? snapshotPath;
};
export const findResticCommonAncestor = (snapshotPaths: string[]): string => {
const resticPaths = snapshotPaths.map(toResticSnapshotPath);
if (resticPaths.every((snapshotPath) => snapshotPath.startsWith("/"))) {
return findCommonAncestor(resticPaths);
}
if (resticPaths.some((snapshotPath) => snapshotPath.startsWith("/"))) {
throw new Error("Snapshot paths must use a single path format.");
}
if (resticPaths.length === 0) return "/";
if (resticPaths.length === 1) return resticPaths[0] || "/";
const splitPaths = resticPaths.map((snapshotPath) => snapshotPath.split("/").filter(Boolean));
const minLength = Math.min(...splitPaths.map((parts) => parts.length));
const commonParts: string[] = [];
for (let i = 0; i < minLength; i++) {
const firstPart = splitPaths[0]?.[i];
if (!firstPart) break;
if (splitPaths.every((parts) => parts[i]?.toLowerCase() === firstPart.toLowerCase())) {
commonParts.push(firstPart);
} else {
break;
}
}
if (!commonParts.length) {
throw new Error("Snapshot paths must be on the same drive.");
}
return commonParts.join("/");
};
export const getResticRestoreRoot = (includes: string[], selectedItemKind?: "file" | "dir"): string => {
if (selectedItemKind === "file" && includes.length === 1) {
return path.posix.dirname(toResticSnapshotPath(includes[0] ?? "/"));
}
return findResticCommonAncestor(includes);
};
const getIncludePatterns = (include: string[] | undefined, target: string, restoreRoot: string): string[] => {
if (!include?.length) return [];
if (target === "/") {
return include.map((pattern) => escapeResticIncludePattern(toResticSnapshotPath(pattern)));
}
const strippedIncludes = include.map((pattern) => path.posix.relative(restoreRoot, toResticSnapshotPath(pattern)));
const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === ".");
if (includesCoverRestoreRoot) {
return [];
}
return strippedIncludes.map(escapeResticIncludePattern);
};
export const createRestorePathArgs = ({
snapshotId,
target,
basePath,
include,
selectedItemKind,
}: RestorePathInput): RestorePathArgs => {
const includes = include?.length ? include : [basePath ?? "/"];
const restoreRoot = getResticRestoreRoot(includes, selectedItemKind);
const restoreArg = target === "/" ? snapshotId : `${snapshotId}:${restoreRoot}`;
return {
restoreArg,
includePatterns: getIncludePatterns(include, target, restoreRoot),
};
};

View File

@@ -0,0 +1,117 @@
import {
getOriginalRestoreTargetForRoot,
getSnapshotSourcePathPlan,
hostPathKindFromPlatform,
type SnapshotSourcePathPlan,
} from "../../utils";
import type { OverwriteMode } from "../schemas";
import { getResticRestoreRoot } from "./restore-paths";
export type SnapshotRestoreLocation = { kind: "original" } | { kind: "custom"; targetPath: string };
export type SnapshotRestoreRequest = {
location: SnapshotRestoreLocation;
include?: string[];
selectedItemKind?: "file" | "dir";
exclude?: string[];
excludeXattr?: string[];
delete?: boolean;
overwrite?: OverwriteMode;
};
export type SnapshotRestoreExecutionPlan = {
target: string;
options: {
basePath: string;
include?: string[];
selectedItemKind?: "file" | "dir";
exclude?: string[];
excludeXattr?: string[];
delete?: boolean;
overwrite?: OverwriteMode;
};
sourcePathPlan: SnapshotSourcePathPlan;
};
export type SnapshotRestoreTargetPlan = Pick<SnapshotSourcePathPlan, "queryBasePath" | "requiresCustomTarget">;
export class SnapshotRestorePlanningError extends Error {
constructor(message: string) {
super(message);
this.name = "SnapshotRestorePlanningError";
}
}
export const isSnapshotRestorePlanningError = (error: unknown): error is SnapshotRestorePlanningError => {
return error instanceof SnapshotRestorePlanningError;
};
export const getSnapshotRestoreTargetPlan = ({
snapshotPaths,
platform,
}: {
snapshotPaths: string[];
platform: string;
}): SnapshotRestoreTargetPlan => {
const sourcePathPlan = getSnapshotSourcePathPlan({
snapshotPaths,
hostPathKind: hostPathKindFromPlatform(platform),
});
return {
queryBasePath: sourcePathPlan.queryBasePath,
requiresCustomTarget: sourcePathPlan.requiresCustomTarget,
};
};
export const createSnapshotRestoreExecutionPlan = ({
snapshotPaths,
platform,
request,
}: {
snapshotPaths: string[];
platform: string;
request: SnapshotRestoreRequest;
}): SnapshotRestoreExecutionPlan => {
const sourcePathPlan = getSnapshotSourcePathPlan({
snapshotPaths,
hostPathKind: hostPathKindFromPlatform(platform),
});
const location = request.location;
const isCustomLocation = location.kind === "custom";
if (location.kind === "custom" && location.targetPath.length === 0) {
throw new SnapshotRestorePlanningError("Restore target path is required for custom-location restores.");
}
if (!isCustomLocation && sourcePathPlan.requiresCustomTarget) {
throw new SnapshotRestorePlanningError(
"Original location restore is unavailable for this snapshot. Restore it to a custom location instead.",
);
}
const basePath = isCustomLocation ? sourcePathPlan.customRestoreBasePath : sourcePathPlan.originalRestoreBasePath;
const restoreIncludes = request.include?.length ? request.include : [basePath];
const restoreRoot = getResticRestoreRoot(restoreIncludes, request.selectedItemKind);
const target =
location.kind === "custom"
? location.targetPath
: getOriginalRestoreTargetForRoot({
restoreRoot,
sourcePathKind: sourcePathPlan.sourcePathKind,
});
return {
target,
options: {
basePath,
...(request.include ? { include: request.include } : {}),
...(request.selectedItemKind ? { selectedItemKind: request.selectedItemKind } : {}),
...(request.exclude ? { exclude: request.exclude } : {}),
...(request.excludeXattr ? { excludeXattr: request.excludeXattr } : {}),
...(request.delete !== undefined ? { delete: request.delete } : {}),
...(request.overwrite ? { overwrite: request.overwrite } : {}),
},
sourcePathPlan,
};
};

View File

@@ -1,6 +1,18 @@
export * from "./schemas";
export * from "./restic-dto";
export { isResticError, ResticError, ResticLockError } from "./error";
export {
createSnapshotRestoreExecutionPlan,
getSnapshotRestoreTargetPlan,
isSnapshotRestorePlanningError,
SnapshotRestorePlanningError,
} from "./helpers/snapshot-restore-plan";
export type {
SnapshotRestoreExecutionPlan,
SnapshotRestoreLocation,
SnapshotRestoreRequest,
SnapshotRestoreTargetPlan,
} from "./helpers/snapshot-restore-plan";
export type {
ResticDeps,
ResticEnv,

View File

@@ -23,6 +23,7 @@ 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 { getResticRestoreRoot } from "./helpers/restore-paths";
export { validateCustomResticParams } from "./helpers/validate-custom-params";
export { isResticError, ResticError, ResticLockError } from "./error";

View File

@@ -1,7 +1,13 @@
import path from "node:path";
import fc from "fast-check";
import { describe, expect, test } from "vitest";
import { hasPathListSeparator, isPathWithin, normalizeAbsolutePath } from "../path";
import {
hasPathListSeparator,
isPathWithin,
normalizeAbsolutePath,
windowsHostPathToResticSnapshotPath,
windowsResticSnapshotPathToHostPath,
} from "../path";
const safePathSegmentArb = fc
.array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", ".", " "), {
@@ -94,6 +100,18 @@ describe("isPathWithin", () => {
});
});
describe("Windows restic snapshot path conversion", () => {
test("maps native Windows host paths to restic snapshot paths", () => {
expect(windowsHostPathToResticSnapshotPath("C:\\Users\\nicolas")).toBe("/C/Users/nicolas");
expect(windowsHostPathToResticSnapshotPath("c:/Users/nicolas/")).toBe("/C/Users/nicolas");
});
test("maps restic snapshot paths to native Windows host paths only when called explicitly", () => {
expect(windowsResticSnapshotPathToHostPath("/C/Users/nicolas")).toBe("C:\\Users\\nicolas");
expect(windowsResticSnapshotPathToHostPath("/d/source")).toBe("D:\\source");
});
});
describe("path list character support", () => {
test("allows line breaks in raw path lists", () => {
expect(hasPathListSeparator("Photos", "raw")).toBe(false);

View File

@@ -0,0 +1,98 @@
import { describe, expect, test } from "vitest";
import { getOriginalRestoreTargetForRoot, getSnapshotSourcePathPlan } from "../snapshot-source-paths";
describe("getSnapshotSourcePathPlan", () => {
test("keeps POSIX snapshots restorable to original by default", () => {
expect(
getSnapshotSourcePathPlan({
snapshotPaths: ["/mnt/source/photos", "/mnt/source/docs"],
}),
).toMatchObject({
sourcePathKind: "posix",
queryBasePath: "/mnt/source",
originalRestoreBasePath: "/mnt/source",
customRestoreBasePath: "/mnt/source",
requiresCustomTarget: false,
});
});
test("keeps Windows snapshots on custom-target restore for POSIX hosts", () => {
expect(
getSnapshotSourcePathPlan({
snapshotPaths: ["C:\\Users\\nicolas\\Photos"],
}),
).toMatchObject({
sourcePathKind: "windows",
queryBasePath: "/",
originalRestoreBasePath: "/",
customRestoreBasePath: "/",
requiresCustomTarget: true,
});
});
test("keeps Windows snapshots restorable to original on Windows hosts", () => {
expect(
getSnapshotSourcePathPlan({
snapshotPaths: ["C:\\Users\\nicolas\\Photos", "c:/Users/nicolas/Documents"],
hostPathKind: "windows",
}),
).toMatchObject({
sourcePathKind: "windows",
queryBasePath: "/C/Users/nicolas",
originalRestoreBasePath: "/C/Users/nicolas",
customRestoreBasePath: "/",
requiresCustomTarget: false,
});
});
test("targets the selected Windows restore root itself for original-location restores", () => {
expect(
getOriginalRestoreTargetForRoot({
restoreRoot: "/C/Users/Nicolas/Downloads",
sourcePathKind: "windows",
}),
).toBe("C:\\Users\\Nicolas\\Downloads");
});
test("requires custom target for unsupported native paths", () => {
expect(
getSnapshotSourcePathPlan({
snapshotPaths: ["relative\\path"],
}),
).toMatchObject({
sourcePathKind: "unsupported",
queryBasePath: "/",
originalRestoreBasePath: "/",
customRestoreBasePath: "/",
requiresCustomTarget: true,
});
});
test("requires custom target for mixed POSIX and Windows paths", () => {
expect(
getSnapshotSourcePathPlan({
snapshotPaths: ["C:\\some\\path", "/mnt/source"],
}),
).toMatchObject({
sourcePathKind: "unsupported",
queryBasePath: "/",
originalRestoreBasePath: "/",
customRestoreBasePath: "/",
requiresCustomTarget: true,
});
});
test("requires custom target for Windows paths spanning drives", () => {
expect(
getSnapshotSourcePathPlan({
snapshotPaths: ["C:\\some\\path", "D:\\other\\path"],
hostPathKind: "windows",
}),
).toMatchObject({
queryBasePath: "/",
originalRestoreBasePath: "/",
customRestoreBasePath: "/",
requiresCustomTarget: true,
});
});
});

View File

@@ -1,6 +1,20 @@
export { safeJsonParse } from "./json.js";
export { toErrorDetails, toMessage } from "./errors.js";
export { hasPathListSeparator, isPathWithin, normalizeAbsolutePath } from "./path.js";
export {
hasPathListSeparator,
isPathWithin,
normalizeAbsolutePath,
normalizeWindowsHostPath,
windowsHostPathToResticSnapshotPath,
windowsResticSnapshotPathToHostPath,
} from "./path.js";
export { findCommonAncestor } from "./common-ancestor.js";
export {
getOriginalRestoreTargetForRoot,
getSnapshotSourcePathPlan,
hostPathKindFromPath,
hostPathKindFromPlatform,
} from "./snapshot-source-paths.js";
export type { HostPathKind, SnapshotSourcePathKind, SnapshotSourcePathPlan } from "./snapshot-source-paths.js";
export { DATE_FORMATS, DEFAULT_TIME_FORMAT, inferDateTimePreferences, TIME_FORMATS } from "./datetime.js";
export type { DateFormatPreference, TimeFormatPreference } from "./datetime.js";

View File

@@ -1,3 +1,89 @@
export const isWindowsHostPath = (value: string): boolean => /^[A-Za-z]:(?:[\\/]|$)/.test(value);
export const normalizeWindowsHostPath = (value: string): string | undefined => {
if (!isWindowsHostPath(value)) return undefined;
const parts: string[] = [];
for (const part of value.slice(2).replace(/\\/g, "/").split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
parts.pop();
continue;
}
parts.push(part);
}
return `${value[0]?.toUpperCase()}:\\${parts.join("\\")}`;
};
export const windowsHostPathToResticSnapshotPath = (value: string): string | undefined => {
const normalized = normalizeWindowsHostPath(value);
if (!normalized) return undefined;
const withoutDrive = normalized
.slice(2)
.replace(/\\/g, "/")
.replace(/^\/+|\/+$/g, "");
return withoutDrive ? `/${normalized[0]}/${withoutDrive}` : `/${normalized[0]}`;
};
export const windowsResticSnapshotPathToHostPath = (value: string): string | undefined => {
const match = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value);
if (!match?.[1]) return undefined;
const segments = match[2]?.split("/").filter(Boolean) ?? [];
return `${match[1].toUpperCase()}:\\${segments.join("\\")}`;
};
export const findWindowsHostCommonAncestor = (paths: string[]): string | undefined => {
const normalizedPaths: string[] = [];
for (const path of paths) {
const normalizedPath = normalizeWindowsHostPath(path);
if (!normalizedPath) return undefined;
normalizedPaths.push(normalizedPath);
}
const drive = normalizedPaths[0]?.slice(0, 2);
if (
!drive ||
normalizedPaths.some((normalizedPath) => normalizedPath.slice(0, 2).toLowerCase() !== drive.toLowerCase())
) {
return undefined;
}
const splitPaths = normalizedPaths.map((normalizedPath) => normalizedPath.slice(3).split("\\").filter(Boolean));
const minLength = Math.min(...splitPaths.map((parts) => parts.length));
const commonParts: string[] = [];
for (let i = 0; i < minLength; i++) {
const firstPart = splitPaths[0]?.[i];
if (!firstPart) break;
if (splitPaths.every((parts) => parts[i]?.toLowerCase() === firstPart.toLowerCase())) {
commonParts.push(firstPart);
} else {
break;
}
}
return `${drive.toUpperCase()}\\${commonParts.join("\\")}`;
};
export const getWindowsHostParentPath = (value: string): string | undefined => {
const normalized = normalizeWindowsHostPath(value);
if (!normalized) return undefined;
const driveRoot = `${normalized.slice(0, 2)}\\`;
const withoutTrailingSlash = normalized.replace(/\\+$/, "");
const lastSlashIndex = withoutTrailingSlash.lastIndexOf("\\");
if (lastSlashIndex <= 2) {
return driveRoot;
}
return withoutTrailingSlash.slice(0, lastSlashIndex);
};
export const normalizeAbsolutePath = (value?: string): string => {
if (!value?.trim()) return "/";

View File

@@ -0,0 +1,99 @@
import { findCommonAncestor } from "./common-ancestor.js";
import {
findWindowsHostCommonAncestor,
isWindowsHostPath,
windowsHostPathToResticSnapshotPath,
windowsResticSnapshotPathToHostPath,
} from "./path.js";
export type HostPathKind = "posix" | "windows";
export type SnapshotSourcePathKind = HostPathKind | "unsupported";
export type SnapshotSourcePathPlan = {
sourcePathKind: SnapshotSourcePathKind;
queryBasePath: string;
originalRestoreBasePath: string;
customRestoreBasePath: string;
requiresCustomTarget: boolean;
};
const rootPlan = (
requiresCustomTarget = false,
sourcePathKind: SnapshotSourcePathKind = requiresCustomTarget ? "unsupported" : "posix",
): SnapshotSourcePathPlan => ({
sourcePathKind,
queryBasePath: "/",
originalRestoreBasePath: "/",
customRestoreBasePath: "/",
requiresCustomTarget,
});
export const hostPathKindFromPath = (hostPath?: string): HostPathKind =>
hostPath && isWindowsHostPath(hostPath) ? "windows" : "posix";
export const hostPathKindFromPlatform = (platform: string): HostPathKind =>
platform === "win32" ? "windows" : "posix";
export const getSnapshotSourcePathPlan = ({
snapshotPaths,
hostPathKind = "posix",
}: {
snapshotPaths: string[];
hostPathKind?: HostPathKind;
}): SnapshotSourcePathPlan => {
if (snapshotPaths.length === 0) return rootPlan();
const hasWindowsPaths = snapshotPaths.some(isWindowsHostPath);
const hasPosixPaths = snapshotPaths.some((path) => path.startsWith("/"));
const hasUnsupportedNativePaths = snapshotPaths.some((path) => !path.startsWith("/") && !isWindowsHostPath(path));
if (hasUnsupportedNativePaths || (hasWindowsPaths && hasPosixPaths)) {
return rootPlan(true);
}
if (hasWindowsPaths) {
if (hostPathKind !== "windows") {
return rootPlan(true, "windows");
}
const basePath = findWindowsHostCommonAncestor(snapshotPaths);
if (!basePath) return rootPlan(true);
const resticBasePath = windowsHostPathToResticSnapshotPath(basePath);
if (!resticBasePath) return rootPlan(true, "windows");
return {
sourcePathKind: "windows",
queryBasePath: resticBasePath,
originalRestoreBasePath: resticBasePath,
customRestoreBasePath: "/",
requiresCustomTarget: false,
};
}
const basePath = findCommonAncestor(snapshotPaths);
return {
sourcePathKind: "posix",
queryBasePath: basePath,
originalRestoreBasePath: basePath,
customRestoreBasePath: basePath,
requiresCustomTarget: false,
};
};
export const getOriginalRestoreTargetForRoot = ({
restoreRoot,
sourcePathKind,
}: {
restoreRoot: string;
sourcePathKind: SnapshotSourcePathKind;
}): string => {
if (sourcePathKind !== "windows") return "/";
const hostRestoreRoot = windowsResticSnapshotPathToHostPath(restoreRoot);
if (!hostRestoreRoot) {
throw new Error("Windows original restore root must use restic drive syntax.");
}
return hostRestoreRoot;
};