diff --git a/.cursor/rules/no-comments.mdc b/.cursor/rules/no-comments.mdc new file mode 100644 index 000000000..4a64d3218 --- /dev/null +++ b/.cursor/rules/no-comments.mdc @@ -0,0 +1,6 @@ +--- +description: +globs: +alwaysApply: true +--- +Never add comments for obvious things, and avoid commenting when starting and ending code blocks \ No newline at end of file diff --git a/.rclone-version b/.rclone-version new file mode 100644 index 000000000..fbde68668 --- /dev/null +++ b/.rclone-version @@ -0,0 +1 @@ +1.69.1 \ No newline at end of file diff --git a/api/.env.development b/api/.env.development index 8cd6451e7..949bdc97e 100644 --- a/api/.env.development +++ b/api/.env.development @@ -13,6 +13,8 @@ PATHS_PARITY_CHECKS=./dev/states/parity-checks.log PATHS_CONFIG_MODULES=./dev/configs PATHS_ACTIVATION_BASE=./dev/activation PATHS_PASSWD=./dev/passwd +PATHS_RCLONE_SOCKET=./dev/rclone-socket +PATHS_LOG_BASE=./dev/log # Where we store logs ENVIRONMENT="development" NODE_ENV="development" PORT="3001" diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 6938cf1eb..986b0fef0 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -946,6 +946,25 @@ type ParityCheckMutations { cancel: JSON! } +"""RClone related mutations""" +type RCloneMutations { + """Create a new RClone remote""" + createRCloneRemote(input: CreateRCloneRemoteInput!): RCloneRemote! + + """Delete an existing RClone remote""" + deleteRCloneRemote(input: DeleteRCloneRemoteInput!): Boolean! +} + +input CreateRCloneRemoteInput { + name: String! + type: String! + parameters: JSON! +} + +input DeleteRCloneRemoteInput { + name: String! +} + type ParityCheck { """Date of the parity check""" date: DateTime @@ -1299,20 +1318,15 @@ type DockerContainer implements Node { """Total size of all the files in the container""" sizeRootFs: Int - labels: JSONObject + labels: JSON state: ContainerState! status: String! hostConfig: ContainerHostConfig - networkSettings: JSONObject - mounts: [JSONObject!] + networkSettings: JSON + mounts: [JSON!] autoStart: Boolean! } -""" -The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") - enum ContainerState { RUNNING EXITED @@ -1325,15 +1339,15 @@ type DockerNetwork implements Node { scope: String! driver: String! enableIPv6: Boolean! - ipam: JSONObject! + ipam: JSON! internal: Boolean! attachable: Boolean! ingress: Boolean! - configFrom: JSONObject! + configFrom: JSON! configOnly: Boolean! - containers: JSONObject! - options: JSONObject! - labels: JSONObject! + containers: JSON! + options: JSON! + labels: JSON! } type Docker implements Node { @@ -1342,6 +1356,49 @@ type Docker implements Node { networks(skipCache: Boolean! = false): [DockerNetwork!]! } +type FlashBackupStatus { + """Status message indicating the outcome of the backup initiation.""" + status: String! + + """Job ID if available, can be used to check job status.""" + jobId: String +} + +type RCloneDrive { + """Provider name""" + name: String! + + """Provider options and configuration schema""" + options: JSON! +} + +type RCloneBackupConfigForm { + id: ID! + dataSchema: JSON! + uiSchema: JSON! +} + +type RCloneBackupSettings { + configForm(formOptions: RCloneConfigFormInput): RCloneBackupConfigForm! + drives: [RCloneDrive!]! + remotes: [RCloneRemote!]! +} + +input RCloneConfigFormInput { + providerType: String + showAdvanced: Boolean = false + parameters: JSON +} + +type RCloneRemote { + name: String! + type: String! + parameters: JSON! + + """Complete remote configuration""" + config: JSON! +} + type Flash implements Node { id: PrefixedID! guid: String! @@ -1543,6 +1600,7 @@ type Query { docker: Docker! disks: [Disk!]! disk(id: PrefixedID!): Disk! + rclone: RCloneBackupSettings! health: String! getDemo: String! } @@ -1572,12 +1630,16 @@ type Mutation { vm: VmMutations! parityCheck: ParityCheckMutations! apiKey: ApiKeyMutations! + rclone: RCloneMutations! updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues! connectSignIn(input: ConnectSignInInput!): Boolean! connectSignOut: Boolean! setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]! enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! + + """Initiates a flash drive backup using a configured remote.""" + initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus! setDemo: String! } @@ -1674,6 +1736,22 @@ input AccessUrlInput { ipv6: URL } +input InitiateFlashBackupInput { + """The name of the remote configuration to use for the backup.""" + remoteName: String! + + """Source path to backup (typically the flash drive).""" + sourcePath: String! + + """Destination path on the remote.""" + destinationPath: String! + + """ + Additional options for the backup operation, such as --dry-run or --transfers. + """ + options: JSON +} + type Subscription { displaySubscription: Display! infoSubscription: Info! diff --git a/api/package.json b/api/package.json index 04e108585..9f832285c 100644 --- a/api/package.json +++ b/api/package.json @@ -109,8 +109,6 @@ "graphql-scalars": "^1.23.0", "graphql-subscriptions": "^3.0.0", "graphql-tag": "^2.12.6", - "graphql-type-json": "^0.3.2", - "graphql-type-uuid": "^0.2.0", "graphql-ws": "^6.0.0", "ini": "^5.0.0", "ip": "^2.0.1", diff --git a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts new file mode 100644 index 000000000..a9c741536 --- /dev/null +++ b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts @@ -0,0 +1,374 @@ +import { HTTPError } from 'got'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; +import { + CreateRCloneRemoteDto, + DeleteRCloneRemoteDto, + GetRCloneJobStatusDto, + GetRCloneRemoteConfigDto, + GetRCloneRemoteDetailsDto, + RCloneStartBackupInput, + UpdateRCloneRemoteDto, +} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; + +vi.mock('got'); +vi.mock('execa'); +vi.mock('p-retry'); +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})); +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + rm: vi.fn(), + writeFile: vi.fn(), +})); +vi.mock('@app/core/log.js', () => ({ + sanitizeParams: vi.fn((params) => params), +})); +vi.mock('@app/store/index.js', () => ({ + getters: { + paths: () => ({ + 'rclone-socket': '/tmp/rclone.sock', + 'log-base': '/var/log', + }), + }, +})); + +// Mock NestJS Logger to suppress logs during tests +vi.mock('@nestjs/common', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Logger: vi.fn(() => ({ + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + }; +}); + +describe('RCloneApiService', () => { + let service: RCloneApiService; + let mockGot: any; + let mockExeca: any; + let mockPRetry: any; + let mockExistsSync: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { default: got } = await import('got'); + const { execa } = await import('execa'); + const pRetry = await import('p-retry'); + const { existsSync } = await import('node:fs'); + + mockGot = vi.mocked(got); + mockExeca = vi.mocked(execa); + mockPRetry = vi.mocked(pRetry.default); + mockExistsSync = vi.mocked(existsSync); + + mockGot.post = vi.fn().mockResolvedValue({ body: {} }); + mockExeca.mockReturnValue({ + on: vi.fn(), + kill: vi.fn(), + killed: false, + pid: 12345, + } as any); + mockPRetry.mockResolvedValue(undefined); + mockExistsSync.mockReturnValue(false); + + service = new RCloneApiService(); + await service.onModuleInit(); + }); + + describe('getProviders', () => { + it('should return list of providers', async () => { + const mockProviders = [ + { name: 'aws', prefix: 's3', description: 'Amazon S3' }, + { name: 'google', prefix: 'drive', description: 'Google Drive' }, + ]; + mockGot.post.mockResolvedValue({ + body: { providers: mockProviders }, + }); + + const result = await service.getProviders(); + + expect(result).toEqual(mockProviders); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/providers', + expect.objectContaining({ + json: {}, + responseType: 'json', + enableUnixSockets: true, + }) + ); + }); + + it('should return empty array when no providers', async () => { + mockGot.post.mockResolvedValue({ body: {} }); + + const result = await service.getProviders(); + + expect(result).toEqual([]); + }); + }); + + describe('listRemotes', () => { + it('should return list of remotes', async () => { + const mockRemotes = ['backup-s3', 'drive-storage']; + mockGot.post.mockResolvedValue({ + body: { remotes: mockRemotes }, + }); + + const result = await service.listRemotes(); + + expect(result).toEqual(mockRemotes); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/listremotes', + expect.objectContaining({ + json: {}, + }) + ); + }); + + it('should return empty array when no remotes', async () => { + mockGot.post.mockResolvedValue({ body: {} }); + + const result = await service.listRemotes(); + + expect(result).toEqual([]); + }); + }); + + describe('getRemoteDetails', () => { + it('should return remote details', async () => { + const input: GetRCloneRemoteDetailsDto = { name: 'test-remote' }; + const mockConfig = { type: 's3', provider: 'AWS' }; + mockGot.post.mockResolvedValue({ body: mockConfig }); + + const result = await service.getRemoteDetails(input); + + expect(result).toEqual(mockConfig); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/get', + expect.objectContaining({ + json: { name: 'test-remote' }, + }) + ); + }); + }); + + describe('getRemoteConfig', () => { + it('should return remote configuration', async () => { + const input: GetRCloneRemoteConfigDto = { name: 'test-remote' }; + const mockConfig = { type: 's3', access_key_id: 'AKIA...' }; + mockGot.post.mockResolvedValue({ body: mockConfig }); + + const result = await service.getRemoteConfig(input); + + expect(result).toEqual(mockConfig); + }); + }); + + describe('createRemote', () => { + it('should create a new remote', async () => { + const input: CreateRCloneRemoteDto = { + name: 'new-remote', + type: 's3', + parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' }, + }; + const mockResponse = { success: true }; + mockGot.post.mockResolvedValue({ body: mockResponse }); + + const result = await service.createRemote(input); + + expect(result).toEqual(mockResponse); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/create', + expect.objectContaining({ + json: { + name: 'new-remote', + type: 's3', + parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' }, + }, + }) + ); + }); + }); + + describe('updateRemote', () => { + it('should update an existing remote', async () => { + const input: UpdateRCloneRemoteDto = { + name: 'existing-remote', + parameters: { access_key_id: 'NEW_AKIA...' }, + }; + const mockResponse = { success: true }; + mockGot.post.mockResolvedValue({ body: mockResponse }); + + const result = await service.updateRemote(input); + + expect(result).toEqual(mockResponse); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/update', + expect.objectContaining({ + json: { + name: 'existing-remote', + access_key_id: 'NEW_AKIA...', + }, + }) + ); + }); + }); + + describe('deleteRemote', () => { + it('should delete a remote', async () => { + const input: DeleteRCloneRemoteDto = { name: 'remote-to-delete' }; + const mockResponse = { success: true }; + mockGot.post.mockResolvedValue({ body: mockResponse }); + + const result = await service.deleteRemote(input); + + expect(result).toEqual(mockResponse); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/delete', + expect.objectContaining({ + json: { name: 'remote-to-delete' }, + }) + ); + }); + }); + + describe('startBackup', () => { + it('should start a backup operation', async () => { + const input: RCloneStartBackupInput = { + srcPath: '/source/path', + dstPath: 'remote:backup/path', + options: { delete_on: 'dst' }, + }; + const mockResponse = { jobid: 'job-123' }; + mockGot.post.mockResolvedValue({ body: mockResponse }); + + const result = await service.startBackup(input); + + expect(result).toEqual(mockResponse); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/sync/copy', + expect.objectContaining({ + json: { + srcFs: '/source/path', + dstFs: 'remote:backup/path', + delete_on: 'dst', + }, + }) + ); + }); + }); + + describe('getJobStatus', () => { + it('should return job status', async () => { + const input: GetRCloneJobStatusDto = { jobId: 'job-123' }; + const mockStatus = { status: 'running', progress: 0.5 }; + mockGot.post.mockResolvedValue({ body: mockStatus }); + + const result = await service.getJobStatus(input); + + expect(result).toEqual(mockStatus); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/job/status', + expect.objectContaining({ + json: { jobid: 'job-123' }, + }) + ); + }); + }); + + describe('listRunningJobs', () => { + it('should return list of running jobs', async () => { + const mockJobs = [ + { id: 'job-1', status: 'running' }, + { id: 'job-2', status: 'finished' }, + ]; + mockGot.post.mockResolvedValue({ body: mockJobs }); + + const result = await service.listRunningJobs(); + + expect(result).toEqual(mockJobs); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/job/list', + expect.objectContaining({ + json: {}, + }) + ); + }); + }); + + describe('error handling', () => { + it('should handle HTTP errors with detailed messages', async () => { + const httpError = { + name: 'HTTPError', + message: 'Request failed', + response: { + statusCode: 500, + body: JSON.stringify({ error: 'Internal server error' }), + }, + }; + Object.setPrototypeOf(httpError, HTTPError.prototype); + mockGot.post.mockRejectedValue(httpError); + + await expect(service.getProviders()).rejects.toThrow( + 'Rclone API Error (config/providers, HTTP 500): Rclone Error: Internal server error' + ); + }); + + it('should handle HTTP errors with empty response body', async () => { + const httpError = { + name: 'HTTPError', + message: 'Request failed', + response: { + statusCode: 404, + body: '', + }, + }; + Object.setPrototypeOf(httpError, HTTPError.prototype); + mockGot.post.mockRejectedValue(httpError); + + await expect(service.getProviders()).rejects.toThrow( + 'Rclone API Error (config/providers, HTTP 404): Failed to process error response body. Raw body:' + ); + }); + + it('should handle HTTP errors with malformed JSON', async () => { + const httpError = { + name: 'HTTPError', + message: 'Request failed', + response: { + statusCode: 400, + body: 'invalid json', + }, + }; + Object.setPrototypeOf(httpError, HTTPError.prototype); + mockGot.post.mockRejectedValue(httpError); + + await expect(service.getProviders()).rejects.toThrow( + 'Rclone API Error (config/providers, HTTP 400): Failed to process error response body. Raw body: invalid json' + ); + }); + + it('should handle non-HTTP errors', async () => { + const networkError = new Error('Network connection failed'); + mockGot.post.mockRejectedValue(networkError); + + await expect(service.getProviders()).rejects.toThrow('Network connection failed'); + }); + + it('should handle unknown errors', async () => { + mockGot.post.mockRejectedValue('unknown error'); + + await expect(service.getProviders()).rejects.toThrow( + 'Unknown error calling RClone API (config/providers) with params {}: unknown error' + ); + }); + }); +}); diff --git a/api/src/__test__/store/modules/__snapshots__/paths.test.ts.snap b/api/src/__test__/store/modules/__snapshots__/paths.test.ts.snap index 85e3e1d5c..e34dd1e54 100644 --- a/api/src/__test__/store/modules/__snapshots__/paths.test.ts.snap +++ b/api/src/__test__/store/modules/__snapshots__/paths.test.ts.snap @@ -7,6 +7,7 @@ exports[`Returns paths 1`] = ` "unraid-data", "docker-autostart", "docker-socket", + "rclone-socket", "parity-checks", "htpasswd", "emhttpd-socket", diff --git a/api/src/core/log.ts b/api/src/core/log.ts index cf0a40c20..da70c5d8f 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -1,7 +1,7 @@ import { pino } from 'pino'; import pretty from 'pino-pretty'; -import { LOG_TYPE } from '@app/environment.js'; +import { API_VERSION, LOG_TYPE } from '@app/environment.js'; export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const; @@ -10,9 +10,7 @@ export type LogLevel = (typeof levels)[number]; const level = levels[levels.indexOf(process.env.LOG_LEVEL?.toLowerCase() as (typeof levels)[number])] ?? 'info'; -export const logDestination = pino.destination({ - sync: true, -}); +export const logDestination = pino.destination(); const stream = LOG_TYPE === 'pretty' @@ -28,9 +26,25 @@ const stream = export const logger = pino( { level, - timestamp: () => `,"time":"${new Date().toISOString()}"`, + timestamp: pino.stdTimeFunctions.isoTime, formatters: { level: (label: string) => ({ level: label }), + bindings: (bindings) => ({ ...bindings, apiVersion: API_VERSION }), + }, + redact: { + paths: [ + '*.password', + '*.pass', + '*.secret', + '*.token', + '*.key', + '*.Password', + '*.Pass', + '*.Secret', + '*.Token', + '*.Key', + ], + censor: '***REDACTED***', }, }, stream @@ -71,3 +85,19 @@ export const loggers = [ remoteQueryLogger, apiLogger, ]; + +export function sanitizeParams(params: Record): Record { + const SENSITIVE_KEYS = ['password', 'secret', 'token', 'key', 'client_secret']; + const mask = (value: any) => (typeof value === 'string' && value.length > 0 ? '***' : value); + const sanitized: Record = {}; + for (const k in params) { + if (SENSITIVE_KEYS.some((s) => k.toLowerCase().includes(s))) { + sanitized[k] = mask(params[k]); + } else if (typeof params[k] === 'object' && params[k] !== null && !Array.isArray(params[k])) { + sanitized[k] = sanitizeParams(params[k]); + } else { + sanitized[k] = params[k]; + } + } + return sanitized; +} diff --git a/api/src/store/modules/paths.ts b/api/src/store/modules/paths.ts index 2d4c75f9e..3a70d38e7 100644 --- a/api/src/store/modules/paths.ts +++ b/api/src/store/modules/paths.ts @@ -21,6 +21,7 @@ const initialState = { ), 'docker-autostart': '/var/lib/docker/unraid-autostart' as const, 'docker-socket': '/var/run/docker.sock' as const, + 'rclone-socket': resolvePath(process.env.PATHS_RCLONE_SOCKET ?? ('/var/run/rclone.socket' as const)), 'parity-checks': resolvePath( process.env.PATHS_PARITY_CHECKS ?? ('/boot/config/parity-checks.log' as const) ), @@ -54,8 +55,8 @@ const initialState = { ('/boot/config/plugins/dynamix.my.servers/fb_keepalive' as const), 'keyfile-base': resolvePath(process.env.PATHS_KEYFILE_BASE ?? ('/boot/config' as const)), 'machine-id': resolvePath(process.env.PATHS_MACHINE_ID ?? ('/var/lib/dbus/machine-id' as const)), - 'log-base': resolvePath('/var/log/unraid-api/' as const), - 'unraid-log-base': resolvePath('/var/log/' as const), + 'log-base': process.env.PATHS_LOG_BASE ?? resolvePath('/var/log/unraid-api/' as const), + 'unraid-log-base': process.env.PATHS_UNRAID_LOG_BASE ?? resolvePath('/var/log/' as const), 'var-run': '/var/run' as const, // contains sess_ files that correspond to authenticated user sessions 'auth-sessions': process.env.PATHS_AUTH_SESSIONS ?? '/var/lib/php', diff --git a/api/src/unraid-api/auth/auth.service.ts b/api/src/unraid-api/auth/auth.service.ts index 526c8f9e8..afcf5d236 100644 --- a/api/src/unraid-api/auth/auth.service.ts +++ b/api/src/unraid-api/auth/auth.service.ts @@ -52,7 +52,11 @@ export class AuthService { async validateCookiesWithCsrfToken(request: FastifyRequest): Promise { try { - if (!this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.csrf_token)) { + if ( + request.method !== 'GET' && + !request.url?.startsWith('/graphql/api/rclone-webgui/') && + !this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.csrf_token) + ) { throw new UnauthorizedException('Invalid CSRF token'); } diff --git a/api/src/unraid-api/graph/resolvers/connect/connect-settings.resolver.ts b/api/src/unraid-api/graph/resolvers/connect/connect-settings.resolver.ts index 163bf05dd..7e07e74f9 100644 --- a/api/src/unraid-api/graph/resolvers/connect/connect-settings.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/connect/connect-settings.resolver.ts @@ -1,8 +1,8 @@ import { Logger } from '@nestjs/common'; import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Layout } from '@jsonforms/core'; -import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'; +import { type Layout } from '@jsonforms/core'; +import { GraphQLJSON } from 'graphql-scalars'; import { getAllowedOrigins } from '@app/common/allowed-origins.js'; import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js'; @@ -46,7 +46,7 @@ export class ConnectSettingsResolver { }; } - @ResolveField(() => GraphQLJSONObject) + @ResolveField(() => GraphQLJSON) public async uiSchema(): Promise { const { elements } = await this.connectSettingsService.buildSettingsSchema(); return { @@ -54,6 +54,7 @@ export class ConnectSettingsResolver { elements, }; } + @ResolveField(() => ConnectSettingsValues) public async values(): Promise { return await this.connectSettingsService.getCurrentSettings(); diff --git a/api/src/unraid-api/graph/resolvers/connect/connect-settings.service.ts b/api/src/unraid-api/graph/resolvers/connect/connect-settings.service.ts index 6f2c135d7..e94ba1948 100644 --- a/api/src/unraid-api/graph/resolvers/connect/connect-settings.service.ts +++ b/api/src/unraid-api/graph/resolvers/connect/connect-settings.service.ts @@ -33,6 +33,7 @@ import { WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, } from '@app/unraid-api/graph/resolvers/connect/connect.model.js'; +import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js'; import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js'; import { csvStringToArray } from '@app/utils.js'; @@ -346,15 +347,15 @@ export class ConnectSettingsService { /** shown when preconditions are met */ const formControls: UIElement[] = [ - { - type: 'Control', + createLabeledControl({ scope: '#/properties/accessType', label: 'Allow Remote Access', - }, - { - type: 'Control', + controlOptions: {}, + }), + createLabeledControl({ scope: '#/properties/forwardType', label: 'Remote Access Forward Type', + controlOptions: {}, rule: { effect: RuleEffect.DISABLE, condition: { @@ -364,12 +365,11 @@ export class ConnectSettingsService { }, } as SchemaBasedCondition, }, - }, - { - type: 'Control', + }), + createLabeledControl({ scope: '#/properties/port', label: 'Remote Access WAN Port', - options: { + controlOptions: { format: 'short', formatOptions: { useGrouping: false, @@ -390,7 +390,7 @@ export class ConnectSettingsService { }, } as Omit, }, - }, + }), ]; /** shape of the data associated with remote access settings, as json schema properties*/ @@ -438,15 +438,14 @@ export class ConnectSettingsService { }, }, elements: [ - { - type: 'Control', + createLabeledControl({ scope: '#/properties/sandbox', label: 'Enable Developer Sandbox:', - options: { + description: sandbox ? description : undefined, + controlOptions: { toggle: true, - description: sandbox ? description : undefined, }, - }, + }), ], }; } @@ -489,14 +488,17 @@ export class ConnectSettingsService { }, }, elements: [ - { - type: 'Control', + createLabeledControl({ scope: '#/properties/extraOrigins', - options: { + label: 'Allowed Origins (CORS)', + description: + 'Provide a comma-separated list of URLs allowed to access the API (e.g., https://myapp.example.com).', + controlOptions: { inputType: 'url', placeholder: 'https://example.com', + format: 'array', }, - }, + }), ], }; } @@ -513,18 +515,20 @@ export class ConnectSettingsService { type: 'string', }, title: 'Unraid API SSO Users', - description: `Provide a list of Unique Unraid Account ID's. Find yours at account.unraid.net/settings`, + description: `Provide a list of Unique Unraid Account ID's. Find yours at account.unraid.net/settings. Requires restart if adding first user.`, }, }, elements: [ - { - type: 'Control', + createLabeledControl({ scope: '#/properties/ssoUserIds', - options: { + label: 'Unraid Connect SSO Users', + description: `Provide a list of Unique Unraid Account IDs. Find yours at account.unraid.net/settings. Requires restart if adding first user.`, + controlOptions: { inputType: 'text', placeholder: 'UUID', + format: 'array', }, - }, + }), ], }; } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker.model.ts index 2dded5521..8c0ec2277 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.model.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.model.ts @@ -1,6 +1,6 @@ import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; -import { GraphQLJSONObject, GraphQLPort } from 'graphql-scalars'; +import { GraphQLJSON, GraphQLPort } from 'graphql-scalars'; import { Node } from '@app/unraid-api/graph/resolvers/base.model.js'; @@ -93,7 +93,7 @@ export class DockerContainer extends Node { @Field(() => Int, { nullable: true, description: 'Total size of all the files in the container' }) sizeRootFs?: number; - @Field(() => GraphQLJSONObject, { nullable: true }) + @Field(() => GraphQLJSON, { nullable: true }) labels?: Record; @Field(() => ContainerState) @@ -105,10 +105,10 @@ export class DockerContainer extends Node { @Field(() => ContainerHostConfig, { nullable: true }) hostConfig?: ContainerHostConfig; - @Field(() => GraphQLJSONObject, { nullable: true }) + @Field(() => GraphQLJSON, { nullable: true }) networkSettings?: Record; - @Field(() => [GraphQLJSONObject], { nullable: true }) + @Field(() => [GraphQLJSON], { nullable: true }) mounts?: Record[]; @Field(() => Boolean) @@ -132,7 +132,7 @@ export class DockerNetwork extends Node { @Field(() => Boolean) enableIPv6!: boolean; - @Field(() => GraphQLJSONObject) + @Field(() => GraphQLJSON) ipam!: Record; @Field(() => Boolean) @@ -144,19 +144,19 @@ export class DockerNetwork extends Node { @Field(() => Boolean) ingress!: boolean; - @Field(() => GraphQLJSONObject) + @Field(() => GraphQLJSON) configFrom!: Record; @Field(() => Boolean) configOnly!: boolean; - @Field(() => GraphQLJSONObject) + @Field(() => GraphQLJSON) containers!: Record; - @Field(() => GraphQLJSONObject) + @Field(() => GraphQLJSON) options!: Record; - @Field(() => GraphQLJSONObject) + @Field(() => GraphQLJSON) labels!: Record; } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts index f67eae5cd..fb9c74a88 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -59,7 +59,6 @@ export class DockerService implements OnModuleInit { public async onModuleInit() { try { - this.logger.debug('Warming Docker cache on startup...'); await this.getContainers({ skipCache: true }); await this.getNetworks({ skipCache: true }); this.logger.debug('Docker cache warming complete.'); diff --git a/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.model.ts b/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.model.ts new file mode 100644 index 000000000..5013eaf3a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.model.ts @@ -0,0 +1,53 @@ +import { Field, InputType, ObjectType } from '@nestjs/graphql'; + +import { GraphQLJSON } from 'graphql-scalars'; + +@InputType() +export class InitiateFlashBackupInput { + @Field(() => String, { description: 'The name of the remote configuration to use for the backup.' }) + remoteName!: string; + + @Field(() => String, { description: 'Source path to backup (typically the flash drive).' }) + sourcePath!: string; + + @Field(() => String, { description: 'Destination path on the remote.' }) + destinationPath!: string; + + @Field(() => GraphQLJSON, { + description: 'Additional options for the backup operation, such as --dry-run or --transfers.', + nullable: true, + }) + options?: Record; +} + +@ObjectType() +export class FlashBackupStatus { + @Field(() => String, { + description: 'Status message indicating the outcome of the backup initiation.', + }) + status!: string; + + @Field(() => String, { + description: 'Job ID if available, can be used to check job status.', + nullable: true, + }) + jobId?: string; +} + +@ObjectType() +export class FlashBackupJob { + @Field(() => String, { description: 'Job ID' }) + id!: string; + + @Field(() => String, { description: 'Job type (e.g., sync/copy)' }) + type!: string; + + @Field(() => GraphQLJSON, { description: 'Job status and statistics' }) + stats!: Record; +} + +@ObjectType() +export class RCloneWebGuiInfo { + @Field() + url!: string; +} diff --git a/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.module.ts b/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.module.ts new file mode 100644 index 000000000..3b9fe0dcb --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { FlashBackupResolver } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.js'; +import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; + +@Module({ + imports: [RCloneModule], + providers: [FlashBackupResolver], + exports: [], +}) +export class FlashBackupModule {} diff --git a/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.ts b/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.ts new file mode 100644 index 000000000..6358fb30e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.ts @@ -0,0 +1,24 @@ +import { Inject, Logger } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { + FlashBackupStatus, + InitiateFlashBackupInput, +} from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.model.js'; +import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js'; + +@Resolver() +export class FlashBackupResolver { + private readonly logger = new Logger(FlashBackupResolver.name); + + constructor(private readonly rcloneService: RCloneService) {} + + @Mutation(() => FlashBackupStatus, { + description: 'Initiates a flash drive backup using a configured remote.', + }) + async initiateFlashBackup( + @Args('input') input: InitiateFlashBackupInput + ): Promise { + throw new Error('Not implemented'); + } +} diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index da09bba2c..8beb1f7a8 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -1,5 +1,13 @@ import { Field, ObjectType } from '@nestjs/graphql'; +/** + * Important: + * + * When adding a new mutation, you must also add it to the RootMutations resolver + * + * @file src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts + */ + @ObjectType() export class ArrayMutations {} @@ -19,6 +27,11 @@ export class ApiKeyMutations {} }) export class ParityCheckMutations {} +@ObjectType({ + description: 'RClone related mutations', +}) +export class RCloneMutations {} + @ObjectType() export class RootMutations { @Field(() => ArrayMutations, { description: 'Array related mutations' }) @@ -35,4 +48,7 @@ export class RootMutations { @Field(() => ParityCheckMutations, { description: 'Parity check related mutations' }) parityCheck: ParityCheckMutations = new ParityCheckMutations(); + + @Field(() => RCloneMutations, { description: 'RClone related mutations' }) + rclone: RCloneMutations = new RCloneMutations(); } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index 16ebe2a41..42a9cb126 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -5,6 +5,7 @@ import { ArrayMutations, DockerMutations, ParityCheckMutations, + RCloneMutations, RootMutations, VmMutations, } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; @@ -35,4 +36,9 @@ export class RootMutationsResolver { apiKey(): ApiKeyMutations { return new ApiKeyMutations(); } + + @Mutation(() => RCloneMutations, { name: 'rclone' }) + rclone(): RCloneMutations { + return new RCloneMutations(); + } } diff --git a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/config.ts b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/config.ts new file mode 100644 index 000000000..4e6ede802 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/config.ts @@ -0,0 +1,4237 @@ +/** + * DO NOT MODIFY - Directly from : https://raw.githubusercontent.com/rclone/rclone-webui-react/master/src/views/RemoteManagement/NewDrive/config.js + */ + +export const config = [ + { + Name: 'alias', + Description: 'Alias for a existing remote', + Prefix: 'alias', + Options: [ + { + Name: 'remote', + Help: 'Remote or path to alias.\nCan be "myremote:path/to/dir", "myremote:bucket", "myremote:" or "/local/path".', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + ], + }, + { + Name: 'amazon cloud drive', + Description: 'Amazon Drive', + Prefix: 'acd', + Options: [ + { + Name: 'client_id', + Help: 'Amazon Application Client ID.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Amazon Application Client Secret.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'auth_url', + Help: "Auth server URL.\nLeave blank to use Amazon's.", + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'token_url', + Help: "Token server url.\nleave blank to use Amazon's.", + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'checkpoint', + Help: 'Checkpoint for internal polling (debug).', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 3, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'upload_wait_per_gb', + Help: 'Additional time per GB to wait after a failed complete upload to see if it appears.\n\nSometimes Amazon Drive gives an error when a file has been fully\nuploaded but the file appears anyway after a little while. This\nhappens sometimes for files over 1GB in size and nearly every time for\nfiles bigger than 10GB. This parameter controls the time rclone waits\nfor the file to appear.\n\nThe default value for this parameter is 3 minutes per GB, so by\ndefault it will wait 3 minutes for every GB uploaded to see if the\nfile appears.\n\nYou can disable this feature by setting it to 0. This may cause\nconflict errors as rclone retries the failed upload but the file will\nmost likely appear correctly eventually.\n\nThese values were determined empirically by observing lots of uploads\nof big files for a range of file sizes.\n\nUpload with the "-v" flag to see more info about what rclone is doing\nin this situation.', + Provider: '', + Default: 180000000000, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'templink_threshold', + Help: 'Files \u003e= this size will be downloaded via their tempLink.\n\nFiles this size or more will be downloaded via their "tempLink". This\nis to work around a problem with Amazon Drive which blocks downloads\nof files bigger than about 10GB. The default for this is 9GB which\nshouldn\'t need to be changed.\n\nTo download files above this threshold, rclone requests a "tempLink"\nwhich downloads the file through a temporary URL directly from the\nunderlying S3 storage.', + Provider: '', + Default: 9663676416, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'azureblob', + Description: 'Microsoft Azure Blob Storage', + Prefix: 'azureblob', + Options: [ + { + Name: 'account', + Help: 'Storage Account Name (leave blank to use connection string or SAS URL)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'key', + Help: 'Storage Account Key (leave blank to use connection string or SAS URL)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'sas_url', + Help: 'SAS URL for container level access only\n(leave blank if using account/key or connection string)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'endpoint', + Help: 'Endpoint for the service\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'upload_cutoff', + Help: 'Cutoff for switching to chunked upload (\u003c= 256MB).', + Provider: '', + Default: 268435456, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_size', + Help: 'Upload chunk size (\u003c= 100MB).\n\nNote that this is stored in memory and there may be up to\n"--transfers" chunks stored at once in memory.', + Provider: '', + Default: 4194304, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'list_chunk', + Help: 'Size of blob list.\n\nThis sets the number of blobs requested in each listing chunk. Default\nis the maximum, 5000. "List blobs" requests are permitted 2 minutes\nper megabyte to complete. If an operation is taking longer than 2\nminutes per megabyte on average, it will time out (\n[source](https://docs.microsoft.com/en-us/rest/api/storageservices/setting-timeouts-for-blob-service-operations#exceptions-to-default-timeout-interval)\n). This can be used to limit the number of blobs items to return, to\navoid the time out.', + Provider: '', + Default: 5000, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'access_tier', + Help: 'Access tier of blob: hot, cool or archive.\n\nArchived blobs can be restored by setting access tier to hot or\ncool. Leave blank if you intend to use default access tier, which is\nset at account level\n\nIf there is no "access tier" specified, rclone doesn\'t apply any tier.\nrclone performs "Set Tier" operation on blobs while uploading, if objects\nare not modified, specifying "access tier" to new one will have no effect.\nIf blobs are in "archive tier" at remote, trying to perform data transfer\noperations from remote will not be allowed. User should first restore by\ntiering blob to "Hot" or "Cool".', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'b2', + Description: 'Backblaze B2', + Prefix: 'b2', + Options: [ + { + Name: 'account', + Help: 'Account ID or Application Key ID', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'key', + Help: 'Application Key', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'endpoint', + Help: 'Endpoint for the service.\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'test_mode', + Help: 'A flag string for X-Bz-Test-Mode header for debugging.\n\nThis is for debugging purposes only. Setting it to one of the strings\nbelow will cause b2 to return specific errors:\n\n * "fail_some_uploads"\n * "expire_some_account_authorization_tokens"\n * "force_cap_exceeded"\n\nThese will be set in the "X-Bz-Test-Mode" header which is documented\nin the [b2 integrations checklist](https://www.backblaze.com/b2/docs/integration_checklist.html).', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 2, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'versions', + Help: "Include old versions in directory listings.\nNote that when using this no file write operations are permitted,\nso you can't upload files or delete them.", + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'hard_delete', + Help: 'Permanently delete files on remote removal, otherwise hide files.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'upload_cutoff', + Help: 'Cutoff for switching to chunked upload.\n\nFiles above this size will be uploaded in chunks of "--b2-chunk-size".\n\nThis value should be set no larger than 4.657GiB (== 5GB).', + Provider: '', + Default: 209715200, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_size', + Help: 'Upload chunk size. Must fit in memory.\n\nWhen uploading large files, chunk the file into this size. Note that\nthese chunks are buffered in memory and there might a maximum of\n"--transfers" chunks in progress at once. 5,000,000 Bytes is the\nminimum size.', + Provider: '', + Default: 100663296, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'disable_checksum', + Help: 'Disable checksums for large (\u003e upload cutoff) files', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'box', + Description: 'Box', + Prefix: 'box', + Options: [ + { + Name: 'client_id', + Help: 'Box App Client Id.\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Box App Client Secret\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'upload_cutoff', + Help: 'Cutoff for switching to multipart upload (\u003e= 50MB).', + Provider: '', + Default: 52428800, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'commit_retries', + Help: 'Max number of times to try committing a multipart file.', + Provider: '', + Default: 100, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'crypt', + Description: 'Encrypt/Decrypt a remote', + Prefix: 'crypt', + Options: [ + { + Name: 'remote', + Help: 'Remote to encrypt/decrypt.\nNormally should contain a \':\' and a path, eg "myremote:path/to/dir",\n"myremote:bucket" or maybe "myremote:" (not recommended).', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'filename_encryption', + Help: 'How to encrypt the filenames.', + Provider: '', + Default: 'standard', + Value: null, + Examples: [ + { + Value: 'off', + Help: 'Don\'t encrypt the file names. Adds a ".bin" extension only.', + Provider: '', + }, + { + Value: 'standard', + Help: 'Encrypt the filenames see the docs for the details.', + Provider: '', + }, + { + Value: 'obfuscate', + Help: 'Very simple filename obfuscation.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'directory_name_encryption', + Help: 'Option to either encrypt directory names or leave them intact.', + Provider: '', + Default: true, + Value: null, + Examples: [ + { + Value: 'true', + Help: 'Encrypt directory names.', + Provider: '', + }, + { + Value: 'false', + Help: "Don't encrypt directory names, leave them intact.", + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'password', + Help: 'Password or pass phrase for encryption.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'password2', + Help: 'Password or pass phrase for salt. Optional but recommended.\nShould be different to the previous password.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'show_mapping', + Help: 'For all files listed show how the names encrypt.\n\nIf this flag is set then for each file that the remote is asked to\nlist, it will log (at level INFO) a line stating the decrypted file\nname and the encrypted file name.\n\nThis is so you can work out which encrypted names are which decrypted\nnames just in case you need to do something with the encrypted file\nnames, or for debugging purposes.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 2, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'cache', + Description: 'Cache a remote', + Prefix: 'cache', + Options: [ + { + Name: 'remote', + Help: 'Remote to cache.\nNormally should contain a \':\' and a path, eg "myremote:path/to/dir",\n"myremote:bucket" or maybe "myremote:" (not recommended).', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'plex_url', + Help: 'The URL of the Plex server', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'plex_username', + Help: 'The username of the Plex user', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'plex_password', + Help: 'The password of the Plex user', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'plex_token', + Help: 'The plex token for authentication - auto set normally', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 3, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'plex_insecure', + Help: 'Skip all certificate verifications when connecting to the Plex server', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_size', + Help: 'The size of a chunk (partial file data).\n\nUse lower numbers for slower connections. If the chunk size is\nchanged, any downloaded chunks will be invalid and cache-chunk-path\nwill need to be cleared or unexpected EOF errors will occur.', + Provider: '', + Default: 5242880, + Value: null, + Examples: [ + { + Value: '1m', + Help: '1MB', + Provider: '', + }, + { + Value: '5M', + Help: '5 MB', + Provider: '', + }, + { + Value: '10M', + Help: '10 MB', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'info_age', + Help: 'How long to cache file structure information (directory listings, file size, times etc). \nIf all write operations are done through the cache then you can safely make\nthis value very large as the cache store will also be updated in real time.', + Provider: '', + Default: 21600000000000, + Value: null, + Examples: [ + { + Value: '1h', + Help: '1 hour', + Provider: '', + }, + { + Value: '24h', + Help: '24 hours', + Provider: '', + }, + { + Value: '48h', + Help: '48 hours', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'chunk_total_size', + Help: 'The total size that the chunks can take up on the local disk.\n\nIf the cache exceeds this value then it will start to delete the\noldest chunks until it goes under this value.', + Provider: '', + Default: 10737418240, + Value: null, + Examples: [ + { + Value: '500M', + Help: '500 MB', + Provider: '', + }, + { + Value: '1G', + Help: '1 GB', + Provider: '', + }, + { + Value: '10G', + Help: '10 GB', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'db_path', + Help: 'Directory to store file structure metadata DB.\nThe remote name is used as the DB file name.', + Provider: '', + Default: '/home/negative0/.cache/rclone/cache-backend', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_path', + Help: 'Directory to cache chunk files.\n\nPath to where partial file data (chunks) are stored locally. The remote\nname is appended to the final path.\n\nThis config follows the "--cache-db-path". If you specify a custom\nlocation for "--cache-db-path" and don\'t specify one for "--cache-chunk-path"\nthen "--cache-chunk-path" will use the same path as "--cache-db-path".', + Provider: '', + Default: '/home/negative0/.cache/rclone/cache-backend', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'db_purge', + Help: 'Clear all the cached data for this remote on start.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 2, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_clean_interval', + Help: 'How often should the cache perform cleanups of the chunk storage.\nThe default value should be ok for most people. If you find that the\ncache goes over "cache-chunk-total-size" too often then try to lower\nthis value to force it to perform cleanups more often.', + Provider: '', + Default: 60000000000, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'read_retries', + Help: "How many times to retry a read from a cache storage.\n\nSince reading from a cache stream is independent from downloading file\ndata, readers can get to a point where there's no more data in the\ncache. Most of the times this can indicate a connectivity issue if\ncache isn't able to provide file data anymore.\n\nFor really slow connections, increase this to a point where the stream is\nable to provide data but your experience will be very stuttering.", + Provider: '', + Default: 10, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'workers', + Help: 'How many workers should run in parallel to download chunks.\n\nHigher values will mean more parallel processing (better CPU needed)\nand more concurrent requests on the cloud provider. This impacts\nseveral aspects like the cloud provider API limits, more stress on the\nhardware that rclone runs on but it also means that streams will be\nmore fluid and data will be available much more faster to readers.\n\n**Note**: If the optional Plex integration is enabled then this\nsetting will adapt to the type of reading performed and the value\nspecified here will be used as a maximum number of workers to use.', + Provider: '', + Default: 4, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_no_memory', + Help: 'Disable the in-memory cache for storing chunks during streaming.\n\nBy default, cache will keep file data during streaming in RAM as well\nto provide it to readers as fast as possible.\n\nThis transient data is evicted as soon as it is read and the number of\nchunks stored doesn\'t exceed the number of workers. However, depending\non other settings like "cache-chunk-size" and "cache-workers" this footprint\ncan increase if there are parallel streams too (multiple files being read\nat the same time).\n\nIf the hardware permits it, use this feature to provide an overall better\nperformance during streaming but it can also be disabled if RAM is not\navailable on the local machine.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'rps', + Help: "Limits the number of requests per second to the source FS (-1 to disable)\n\nThis setting places a hard limit on the number of requests per second\nthat cache will be doing to the cloud provider remote and try to\nrespect that value by setting waits between reads.\n\nIf you find that you're getting banned or limited on the cloud\nprovider through cache and know that a smaller number of requests per\nsecond will allow you to work with it then you can use this setting\nfor that.\n\nA good balance of all the other settings should make this setting\nuseless but it is available to set for more special cases.\n\n**NOTE**: This will limit the number of requests during streams but\nother API calls to the cloud provider like directory listings will\nstill pass.", + Provider: '', + Default: -1, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'writes', + Help: 'Cache file data on writes through the FS\n\nIf you need to read files immediately after you upload them through\ncache you can enable this flag to have their data stored in the\ncache store at the same time during upload.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'tmp_upload_path', + Help: 'Directory to keep temporary files until they are uploaded.\n\nThis is the path where cache will use as a temporary storage for new\nfiles that need to be uploaded to the cloud provider.\n\nSpecifying a value will enable this feature. Without it, it is\ncompletely disabled and files will be uploaded directly to the cloud\nprovider', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'tmp_wait_time', + Help: 'How long should files be stored in local cache before being uploaded\n\nThis is the duration that a file must wait in the temporary location\n_cache-tmp-upload-path_ before it is selected for upload.\n\nNote that only one file is uploaded at a time and it can take longer\nto start the upload if a queue formed for this purpose.', + Provider: '', + Default: 15000000000, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'db_wait_time', + Help: 'How long to wait for the DB to be available - 0 is unlimited\n\nOnly one process can have the DB open at any one time, so rclone waits\nfor this duration for the DB to become available before it gives an\nerror.\n\nIf you set it to 0 then it will wait forever.', + Provider: '', + Default: 1000000000, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'drive', + Description: 'Google Drive', + Prefix: 'drive', + Options: [ + { + Name: 'client_id', + Help: 'Google Application Client Id\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Google Application Client Secret\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'scope', + Help: 'Scope that rclone should use when requesting access from drive.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'drive', + Help: 'Full access all files, excluding Application Data Folder.', + Provider: '', + }, + { + Value: 'drive.readonly', + Help: 'Read-only access to file metadata and file contents.', + Provider: '', + }, + { + Value: 'drive.file', + Help: 'Access to files created by rclone only.\nThese are visible in the drive website.\nFile authorization is revoked when the user deauthorizes the app.', + Provider: '', + }, + { + Value: 'drive.appfolder', + Help: 'Allows read and write access to the Application Data folder.\nThis is not visible in the drive website.', + Provider: '', + }, + { + Value: 'drive.metadata.readonly', + Help: 'Allows read-only access to file metadata but\ndoes not allow any access to read or download file content.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'root_folder_id', + Help: 'ID of the root folder\nLeave blank normally.\nFill in to access "Computers" folders. (see docs).', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'service_account_file', + Help: 'Service Account Credentials JSON file path \nLeave blank normally.\nNeeded only if you want use SA instead of interactive login.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'service_account_credentials', + Help: 'Service Account Credentials JSON blob\nLeave blank normally.\nNeeded only if you want use SA instead of interactive login.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 2, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'team_drive', + Help: 'ID of the Team Drive', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 2, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'auth_owner_only', + Help: 'Only consider files owned by the authenticated user.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'use_trash', + Help: 'Send files to the trash instead of deleting permanently.\nDefaults to true, namely sending files to the trash.\nUse `--drive-use-trash=false` to delete files permanently instead.', + Provider: '', + Default: true, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'skip_gdocs', + Help: 'Skip google documents in all listings.\nIf given, gdocs practically become invisible to rclone.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'shared_with_me', + Help: 'Only show files that are shared with me.\n\nInstructs rclone to operate on your "Shared with me" folder (where\nGoogle Drive lets you access the files and folders others have shared\nwith you).\n\nThis works both with the "list" (lsd, lsl, etc) and the "copy"\ncommands (copy, sync, etc), and with all other commands too.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'trashed_only', + Help: 'Only show files that are in the trash.\nThis will show trashed files in their original directory structure.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'formats', + Help: 'Deprecated: see export_formats', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 2, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'export_formats', + Help: 'Comma separated list of preferred formats for downloading Google docs.', + Provider: '', + Default: 'docx,xlsx,pptx,svg', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'import_formats', + Help: 'Comma separated list of preferred formats for uploading Google docs.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'allow_import_name_change', + Help: 'Allow the filetype to change when uploading Google docs (e.g. file.doc to file.docx). This will confuse sync and reupload every time.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'use_created_date', + Help: 'Use file created date instead of modified date.,\n\nUseful when downloading data and you want the creation date used in\nplace of the last modified date.\n\n**WARNING**: This flag may have some unexpected consequences.\n\nWhen uploading to your drive all files will be overwritten unless they\nhaven\'t been modified since their creation. And the inverse will occur\nwhile downloading. This side effect can be avoided by using the\n"--checksum" flag.\n\nThis feature was implemented to retain photos capture date as recorded\nby google photos. You will first need to check the "Create a Google\nPhotos folder" option in your google drive settings. You can then copy\nor move the photos locally and use the date the image was taken\n(created) set as the modification date.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'list_chunk', + Help: 'Size of listing chunk 100-1000. 0 to disable.', + Provider: '', + Default: 1000, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'impersonate', + Help: 'Impersonate this user when using a service account.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'alternate_export', + Help: "Use alternate export URLs for google documents export.,\n\nIf this option is set this instructs rclone to use an alternate set of\nexport URLs for drive documents. Users have reported that the\nofficial export URLs can't export large documents, whereas these\nunofficial ones can.\n\nSee rclone issue [#2243](https://github.com/ncw/rclone/issues/2243) for background,\n[this google drive issue](https://issuetracker.google.com/issues/36761333) and\n[this helpful post](https://www.labnol.org/internet/direct-links-for-google-drive/28356/).", + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'upload_cutoff', + Help: 'Cutoff for switching to chunked upload', + Provider: '', + Default: 8388608, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_size', + Help: 'Upload chunk size. Must a power of 2 \u003e= 256k.\n\nMaking this larger will improve performance, but note that each chunk\nis buffered in memory one per transfer.\n\nReducing this will reduce memory usage but decrease performance.', + Provider: '', + Default: 8388608, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'acknowledge_abuse', + Help: 'Set to allow files which return cannotDownloadAbusiveFile to be downloaded.\n\nIf downloading a file returns the error "This file has been identified\nas malware or spam and cannot be downloaded" with the error code\n"cannotDownloadAbusiveFile" then supply this flag to rclone to\nindicate you acknowledge the risks of downloading the file and rclone\nwill download it anyway.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'keep_revision_forever', + Help: 'Keep new head revision of each file forever.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'v2_download_min_size', + Help: "If Object's are greater, use drive v2 API to download.", + Provider: '', + Default: -1, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'pacer_min_sleep', + Help: 'Minimum time to sleep between API calls.', + Provider: '', + Default: 100000000, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'pacer_burst', + Help: 'Number of API calls to allow without sleeping.', + Provider: '', + Default: 100, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'dropbox', + Description: 'Dropbox', + Prefix: 'dropbox', + Options: [ + { + Name: 'client_id', + Help: 'Dropbox App Client Id\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Dropbox App Client Secret\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'chunk_size', + Help: 'Upload chunk size. (\u003c 150M).\n\nAny files larger than this will be uploaded in chunks of this size.\n\nNote that chunks are buffered in memory (one at a time) so rclone can\ndeal with retries. Setting this larger will increase the speed\nslightly (at most 10% for 128MB in tests) at the cost of using more\nmemory. It can be set smaller if you are tight on memory.', + Provider: '', + Default: 50331648, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'impersonate', + Help: 'Impersonate this user when using a business account.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'ftp', + Description: 'FTP Connection', + Prefix: 'ftp', + Options: [ + { + Name: 'host', + Help: 'FTP host to connect to', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'ftp.example.com', + Help: 'Connect to ftp.example.com', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'user', + Help: 'FTP username, leave blank for current username, negative0', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'port', + Help: 'FTP port, leave blank to use default (21)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'pass', + Help: 'FTP password', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + ], + }, + { + Name: 'google cloud storage', + Description: 'Google Cloud Storage (this is not Google Drive)', + Prefix: 'gcs', + Options: [ + { + Name: 'client_id', + Help: 'Google Application Client Id\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Google Application Client Secret\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'project_number', + Help: 'Project number.\nOptional - needed only for list/create/delete buckets - see your developer console.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'service_account_file', + Help: 'Service Account Credentials JSON file path\nLeave blank normally.\nNeeded only if you want use SA instead of interactive login.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'service_account_credentials', + Help: 'Service Account Credentials JSON blob\nLeave blank normally.\nNeeded only if you want use SA instead of interactive login.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 3, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'object_acl', + Help: 'Access Control List for new objects.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'authenticatedRead', + Help: 'Object owner gets OWNER access, and all Authenticated Users get READER access.', + Provider: '', + }, + { + Value: 'bucketOwnerFullControl', + Help: 'Object owner gets OWNER access, and project team owners get OWNER access.', + Provider: '', + }, + { + Value: 'bucketOwnerRead', + Help: 'Object owner gets OWNER access, and project team owners get READER access.', + Provider: '', + }, + { + Value: 'private', + Help: 'Object owner gets OWNER access [default if left blank].', + Provider: '', + }, + { + Value: 'projectPrivate', + Help: 'Object owner gets OWNER access, and project team members get access according to their roles.', + Provider: '', + }, + { + Value: 'publicRead', + Help: 'Object owner gets OWNER access, and all Users get READER access.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'bucket_acl', + Help: 'Access Control List for new buckets.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'authenticatedRead', + Help: 'Project team owners get OWNER access, and all Authenticated Users get READER access.', + Provider: '', + }, + { + Value: 'private', + Help: 'Project team owners get OWNER access [default if left blank].', + Provider: '', + }, + { + Value: 'projectPrivate', + Help: 'Project team members get access according to their roles.', + Provider: '', + }, + { + Value: 'publicRead', + Help: 'Project team owners get OWNER access, and all Users get READER access.', + Provider: '', + }, + { + Value: 'publicReadWrite', + Help: 'Project team owners get OWNER access, and all Users get WRITER access.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'location', + Help: 'Location for the newly created buckets.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'Empty for default location (US).', + Provider: '', + }, + { + Value: 'asia', + Help: 'Multi-regional location for Asia.', + Provider: '', + }, + { + Value: 'eu', + Help: 'Multi-regional location for Europe.', + Provider: '', + }, + { + Value: 'us', + Help: 'Multi-regional location for United States.', + Provider: '', + }, + { + Value: 'asia-east1', + Help: 'Taiwan.', + Provider: '', + }, + { + Value: 'asia-east2', + Help: 'Hong Kong.', + Provider: '', + }, + { + Value: 'asia-northeast1', + Help: 'Tokyo.', + Provider: '', + }, + { + Value: 'asia-south1', + Help: 'Mumbai.', + Provider: '', + }, + { + Value: 'asia-southeast1', + Help: 'Singapore.', + Provider: '', + }, + { + Value: 'australia-southeast1', + Help: 'Sydney.', + Provider: '', + }, + { + Value: 'europe-north1', + Help: 'Finland.', + Provider: '', + }, + { + Value: 'europe-west1', + Help: 'Belgium.', + Provider: '', + }, + { + Value: 'europe-west2', + Help: 'London.', + Provider: '', + }, + { + Value: 'europe-west3', + Help: 'Frankfurt.', + Provider: '', + }, + { + Value: 'europe-west4', + Help: 'Netherlands.', + Provider: '', + }, + { + Value: 'us-central1', + Help: 'Iowa.', + Provider: '', + }, + { + Value: 'us-east1', + Help: 'South Carolina.', + Provider: '', + }, + { + Value: 'us-east4', + Help: 'Northern Virginia.', + Provider: '', + }, + { + Value: 'us-west1', + Help: 'Oregon.', + Provider: '', + }, + { + Value: 'us-west2', + Help: 'California.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'storage_class', + Help: 'The storage class to use when storing objects in Google Cloud Storage.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'Default', + Provider: '', + }, + { + Value: 'MULTI_REGIONAL', + Help: 'Multi-regional storage class', + Provider: '', + }, + { + Value: 'REGIONAL', + Help: 'Regional storage class', + Provider: '', + }, + { + Value: 'NEARLINE', + Help: 'Nearline storage class', + Provider: '', + }, + { + Value: 'COLDLINE', + Help: 'Coldline storage class', + Provider: '', + }, + { + Value: 'DURABLE_REDUCED_AVAILABILITY', + Help: 'Durable reduced availability storage class', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + ], + }, + { + Name: 'http', + Description: 'http Connection', + Prefix: 'http', + Options: [ + { + Name: 'url', + Help: 'URL of http host to connect to', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'https://example.com', + Help: 'Connect to example.com', + Provider: '', + }, + { + Value: 'https://user:pass@example.com', + Help: 'Connect to example.com using a username and password', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + ], + }, + { + Name: 'swift', + Description: 'Openstack Swift (Rackspace Cloud Files, Memset Memstore, OVH)', + Prefix: 'swift', + Options: [ + { + Name: 'env_auth', + Help: 'Get swift credentials from environment variables in standard OpenStack form.', + Provider: '', + Default: false, + Value: null, + Examples: [ + { + Value: 'false', + Help: 'Enter swift credentials in the next step', + Provider: '', + }, + { + Value: 'true', + Help: 'Get swift credentials from environment vars. Leave other fields blank if using this.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'user', + Help: 'User name to log in (OS_USERNAME).', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'key', + Help: 'API key or password (OS_PASSWORD).', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'auth', + Help: 'Authentication URL for server (OS_AUTH_URL).', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'https://auth.api.rackspacecloud.com/v1.0', + Help: 'Rackspace US', + Provider: '', + }, + { + Value: 'https://lon.auth.api.rackspacecloud.com/v1.0', + Help: 'Rackspace UK', + Provider: '', + }, + { + Value: 'https://identity.api.rackspacecloud.com/v2.0', + Help: 'Rackspace v2', + Provider: '', + }, + { + Value: 'https://auth.storage.memset.com/v1.0', + Help: 'Memset Memstore UK', + Provider: '', + }, + { + Value: 'https://auth.storage.memset.com/v2.0', + Help: 'Memset Memstore UK v2', + Provider: '', + }, + { + Value: 'https://auth.cloud.ovh.net/v2.0', + Help: 'OVH', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'user_id', + Help: 'User ID to log in - optional - most swift systems use user and leave this blank (v3 auth) (OS_USER_ID).', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'domain', + Help: 'User domain - optional (v3 auth) (OS_USER_DOMAIN_NAME)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'tenant', + Help: 'Tenant name - optional for v1 auth, this or tenant_id required otherwise (OS_TENANT_NAME or OS_PROJECT_NAME)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'tenant_id', + Help: 'Tenant ID - optional for v1 auth, this or tenant required otherwise (OS_TENANT_ID)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'tenant_domain', + Help: 'Tenant domain - optional (v3 auth) (OS_PROJECT_DOMAIN_NAME)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'region', + Help: 'Region name - optional (OS_REGION_NAME)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'storage_url', + Help: 'Storage URL - optional (OS_STORAGE_URL)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'auth_token', + Help: 'Auth Token from alternate authentication - optional (OS_AUTH_TOKEN)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'application_credential_id', + Help: 'Application Credential ID (OS_APPLICATION_CREDENTIAL_ID)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'application_credential_name', + Help: 'Application Credential Name (OS_APPLICATION_CREDENTIAL_NAME)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'application_credential_secret', + Help: 'Application Credential Secret (OS_APPLICATION_CREDENTIAL_SECRET)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'auth_version', + Help: 'AuthVersion - optional - set to (1,2,3) if your auth URL has no version (ST_AUTH_VERSION)', + Provider: '', + Default: 0, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'endpoint_type', + Help: 'Endpoint type to choose from the service catalogue (OS_ENDPOINT_TYPE)', + Provider: '', + Default: 'public', + Value: null, + Examples: [ + { + Value: 'public', + Help: 'Public (default, choose this if not sure)', + Provider: '', + }, + { + Value: 'internal', + Help: 'Internal (use internal service net)', + Provider: '', + }, + { + Value: 'admin', + Help: 'Admin', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'storage_policy', + Help: 'The storage policy to use when creating a new container\n\nThis applies the specified storage policy when creating a new\ncontainer. The policy cannot be changed afterwards. The allowed\nconfiguration values and their meaning depend on your Swift storage\nprovider.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'Default', + Provider: '', + }, + { + Value: 'pcs', + Help: 'OVH Public Cloud Storage', + Provider: '', + }, + { + Value: 'pca', + Help: 'OVH Public Cloud Archive', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'chunk_size', + Help: 'Above this size files will be chunked into a _segments container.\n\nAbove this size files will be chunked into a _segments container. The\ndefault for this is 5GB which is its maximum value.', + Provider: '', + Default: 5368709120, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'no_chunk', + Help: "Don't chunk files during streaming upload.\n\nWhen doing streaming uploads (eg using rcat or mount) setting this\nflag will cause the swift backend to not upload chunked files.\n\nThis will limit the maximum upload size to 5GB. However non chunked\nfiles are easier to deal with and have an MD5SUM.\n\nRclone will still chunk files bigger than chunk_size when doing normal\ncopy operations.", + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'hubic', + Description: 'Hubic', + Prefix: 'hubic', + Options: [ + { + Name: 'client_id', + Help: 'Hubic Client Id\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Hubic Client Secret\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'chunk_size', + Help: 'Above this size files will be chunked into a _segments container.\n\nAbove this size files will be chunked into a _segments container. The\ndefault for this is 5GB which is its maximum value.', + Provider: '', + Default: 5368709120, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'no_chunk', + Help: "Don't chunk files during streaming upload.\n\nWhen doing streaming uploads (eg using rcat or mount) setting this\nflag will cause the swift backend to not upload chunked files.\n\nThis will limit the maximum upload size to 5GB. However non chunked\nfiles are easier to deal with and have an MD5SUM.\n\nRclone will still chunk files bigger than chunk_size when doing normal\ncopy operations.", + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'jottacloud', + Description: 'JottaCloud', + Prefix: 'jottacloud', + Options: [ + { + Name: 'user', + Help: 'User Name:', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'mountpoint', + Help: 'The mountpoint to use.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'Sync', + Help: 'Will be synced by the official client.', + Provider: '', + }, + { + Value: 'Archive', + Help: 'Archive', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'md5_memory_limit', + Help: 'Files bigger than this will be cached on disk to calculate the MD5 if required.', + Provider: '', + Default: 10485760, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'hard_delete', + Help: 'Delete files permanently rather than putting them into the trash.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'unlink', + Help: 'Remove existing public link to file/folder with link command rather than creating.\nDefault is false, meaning link command will create or retrieve public link.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'upload_resume_limit', + Help: "Files bigger than this can be resumed if the upload fail's.", + Provider: '', + Default: 10485760, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'local', + Description: 'Local Disk', + Prefix: 'local', + Options: [ + { + Name: 'nounc', + Help: 'Disable UNC (long path names) conversion on Windows', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'true', + Help: 'Disables long file names', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'copy_links', + Help: 'Follow symlinks and copy the pointed to item.', + Provider: '', + Default: false, + Value: null, + ShortOpt: 'L', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: true, + Advanced: true, + }, + { + Name: 'links', + Help: "Translate symlinks to/from regular files with a '.rclonelink' extension", + Provider: '', + Default: false, + Value: null, + ShortOpt: 'l', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: true, + Advanced: true, + }, + { + Name: 'skip_links', + Help: "Don't warn about skipped symlinks.\nThis flag disables warning messages on skipped symlinks or junction\npoints, as you explicitly acknowledge that they should be skipped.", + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: true, + Advanced: true, + }, + { + Name: 'no_unicode_normalization', + Help: "Don't apply unicode normalization to paths and filenames (Deprecated)\n\nThis flag is deprecated now. Rclone no longer normalizes unicode file\nnames, but it compares them with unicode normalization in the sync\nroutine instead.", + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'no_check_updated', + Help: 'Don\'t check to see if the files change during upload\n\nNormally rclone checks the size and modification time of files as they\nare being uploaded and aborts with a message which starts "can\'t copy\n- source file is being updated" if the file changes during upload.\n\nHowever on some file systems this modification time check may fail (eg\n[Glusterfs #2206](https://github.com/ncw/rclone/issues/2206)) so this\ncheck can be disabled with this flag.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'one_file_system', + Help: "Don't cross filesystem boundaries (unix/macOS only).", + Provider: '', + Default: false, + Value: null, + ShortOpt: 'x', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: true, + Advanced: true, + }, + ], + }, + { + Name: 'mega', + Description: 'Mega', + Prefix: 'mega', + Options: [ + { + Name: 'user', + Help: 'User name', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'pass', + Help: 'Password.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'debug', + Help: 'Output more debug from Mega.\n\nIf this flag is set (along with -vv) it will print further debugging\ninformation from the mega backend.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'hard_delete', + Help: 'Delete files permanently rather than putting them into the trash.\n\nNormally the mega backend will put all deletions into the trash rather\nthan permanently deleting them. If you specify this then rclone will\npermanently delete objects instead.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'onedrive', + Description: 'Microsoft OneDrive', + Prefix: 'onedrive', + Options: [ + { + Name: 'client_id', + Help: 'Microsoft App Client Id\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Microsoft App Client Secret\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'chunk_size', + Help: 'Chunk size to upload files with - must be multiple of 320k.\n\nAbove this size files will be chunked - must be multiple of 320k. Note\nthat the chunks will be buffered into memory.', + Provider: '', + Default: 10485760, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'drive_id', + Help: 'The ID of the drive to use', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'drive_type', + Help: 'The type of the drive ( personal | business | documentLibrary )', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'expose_onenote_files', + Help: 'Set to make OneNote files show up in directory listings.\n\nBy default rclone will hide OneNote files in directory listings because\noperations like "Open" and "Update" won\'t work on them. But this\nbehaviour may also prevent you from deleting them. If you want to\ndelete OneNote files or otherwise want them to show up in directory\nlisting, set this option.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'opendrive', + Description: 'OpenDrive', + Prefix: 'opendrive', + Options: [ + { + Name: 'username', + Help: 'Username', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'password', + Help: 'Password.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + ], + }, + { + Name: 'pcloud', + Description: 'Pcloud', + Prefix: 'pcloud', + Options: [ + { + Name: 'client_id', + Help: 'Pcloud App Client Id\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Pcloud App Client Secret\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + ], + }, + { + Name: 'qingstor', + Description: 'QingCloud Object Storage', + Prefix: 'qingstor', + Options: [ + { + Name: 'env_auth', + Help: 'Get QingStor credentials from runtime. Only applies if access_key_id and secret_access_key is blank.', + Provider: '', + Default: false, + Value: null, + Examples: [ + { + Value: 'false', + Help: 'Enter QingStor credentials in the next step', + Provider: '', + }, + { + Value: 'true', + Help: 'Get QingStor credentials from the environment (env vars or IAM)', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'access_key_id', + Help: 'QingStor Access Key ID\nLeave blank for anonymous access or runtime credentials.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'secret_access_key', + Help: 'QingStor Secret Access Key (password)\nLeave blank for anonymous access or runtime credentials.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'endpoint', + Help: 'Enter a endpoint URL to connection QingStor API.\nLeave blank will use the default value "https://qingstor.com:443"', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'zone', + Help: 'Zone to connect to.\nDefault is "pek3a".', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'pek3a', + Help: 'The Beijing (China) Three Zone\nNeeds location constraint pek3a.', + Provider: '', + }, + { + Value: 'sh1a', + Help: 'The Shanghai (China) First Zone\nNeeds location constraint sh1a.', + Provider: '', + }, + { + Value: 'gd2a', + Help: 'The Guangdong (China) Second Zone\nNeeds location constraint gd2a.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'connection_retries', + Help: 'Number of connection retries.', + Provider: '', + Default: 3, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'upload_cutoff', + Help: 'Cutoff for switching to chunked upload\n\nAny files larger than this will be uploaded in chunks of chunk_size.\nThe minimum is 0 and the maximum is 5GB.', + Provider: '', + Default: 209715200, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_size', + Help: 'Chunk size to use for uploading.\n\nWhen uploading files larger than upload_cutoff they will be uploaded\nas multipart uploads using this chunk size.\n\nNote that "--qingstor-upload-concurrency" chunks of this size are buffered\nin memory per transfer.\n\nIf you are transferring large files over high speed links and you have\nenough memory, then increasing this will speed up the transfers.', + Provider: '', + Default: 4194304, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'upload_concurrency', + Help: 'Concurrency for multipart uploads.\n\nThis is the number of chunks of the same file that are uploaded\nconcurrently.\n\nNB if you set this to \u003e 1 then the checksums of multpart uploads\nbecome corrupted (the uploads themselves are not corrupted though).\n\nIf you are uploading small numbers of large file over high speed link\nand these uploads do not fully utilize your bandwidth, then increasing\nthis may help to speed up the transfers.', + Provider: '', + Default: 1, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 's3', + Description: + 'Amazon S3 Compliant Storage Provider (AWS, Alibaba, Ceph, Digital Ocean, Dreamhost, IBM COS, Minio, etc)', + Prefix: 's3', + Options: [ + { + Name: 'provider', + Help: 'Choose your S3 provider.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'AWS', + Help: 'Amazon Web Services (AWS) S3', + Provider: '', + }, + { + Value: 'Alibaba', + Help: 'Alibaba Cloud Object Storage System (OSS) formerly Aliyun', + Provider: '', + }, + { + Value: 'Ceph', + Help: 'Ceph Object Storage', + Provider: '', + }, + { + Value: 'DigitalOcean', + Help: 'Digital Ocean Spaces', + Provider: '', + }, + { + Value: 'Dreamhost', + Help: 'Dreamhost DreamObjects', + Provider: '', + }, + { + Value: 'IBMCOS', + Help: 'IBM COS S3', + Provider: '', + }, + { + Value: 'Minio', + Help: 'Minio Object Storage', + Provider: '', + }, + { + Value: 'Netease', + Help: 'Netease Object Storage (NOS)', + Provider: '', + }, + { + Value: 'Wasabi', + Help: 'Wasabi Object Storage', + Provider: '', + }, + { + Value: 'Other', + Help: 'Any other S3 compatible provider', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'env_auth', + Help: 'Get AWS credentials from runtime (environment variables or EC2/ECS meta data if no env vars).\nOnly applies if access_key_id and secret_access_key is blank.', + Provider: '', + Default: false, + Value: null, + Examples: [ + { + Value: 'false', + Help: 'Enter AWS credentials in the next step', + Provider: '', + }, + { + Value: 'true', + Help: 'Get AWS credentials from the environment (env vars or IAM)', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'access_key_id', + Help: 'AWS Access Key ID.\nLeave blank for anonymous access or runtime credentials.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'secret_access_key', + Help: 'AWS Secret Access Key (password)\nLeave blank for anonymous access or runtime credentials.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'region', + Help: 'Region to connect to.', + Provider: 'AWS', + Default: '', + Value: null, + Examples: [ + { + Value: 'us-east-1', + Help: 'The default endpoint - a good choice if you are unsure.\nUS Region, Northern Virginia or Pacific Northwest.\nLeave location constraint empty.', + Provider: '', + }, + { + Value: 'us-east-2', + Help: 'US East (Ohio) Region\nNeeds location constraint us-east-2.', + Provider: '', + }, + { + Value: 'us-west-2', + Help: 'US West (Oregon) Region\nNeeds location constraint us-west-2.', + Provider: '', + }, + { + Value: 'us-west-1', + Help: 'US West (Northern California) Region\nNeeds location constraint us-west-1.', + Provider: '', + }, + { + Value: 'ca-central-1', + Help: 'Canada (Central) Region\nNeeds location constraint ca-central-1.', + Provider: '', + }, + { + Value: 'eu-west-1', + Help: 'EU (Ireland) Region\nNeeds location constraint EU or eu-west-1.', + Provider: '', + }, + { + Value: 'eu-west-2', + Help: 'EU (London) Region\nNeeds location constraint eu-west-2.', + Provider: '', + }, + { + Value: 'eu-north-1', + Help: 'EU (Stockholm) Region\nNeeds location constraint eu-north-1.', + Provider: '', + }, + { + Value: 'eu-central-1', + Help: 'EU (Frankfurt) Region\nNeeds location constraint eu-central-1.', + Provider: '', + }, + { + Value: 'ap-southeast-1', + Help: 'Asia Pacific (Singapore) Region\nNeeds location constraint ap-southeast-1.', + Provider: '', + }, + { + Value: 'ap-southeast-2', + Help: 'Asia Pacific (Sydney) Region\nNeeds location constraint ap-southeast-2.', + Provider: '', + }, + { + Value: 'ap-northeast-1', + Help: 'Asia Pacific (Tokyo) Region\nNeeds location constraint ap-northeast-1.', + Provider: '', + }, + { + Value: 'ap-northeast-2', + Help: 'Asia Pacific (Seoul)\nNeeds location constraint ap-northeast-2.', + Provider: '', + }, + { + Value: 'ap-south-1', + Help: 'Asia Pacific (Mumbai)\nNeeds location constraint ap-south-1.', + Provider: '', + }, + { + Value: 'sa-east-1', + Help: 'South America (Sao Paulo) Region\nNeeds location constraint sa-east-1.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'region', + Help: "Region to connect to.\nLeave blank if you are using an S3 clone and you don't have a region.", + Provider: '!AWS,Alibaba', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'Use this if unsure. Will use v4 signatures and an empty region.', + Provider: '', + }, + { + Value: 'other-v2-signature', + Help: "Use this only if v4 signatures don't work, eg pre Jewel/v10 CEPH.", + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'endpoint', + Help: 'Endpoint for S3 API.\nLeave blank if using AWS to use the default endpoint for the region.', + Provider: 'AWS', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'endpoint', + Help: 'Endpoint for IBM COS S3 API.\nSpecify if using an IBM COS On Premise.', + Provider: 'IBMCOS', + Default: '', + Value: null, + Examples: [ + { + Value: 's3-api.us-geo.objectstorage.softlayer.net', + Help: 'US Cross Region Endpoint', + Provider: '', + }, + { + Value: 's3-api.dal.us-geo.objectstorage.softlayer.net', + Help: 'US Cross Region Dallas Endpoint', + Provider: '', + }, + { + Value: 's3-api.wdc-us-geo.objectstorage.softlayer.net', + Help: 'US Cross Region Washington DC Endpoint', + Provider: '', + }, + { + Value: 's3-api.sjc-us-geo.objectstorage.softlayer.net', + Help: 'US Cross Region San Jose Endpoint', + Provider: '', + }, + { + Value: 's3-api.us-geo.objectstorage.service.networklayer.com', + Help: 'US Cross Region Private Endpoint', + Provider: '', + }, + { + Value: 's3-api.dal-us-geo.objectstorage.service.networklayer.com', + Help: 'US Cross Region Dallas Private Endpoint', + Provider: '', + }, + { + Value: 's3-api.wdc-us-geo.objectstorage.service.networklayer.com', + Help: 'US Cross Region Washington DC Private Endpoint', + Provider: '', + }, + { + Value: 's3-api.sjc-us-geo.objectstorage.service.networklayer.com', + Help: 'US Cross Region San Jose Private Endpoint', + Provider: '', + }, + { + Value: 's3.us-east.objectstorage.softlayer.net', + Help: 'US Region East Endpoint', + Provider: '', + }, + { + Value: 's3.us-east.objectstorage.service.networklayer.com', + Help: 'US Region East Private Endpoint', + Provider: '', + }, + { + Value: 's3.us-south.objectstorage.softlayer.net', + Help: 'US Region South Endpoint', + Provider: '', + }, + { + Value: 's3.us-south.objectstorage.service.networklayer.com', + Help: 'US Region South Private Endpoint', + Provider: '', + }, + { + Value: 's3.eu-geo.objectstorage.softlayer.net', + Help: 'EU Cross Region Endpoint', + Provider: '', + }, + { + Value: 's3.fra-eu-geo.objectstorage.softlayer.net', + Help: 'EU Cross Region Frankfurt Endpoint', + Provider: '', + }, + { + Value: 's3.mil-eu-geo.objectstorage.softlayer.net', + Help: 'EU Cross Region Milan Endpoint', + Provider: '', + }, + { + Value: 's3.ams-eu-geo.objectstorage.softlayer.net', + Help: 'EU Cross Region Amsterdam Endpoint', + Provider: '', + }, + { + Value: 's3.eu-geo.objectstorage.service.networklayer.com', + Help: 'EU Cross Region Private Endpoint', + Provider: '', + }, + { + Value: 's3.fra-eu-geo.objectstorage.service.networklayer.com', + Help: 'EU Cross Region Frankfurt Private Endpoint', + Provider: '', + }, + { + Value: 's3.mil-eu-geo.objectstorage.service.networklayer.com', + Help: 'EU Cross Region Milan Private Endpoint', + Provider: '', + }, + { + Value: 's3.ams-eu-geo.objectstorage.service.networklayer.com', + Help: 'EU Cross Region Amsterdam Private Endpoint', + Provider: '', + }, + { + Value: 's3.eu-gb.objectstorage.softlayer.net', + Help: 'Great Britain Endpoint', + Provider: '', + }, + { + Value: 's3.eu-gb.objectstorage.service.networklayer.com', + Help: 'Great Britain Private Endpoint', + Provider: '', + }, + { + Value: 's3.ap-geo.objectstorage.softlayer.net', + Help: 'APAC Cross Regional Endpoint', + Provider: '', + }, + { + Value: 's3.tok-ap-geo.objectstorage.softlayer.net', + Help: 'APAC Cross Regional Tokyo Endpoint', + Provider: '', + }, + { + Value: 's3.hkg-ap-geo.objectstorage.softlayer.net', + Help: 'APAC Cross Regional HongKong Endpoint', + Provider: '', + }, + { + Value: 's3.seo-ap-geo.objectstorage.softlayer.net', + Help: 'APAC Cross Regional Seoul Endpoint', + Provider: '', + }, + { + Value: 's3.ap-geo.objectstorage.service.networklayer.com', + Help: 'APAC Cross Regional Private Endpoint', + Provider: '', + }, + { + Value: 's3.tok-ap-geo.objectstorage.service.networklayer.com', + Help: 'APAC Cross Regional Tokyo Private Endpoint', + Provider: '', + }, + { + Value: 's3.hkg-ap-geo.objectstorage.service.networklayer.com', + Help: 'APAC Cross Regional HongKong Private Endpoint', + Provider: '', + }, + { + Value: 's3.seo-ap-geo.objectstorage.service.networklayer.com', + Help: 'APAC Cross Regional Seoul Private Endpoint', + Provider: '', + }, + { + Value: 's3.mel01.objectstorage.softlayer.net', + Help: 'Melbourne Single Site Endpoint', + Provider: '', + }, + { + Value: 's3.mel01.objectstorage.service.networklayer.com', + Help: 'Melbourne Single Site Private Endpoint', + Provider: '', + }, + { + Value: 's3.tor01.objectstorage.softlayer.net', + Help: 'Toronto Single Site Endpoint', + Provider: '', + }, + { + Value: 's3.tor01.objectstorage.service.networklayer.com', + Help: 'Toronto Single Site Private Endpoint', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'endpoint', + Help: 'Endpoint for OSS API.', + Provider: 'Alibaba', + Default: '', + Value: null, + Examples: [ + { + Value: 'oss-cn-hangzhou.aliyuncs.com', + Help: 'East China 1 (Hangzhou)', + Provider: '', + }, + { + Value: 'oss-cn-shanghai.aliyuncs.com', + Help: 'East China 2 (Shanghai)', + Provider: '', + }, + { + Value: 'oss-cn-qingdao.aliyuncs.com', + Help: 'North China 1 (Qingdao)', + Provider: '', + }, + { + Value: 'oss-cn-beijing.aliyuncs.com', + Help: 'North China 2 (Beijing)', + Provider: '', + }, + { + Value: 'oss-cn-zhangjiakou.aliyuncs.com', + Help: 'North China 3 (Zhangjiakou)', + Provider: '', + }, + { + Value: 'oss-cn-huhehaote.aliyuncs.com', + Help: 'North China 5 (Huhehaote)', + Provider: '', + }, + { + Value: 'oss-cn-shenzhen.aliyuncs.com', + Help: 'South China 1 (Shenzhen)', + Provider: '', + }, + { + Value: 'oss-cn-hongkong.aliyuncs.com', + Help: 'Hong Kong (Hong Kong)', + Provider: '', + }, + { + Value: 'oss-us-west-1.aliyuncs.com', + Help: 'US West 1 (Silicon Valley)', + Provider: '', + }, + { + Value: 'oss-us-east-1.aliyuncs.com', + Help: 'US East 1 (Virginia)', + Provider: '', + }, + { + Value: 'oss-ap-southeast-1.aliyuncs.com', + Help: 'Southeast Asia Southeast 1 (Singapore)', + Provider: '', + }, + { + Value: 'oss-ap-southeast-2.aliyuncs.com', + Help: 'Asia Pacific Southeast 2 (Sydney)', + Provider: '', + }, + { + Value: 'oss-ap-southeast-3.aliyuncs.com', + Help: 'Southeast Asia Southeast 3 (Kuala Lumpur)', + Provider: '', + }, + { + Value: 'oss-ap-southeast-5.aliyuncs.com', + Help: 'Asia Pacific Southeast 5 (Jakarta)', + Provider: '', + }, + { + Value: 'oss-ap-northeast-1.aliyuncs.com', + Help: 'Asia Pacific Northeast 1 (Japan)', + Provider: '', + }, + { + Value: 'oss-ap-south-1.aliyuncs.com', + Help: 'Asia Pacific South 1 (Mumbai)', + Provider: '', + }, + { + Value: 'oss-eu-central-1.aliyuncs.com', + Help: 'Central Europe 1 (Frankfurt)', + Provider: '', + }, + { + Value: 'oss-eu-west-1.aliyuncs.com', + Help: 'West Europe (London)', + Provider: '', + }, + { + Value: 'oss-me-east-1.aliyuncs.com', + Help: 'Middle East 1 (Dubai)', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'endpoint', + Help: 'Endpoint for S3 API.\nRequired when using an S3 clone.', + Provider: '!AWS,IBMCOS,Alibaba', + Default: '', + Value: null, + Examples: [ + { + Value: 'objects-us-west-1.dream.io', + Help: 'Dream Objects endpoint', + Provider: 'Dreamhost', + }, + { + Value: 'nyc3.digitaloceanspaces.com', + Help: 'Digital Ocean Spaces New York 3', + Provider: 'DigitalOcean', + }, + { + Value: 'ams3.digitaloceanspaces.com', + Help: 'Digital Ocean Spaces Amsterdam 3', + Provider: 'DigitalOcean', + }, + { + Value: 'sgp1.digitaloceanspaces.com', + Help: 'Digital Ocean Spaces Singapore 1', + Provider: 'DigitalOcean', + }, + { + Value: 's3.wasabisys.com', + Help: 'Wasabi US East endpoint', + Provider: 'Wasabi', + }, + { + Value: 's3.us-west-1.wasabisys.com', + Help: 'Wasabi US West endpoint', + Provider: 'Wasabi', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'location_constraint', + Help: 'Location constraint - must be set to match the Region.\nUsed when creating buckets only.', + Provider: 'AWS', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'Empty for US Region, Northern Virginia or Pacific Northwest.', + Provider: '', + }, + { + Value: 'us-east-2', + Help: 'US East (Ohio) Region.', + Provider: '', + }, + { + Value: 'us-west-2', + Help: 'US West (Oregon) Region.', + Provider: '', + }, + { + Value: 'us-west-1', + Help: 'US West (Northern California) Region.', + Provider: '', + }, + { + Value: 'ca-central-1', + Help: 'Canada (Central) Region.', + Provider: '', + }, + { + Value: 'eu-west-1', + Help: 'EU (Ireland) Region.', + Provider: '', + }, + { + Value: 'eu-west-2', + Help: 'EU (London) Region.', + Provider: '', + }, + { + Value: 'eu-north-1', + Help: 'EU (Stockholm) Region.', + Provider: '', + }, + { + Value: 'EU', + Help: 'EU Region.', + Provider: '', + }, + { + Value: 'ap-southeast-1', + Help: 'Asia Pacific (Singapore) Region.', + Provider: '', + }, + { + Value: 'ap-southeast-2', + Help: 'Asia Pacific (Sydney) Region.', + Provider: '', + }, + { + Value: 'ap-northeast-1', + Help: 'Asia Pacific (Tokyo) Region.', + Provider: '', + }, + { + Value: 'ap-northeast-2', + Help: 'Asia Pacific (Seoul)', + Provider: '', + }, + { + Value: 'ap-south-1', + Help: 'Asia Pacific (Mumbai)', + Provider: '', + }, + { + Value: 'sa-east-1', + Help: 'South America (Sao Paulo) Region.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'location_constraint', + Help: 'Location constraint - must match endpoint when using IBM Cloud Public.\nFor on-prem COS, do not make a selection from this list, hit enter', + Provider: 'IBMCOS', + Default: '', + Value: null, + Examples: [ + { + Value: 'us-standard', + Help: 'US Cross Region Standard', + Provider: '', + }, + { + Value: 'us-vault', + Help: 'US Cross Region Vault', + Provider: '', + }, + { + Value: 'us-cold', + Help: 'US Cross Region Cold', + Provider: '', + }, + { + Value: 'us-flex', + Help: 'US Cross Region Flex', + Provider: '', + }, + { + Value: 'us-east-standard', + Help: 'US East Region Standard', + Provider: '', + }, + { + Value: 'us-east-vault', + Help: 'US East Region Vault', + Provider: '', + }, + { + Value: 'us-east-cold', + Help: 'US East Region Cold', + Provider: '', + }, + { + Value: 'us-east-flex', + Help: 'US East Region Flex', + Provider: '', + }, + { + Value: 'us-south-standard', + Help: 'US South Region Standard', + Provider: '', + }, + { + Value: 'us-south-vault', + Help: 'US South Region Vault', + Provider: '', + }, + { + Value: 'us-south-cold', + Help: 'US South Region Cold', + Provider: '', + }, + { + Value: 'us-south-flex', + Help: 'US South Region Flex', + Provider: '', + }, + { + Value: 'eu-standard', + Help: 'EU Cross Region Standard', + Provider: '', + }, + { + Value: 'eu-vault', + Help: 'EU Cross Region Vault', + Provider: '', + }, + { + Value: 'eu-cold', + Help: 'EU Cross Region Cold', + Provider: '', + }, + { + Value: 'eu-flex', + Help: 'EU Cross Region Flex', + Provider: '', + }, + { + Value: 'eu-gb-standard', + Help: 'Great Britain Standard', + Provider: '', + }, + { + Value: 'eu-gb-vault', + Help: 'Great Britain Vault', + Provider: '', + }, + { + Value: 'eu-gb-cold', + Help: 'Great Britain Cold', + Provider: '', + }, + { + Value: 'eu-gb-flex', + Help: 'Great Britain Flex', + Provider: '', + }, + { + Value: 'ap-standard', + Help: 'APAC Standard', + Provider: '', + }, + { + Value: 'ap-vault', + Help: 'APAC Vault', + Provider: '', + }, + { + Value: 'ap-cold', + Help: 'APAC Cold', + Provider: '', + }, + { + Value: 'ap-flex', + Help: 'APAC Flex', + Provider: '', + }, + { + Value: 'mel01-standard', + Help: 'Melbourne Standard', + Provider: '', + }, + { + Value: 'mel01-vault', + Help: 'Melbourne Vault', + Provider: '', + }, + { + Value: 'mel01-cold', + Help: 'Melbourne Cold', + Provider: '', + }, + { + Value: 'mel01-flex', + Help: 'Melbourne Flex', + Provider: '', + }, + { + Value: 'tor01-standard', + Help: 'Toronto Standard', + Provider: '', + }, + { + Value: 'tor01-vault', + Help: 'Toronto Vault', + Provider: '', + }, + { + Value: 'tor01-cold', + Help: 'Toronto Cold', + Provider: '', + }, + { + Value: 'tor01-flex', + Help: 'Toronto Flex', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'location_constraint', + Help: 'Location constraint - must be set to match the Region.\nLeave blank if not sure. Used when creating buckets only.', + Provider: '!AWS,IBMCOS,Alibaba', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'acl', + Help: "Canned ACL used when creating buckets and storing or copying objects.\n\nThis ACL is used for creating objects and if bucket_acl isn't set, for creating buckets too.\n\nFor more info visit https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl\n\nNote that this ACL is applied when server side copying objects as S3\ndoesn't copy the ACL from the source but rather writes a fresh one.", + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'private', + Help: 'Owner gets FULL_CONTROL. No one else has access rights (default).', + Provider: '!IBMCOS', + }, + { + Value: 'public-read', + Help: 'Owner gets FULL_CONTROL. The AllUsers group gets READ access.', + Provider: '!IBMCOS', + }, + { + Value: 'public-read-write', + Help: 'Owner gets FULL_CONTROL. The AllUsers group gets READ and WRITE access.\nGranting this on a bucket is generally not recommended.', + Provider: '!IBMCOS', + }, + { + Value: 'authenticated-read', + Help: 'Owner gets FULL_CONTROL. The AuthenticatedUsers group gets READ access.', + Provider: '!IBMCOS', + }, + { + Value: 'bucket-owner-read', + Help: 'Object owner gets FULL_CONTROL. Bucket owner gets READ access.\nIf you specify this canned ACL when creating a bucket, Amazon S3 ignores it.', + Provider: '!IBMCOS', + }, + { + Value: 'bucket-owner-full-control', + Help: 'Both the object owner and the bucket owner get FULL_CONTROL over the object.\nIf you specify this canned ACL when creating a bucket, Amazon S3 ignores it.', + Provider: '!IBMCOS', + }, + { + Value: 'private', + Help: 'Owner gets FULL_CONTROL. No one else has access rights (default). This acl is available on IBM Cloud (Infra), IBM Cloud (Storage), On-Premise COS', + Provider: 'IBMCOS', + }, + { + Value: 'public-read', + Help: 'Owner gets FULL_CONTROL. The AllUsers group gets READ access. This acl is available on IBM Cloud (Infra), IBM Cloud (Storage), On-Premise IBM COS', + Provider: 'IBMCOS', + }, + { + Value: 'public-read-write', + Help: 'Owner gets FULL_CONTROL. The AllUsers group gets READ and WRITE access. This acl is available on IBM Cloud (Infra), On-Premise IBM COS', + Provider: 'IBMCOS', + }, + { + Value: 'authenticated-read', + Help: 'Owner gets FULL_CONTROL. The AuthenticatedUsers group gets READ access. Not supported on Buckets. This acl is available on IBM Cloud (Infra) and On-Premise IBM COS', + Provider: 'IBMCOS', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'bucket_acl', + Help: 'Canned ACL used when creating buckets.\n\nFor more info visit https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl\n\nNote that this ACL is applied when only when creating buckets. If it\nisn\'t set then "acl" is used instead.', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'private', + Help: 'Owner gets FULL_CONTROL. No one else has access rights (default).', + Provider: '', + }, + { + Value: 'public-read', + Help: 'Owner gets FULL_CONTROL. The AllUsers group gets READ access.', + Provider: '', + }, + { + Value: 'public-read-write', + Help: 'Owner gets FULL_CONTROL. The AllUsers group gets READ and WRITE access.\nGranting this on a bucket is generally not recommended.', + Provider: '', + }, + { + Value: 'authenticated-read', + Help: 'Owner gets FULL_CONTROL. The AuthenticatedUsers group gets READ access.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'server_side_encryption', + Help: 'The server-side encryption algorithm used when storing this object in S3.', + Provider: 'AWS', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'None', + Provider: '', + }, + { + Value: 'AES256', + Help: 'AES256', + Provider: '', + }, + { + Value: 'aws:kms', + Help: 'aws:kms', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'sse_kms_key_id', + Help: 'If using KMS ID you must provide the ARN of Key.', + Provider: 'AWS', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'None', + Provider: '', + }, + { + Value: 'arn:aws:kms:us-east-1:*', + Help: 'arn:aws:kms:*', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'storage_class', + Help: 'The storage class to use when storing new objects in S3.', + Provider: 'AWS', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'Default', + Provider: '', + }, + { + Value: 'STANDARD', + Help: 'Standard storage class', + Provider: '', + }, + { + Value: 'REDUCED_REDUNDANCY', + Help: 'Reduced redundancy storage class', + Provider: '', + }, + { + Value: 'STANDARD_IA', + Help: 'Standard Infrequent Access storage class', + Provider: '', + }, + { + Value: 'ONEZONE_IA', + Help: 'One Zone Infrequent Access storage class', + Provider: '', + }, + { + Value: 'GLACIER', + Help: 'Glacier storage class', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'storage_class', + Help: 'The storage class to use when storing new objects in OSS.', + Provider: 'Alibaba', + Default: '', + Value: null, + Examples: [ + { + Value: '', + Help: 'Default', + Provider: '', + }, + { + Value: 'STANDARD', + Help: 'Standard storage class', + Provider: '', + }, + { + Value: 'GLACIER', + Help: 'Archive storage mode.', + Provider: '', + }, + { + Value: 'STANDARD_IA', + Help: 'Infrequent access storage mode.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'upload_cutoff', + Help: 'Cutoff for switching to chunked upload\n\nAny files larger than this will be uploaded in chunks of chunk_size.\nThe minimum is 0 and the maximum is 5GB.', + Provider: '', + Default: 209715200, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'chunk_size', + Help: 'Chunk size to use for uploading.\n\nWhen uploading files larger than upload_cutoff they will be uploaded\nas multipart uploads using this chunk size.\n\nNote that "--s3-upload-concurrency" chunks of this size are buffered\nin memory per transfer.\n\nIf you are transferring large files over high speed links and you have\nenough memory, then increasing this will speed up the transfers.', + Provider: '', + Default: 5242880, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'disable_checksum', + Help: "Don't store MD5 checksum with object metadata", + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'session_token', + Help: 'An AWS session token', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'upload_concurrency', + Help: 'Concurrency for multipart uploads.\n\nThis is the number of chunks of the same file that are uploaded\nconcurrently.\n\nIf you are uploading small numbers of large file over high speed link\nand these uploads do not fully utilize your bandwidth, then increasing\nthis may help to speed up the transfers.', + Provider: '', + Default: 4, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'force_path_style', + Help: 'If true use path style access if false use virtual hosted style.\n\nIf this is true (the default) then rclone will use path style access,\nif false then rclone will use virtual path style. See [the AWS S3\ndocs](https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingBucket.html#access-bucket-intro)\nfor more info.\n\nSome providers (eg Aliyun OSS or Netease COS) require this set to false.', + Provider: '', + Default: true, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'v2_auth', + Help: "If true use v2 authentication.\n\nIf this is false (the default) then rclone will use v4 authentication.\nIf it is set then rclone will use v2 authentication.\n\nUse this only if v4 signatures don't work, eg pre Jewel/v10 CEPH.", + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'sftp', + Description: 'SSH/SFTP Connection', + Prefix: 'sftp', + Options: [ + { + Name: 'host', + Help: 'SSH host to connect to', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'example.com', + Help: 'Connect to example.com', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'user', + Help: 'SSH username, leave blank for current username, negative0', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'port', + Help: 'SSH port, leave blank to use default (22)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'pass', + Help: 'SSH password, leave blank to use ssh-agent.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'key_file', + Help: 'Path to PEM-encoded private key file, leave blank or set key-use-agent to use ssh-agent.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'key_file_pass', + Help: "The passphrase to decrypt the PEM-encoded private key file.\n\nOnly PEM encrypted key files (old OpenSSH format) are supported. Encrypted keys\nin the new OpenSSH format can't be used.", + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'key_use_agent', + Help: 'When set forces the usage of the ssh-agent.\n\nWhen key-file is also set, the ".pub" file of the specified key-file is read and only the associated key is\nrequested from the ssh-agent. This allows to avoid `Too many authentication failures for *username*` errors\nwhen the ssh-agent contains many keys.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'use_insecure_cipher', + Help: 'Enable the use of the aes128-cbc cipher. This cipher is insecure and may allow plaintext data to be recovered by an attacker.', + Provider: '', + Default: false, + Value: null, + Examples: [ + { + Value: 'false', + Help: 'Use default Cipher list.', + Provider: '', + }, + { + Value: 'true', + Help: 'Enables the use of the aes128-cbc cipher.', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'disable_hashcheck', + Help: 'Disable the execution of SSH commands to determine if remote file hashing is available.\nLeave blank or set to false to enable hashing (recommended), set to true to disable hashing.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'ask_password', + Help: 'Allow asking for SFTP password when needed.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'path_override', + Help: 'Override path used by SSH connection.\n\nThis allows checksum calculation when SFTP and SSH paths are\ndifferent. This issue affects among others Synology NAS boxes.\n\nShared folders can be found in directories representing volumes\n\n rclone sync /home/local/directory remote:/directory --ssh-path-override /volume2/directory\n\nHome directory can be found in a shared folder called "home"\n\n rclone sync /home/local/directory remote:/home/directory --ssh-path-override /volume1/homes/USER/directory', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + { + Name: 'set_modtime', + Help: 'Set the modified time on the remote if set.', + Provider: '', + Default: true, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, + { + Name: 'union', + Description: + 'A stackable unification remote, which can appear to merge the contents of several remotes', + Prefix: 'union', + Options: [ + { + Name: 'remotes', + Help: "List of space separated remotes.\nCan be 'remotea:test/dir remoteb:', '\"remotea:test/space dir\" remoteb:', etc.\nThe last remote is used to write to.", + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + ], + }, + { + Name: 'webdav', + Description: 'Webdav', + Prefix: 'webdav', + Options: [ + { + Name: 'url', + Help: 'URL of http host to connect to', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'https://example.com', + Help: 'Connect to example.com', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: true, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'vendor', + Help: 'Name of the Webdav site/service/software you are using', + Provider: '', + Default: '', + Value: null, + Examples: [ + { + Value: 'nextcloud', + Help: 'Nextcloud', + Provider: '', + }, + { + Value: 'owncloud', + Help: 'Owncloud', + Provider: '', + }, + { + Value: 'sharepoint', + Help: 'Sharepoint', + Provider: '', + }, + { + Value: 'other', + Help: 'Other site/service or software', + Provider: '', + }, + ], + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'user', + Help: 'User name', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'pass', + Help: 'Password.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: true, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'bearer_token', + Help: 'Bearer token instead of user/pass (eg a Macaroon)', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + ], + }, + { + Name: 'yandex', + Description: 'Yandex Disk', + Prefix: 'yandex', + Options: [ + { + Name: 'client_id', + Help: 'Yandex Client Id\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'client_secret', + Help: 'Yandex Client Secret\nLeave blank normally.', + Provider: '', + Default: '', + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: false, + }, + { + Name: 'unlink', + Help: 'Remove existing public link to file/folder with link command rather than creating.\nDefault is false, meaning link command will create or retrieve public link.', + Provider: '', + Default: false, + Value: null, + ShortOpt: '', + Hide: 0, + Required: false, + IsPassword: false, + NoPrefix: false, + Advanced: true, + }, + ], + }, +]; diff --git a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.spec.ts b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.spec.ts new file mode 100644 index 000000000..6301202a0 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.spec.ts @@ -0,0 +1,339 @@ +import type { JsonSchema7, Layout, SchemaBasedCondition } from '@jsonforms/core'; +import { beforeEach, describe, expect, it } from 'vitest'; + +// Adjusted path assuming rclone.model.ts is sibling to jsonforms dir +import type { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; +import { config as rawProviderConfig } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/config.js'; // Added .js extension +import { getProviderConfigSlice } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.js'; // Added .js extension + +// --- Data Processing --- + +// Type assertion for the imported config +interface RawProviderConfigEntry { + Name: string; + Options: RCloneProviderOptionResponse[]; + // Add other properties from config.ts if needed, though Name and Options are primary +} + +// Process the raw config into the format expected by the functions under test +const providerOptionsMap: Record = ( + rawProviderConfig as RawProviderConfigEntry[] +).reduce( + (acc, provider) => { + if (provider.Name && Array.isArray(provider.Options)) { + // Ensure options conform to the expected type structure if necessary + // For now, we assume the structure matches RCloneProviderOptionResponse + acc[provider.Name] = provider.Options; + } + return acc; + }, + {} as Record +); + +const providerNames = Object.keys(providerOptionsMap); + +// --- Test Suite --- + +describe('getProviderConfigSlice', () => { + // Example provider to use in tests - choose one with both standard and advanced options + const testProvider = 's3'; // S3 usually has a good mix + let s3Options: RCloneProviderOptionResponse[]; + + beforeEach(() => { + // Ensure we have the options for the test provider + s3Options = providerOptionsMap[testProvider]; + expect(s3Options).toBeDefined(); + expect(s3Options.length).toBeGreaterThan(0); + }); + + it('should return an empty slice if the provider name is invalid', () => { + const result = getProviderConfigSlice({ + selectedProvider: 'invalid-provider-name', + providerOptions: [], // Doesn't matter for this case + isAdvancedStep: false, + stepIndex: 1, + }); + expect(result.properties).toEqual({}); + expect(result.elements).toEqual([]); + }); + + it('should return an empty slice if providerOptions are empty', () => { + const result = getProviderConfigSlice({ + selectedProvider: testProvider, // Valid provider + providerOptions: [], // Empty options + isAdvancedStep: false, + stepIndex: 1, + }); + expect(result.properties).toEqual({}); + expect(result.elements).toEqual([]); + }); + + it('should return only standard options when isAdvancedStep is false', () => { + const result = getProviderConfigSlice({ + selectedProvider: testProvider, + providerOptions: s3Options, + isAdvancedStep: false, + stepIndex: 1, + }); + + // Check properties schema + expect(result.properties).toBeDefined(); + expect(result.properties.parameters).toBeDefined(); + const paramProps = result.properties.parameters?.properties || {}; + expect(Object.keys(paramProps).length).toBeGreaterThan(0); + + // Check that all properties included are standard (Advanced !== true) + const standardOptions = s3Options.filter((opt) => opt.Advanced !== true); + const uniqueStandardOptionNames = [...new Set(standardOptions.map((opt) => opt.Name))]; + + // Assert against the count of UNIQUE standard option names + expect(Object.keys(paramProps).length).toEqual(uniqueStandardOptionNames.length); + + // Check that each unique standard option name exists in the generated props + uniqueStandardOptionNames.forEach((name) => { + expect(paramProps[name]).toBeDefined(); + // Find the first option with this name to check title (or implement more complex logic if needed) + const correspondingOption = standardOptions.find((opt) => opt.Name === name); + expect(paramProps[name]?.title).toEqual(correspondingOption?.Name); + }); + + // Check UI elements - compare count against unique names + expect(result.elements).toBeDefined(); + // Expect a single VerticalLayout containing the actual elements + expect(result.elements).toHaveLength(1); + const verticalLayoutStd = result.elements[0]; + expect(verticalLayoutStd.type).toBe('VerticalLayout'); + expect(Array.isArray(verticalLayoutStd.elements)).toBe(true); + expect(verticalLayoutStd.elements?.length).toEqual(uniqueStandardOptionNames.length); + + // Check elements based on unique names + uniqueStandardOptionNames.forEach((name) => { + // Use `as any` for type assertion on the result elements array + // Adjust to check within the VerticalLayout's elements + const elementsArray = verticalLayoutStd.elements as any[]; + // Find element by scope instead of label + const expectedScope = `#/properties/parameters/properties/${name}`; + const element = elementsArray.find((el) => el.scope === expectedScope); + expect(element).toBeDefined(); // Check if element was found + if (element) { + // Check the type of the wrapper layout + expect(element.type).toEqual('UnraidSettingsLayout'); + } + }); + }); + + it('should return only advanced options when isAdvancedStep is true', () => { + const result = getProviderConfigSlice({ + selectedProvider: testProvider, + providerOptions: s3Options, + isAdvancedStep: true, + stepIndex: 2, + }); + + // Check properties schema + expect(result.properties).toBeDefined(); + expect(result.properties.parameters).toBeDefined(); + const paramProps = result.properties.parameters?.properties || {}; + expect(Object.keys(paramProps).length).toBeGreaterThan(0); + + // Check that all properties included are advanced (Advanced === true) + const advancedOptions = s3Options.filter((opt) => opt.Advanced === true); + const uniqueAdvancedOptionNames = [...new Set(advancedOptions.map((opt) => opt.Name))]; + + // Assert against the count of UNIQUE advanced option names + expect(Object.keys(paramProps).length).toEqual(uniqueAdvancedOptionNames.length); + + // Check that each unique advanced option name exists in the generated props + uniqueAdvancedOptionNames.forEach((name) => { + expect(paramProps[name]).toBeDefined(); + const correspondingOption = advancedOptions.find((opt) => opt.Name === name); + expect(paramProps[name]?.title).toEqual(correspondingOption?.Name); + }); + + // Check UI elements - compare count against unique names + expect(result.elements).toBeDefined(); + // Expect a single VerticalLayout containing the actual elements + expect(result.elements).toHaveLength(1); + const verticalLayoutAdv = result.elements[0]; + expect(verticalLayoutAdv.type).toBe('VerticalLayout'); + expect(Array.isArray(verticalLayoutAdv.elements)).toBe(true); + expect(verticalLayoutAdv.elements?.length).toEqual(uniqueAdvancedOptionNames.length); + + // Check elements based on unique names + uniqueAdvancedOptionNames.forEach((name) => { + // Use `as any` for type assertion on the result elements array + // Adjust to check within the VerticalLayout's elements + const elementsArray = verticalLayoutAdv.elements as any[]; + // Find element by scope instead of label + const expectedScope = `#/properties/parameters/properties/${name}`; + const element = elementsArray.find((el) => el.scope === expectedScope); + expect(element).toBeDefined(); // Check if element was found + if (element) { + // Check the type of the wrapper layout + expect(element.type).toEqual('UnraidSettingsLayout'); + } + }); + }); + + it('should return an empty slice for advanced options if none exist for the provider', () => { + const testProviderNoAdvanced = 'alias'; // 'alias' provider typically has no advanced options + const aliasOptions = providerOptionsMap[testProviderNoAdvanced]; + + // Pre-check: Verify that the chosen provider actually has no advanced options in our data + const hasAdvanced = aliasOptions?.some((opt) => opt.Advanced === true); + expect(hasAdvanced).toBe(false); // Ensure our assumption about 'alias' holds + + const result = getProviderConfigSlice({ + selectedProvider: testProviderNoAdvanced, + providerOptions: aliasOptions || [], + isAdvancedStep: true, + stepIndex: 2, + }); + + // Expect empty results because no advanced options should be found + expect(result.properties).toEqual({}); // Should not even have parameters object + expect(result.elements).toEqual([]); + }); + + it('should handle duplicate option names within the same type (standard/advanced)', () => { + const duplicateOptions: RCloneProviderOptionResponse[] = [ + { Name: 'test_opt', Help: 'First', Advanced: false, Provider: '' }, + { Name: 'duplicate_opt', Help: 'Keep this one', Advanced: false, Provider: '' }, + { Name: 'duplicate_opt', Help: 'Skip this one', Advanced: false, Provider: '' }, + { Name: 'another_opt', Help: 'Another', Advanced: false, Provider: '' }, + ]; + + const result = getProviderConfigSlice({ + selectedProvider: 'test', + providerOptions: duplicateOptions, + isAdvancedStep: false, + stepIndex: 1, + }); + + // Check properties - should only contain unique names + const paramProps = result.properties.parameters?.properties || {}; + expect(Object.keys(paramProps)).toEqual(['test_opt', 'duplicate_opt', 'another_opt']); + expect(paramProps['duplicate_opt']?.description).toBe('Keep this one'); // Check it kept the first one + + // Check elements - should only contain unique names + // Expect a single VerticalLayout containing the actual elements + expect(result.elements).toHaveLength(1); + const verticalLayoutDup = result.elements[0]; + expect(verticalLayoutDup.type).toBe('VerticalLayout'); + expect(Array.isArray(verticalLayoutDup.elements)).toBe(true); + expect(verticalLayoutDup.elements?.length).toBe(3); + + const foundDuplicateElement = verticalLayoutDup.elements?.find((el: any) => + el.scope?.includes('duplicate_opt') + ); + expect(foundDuplicateElement).toBeDefined(); + const duplicateLabelElement = foundDuplicateElement?.elements?.find( + (innerEl: any) => innerEl.type === 'Label' + ); + expect(duplicateLabelElement?.options?.description).toBe('Keep this one'); + const containsSkipped = verticalLayoutDup.elements?.some((el: any) => + el.elements?.some( + (innerEl: any) => + innerEl.type === 'Label' && innerEl.options?.description === 'Skip this one' + ) + ); + expect(containsSkipped).toBe(false); + }); + + it('should add a SHOW rule for positive Provider filters', () => { + const providerSpecificOptions: RCloneProviderOptionResponse[] = [ + { Name: 'always_show', Help: 'Always Visible', Provider: '' }, + { Name: 's3_only', Help: 'S3 Specific', Provider: 's3' }, + { Name: 'gdrive_only', Help: 'GDrive Specific', Provider: 'google drive' }, // Check space handling + ]; + + const result = getProviderConfigSlice({ + selectedProvider: 'anyProvider', + providerOptions: providerSpecificOptions, + isAdvancedStep: false, + stepIndex: 1, + }); + + // Expect a single VerticalLayout containing the actual elements + expect(result.elements).toHaveLength(1); + const verticalLayoutPos = result.elements[0]; + expect(verticalLayoutPos.type).toBe('VerticalLayout'); + expect(verticalLayoutPos.elements?.length).toBe(3); + + const alwaysShowEl = verticalLayoutPos.elements?.find((el: any) => + el.scope.includes('always_show') + ); + const s3OnlyEl = verticalLayoutPos.elements?.find((el: any) => el.scope.includes('s3_only')); + const gdriveOnlyEl = verticalLayoutPos.elements?.find((el: any) => + el.scope.includes('gdrive_only') + ); + + expect(alwaysShowEl).toBeDefined(); + expect(s3OnlyEl).toBeDefined(); + expect(gdriveOnlyEl).toBeDefined(); + + expect(alwaysShowEl!.rule).toBeUndefined(); + + expect(s3OnlyEl!.rule).toBeDefined(); + expect(s3OnlyEl!.rule!.effect).toBe('SHOW'); + // Explicitly cast condition to SchemaBasedCondition + const s3Condition = s3OnlyEl!.rule!.condition as SchemaBasedCondition; + expect(s3Condition.scope).toBe('#/properties/type'); + expect(s3Condition.schema).toEqual({ enum: ['s3'] }); + + expect(gdriveOnlyEl!.rule).toBeDefined(); + expect(gdriveOnlyEl!.rule!.effect).toBe('SHOW'); + // Explicitly cast condition to SchemaBasedCondition + const gdriveCondition = gdriveOnlyEl!.rule!.condition as SchemaBasedCondition; + expect(gdriveCondition.scope).toBe('#/properties/type'); + expect(gdriveCondition.schema).toEqual({ enum: ['google drive'] }); + }); + + it('should add a SHOW rule with negated condition for negative Provider filters', () => { + const providerSpecificOptions: RCloneProviderOptionResponse[] = [ + { Name: 'not_s3', Help: 'Not S3', Provider: '!s3' }, + { + Name: 'not_s3_or_gdrive', + Help: 'Not S3 or GDrive', + Provider: '!s3, google drive ', + }, // Check trimming + ]; + + const result = getProviderConfigSlice({ + selectedProvider: 'anyProvider', + providerOptions: providerSpecificOptions, + isAdvancedStep: false, + stepIndex: 1, + }); + + // Expect a single VerticalLayout containing the actual elements + expect(result.elements).toHaveLength(1); + const verticalLayoutNeg = result.elements[0]; + expect(verticalLayoutNeg.type).toBe('VerticalLayout'); + expect(verticalLayoutNeg.elements?.length).toBe(2); + + const notS3El = verticalLayoutNeg.elements?.find((el: any) => el.scope.includes('not_s3')); + const notS3OrGDriveEl = verticalLayoutNeg.elements?.find((el: any) => + el.scope.includes('not_s3_or_gdrive') + ); + + expect(notS3El).toBeDefined(); + expect(notS3OrGDriveEl).toBeDefined(); + + expect(notS3El!.rule).toBeDefined(); + expect(notS3El!.rule!.effect).toBe('SHOW'); + // Explicitly cast condition to SchemaBasedCondition + const notS3Condition = notS3El!.rule!.condition as SchemaBasedCondition; + expect(notS3Condition.scope).toBe('#/properties/type'); + expect(notS3Condition.schema).toEqual({ not: { enum: ['s3'] } }); + + expect(notS3OrGDriveEl!.rule).toBeDefined(); + expect(notS3OrGDriveEl!.rule!.effect).toBe('SHOW'); + // Explicitly cast condition to SchemaBasedCondition + const notS3OrGDriveCondition = notS3OrGDriveEl!.rule!.condition as SchemaBasedCondition; + expect(notS3OrGDriveCondition.scope).toBe('#/properties/type'); + expect(notS3OrGDriveCondition.schema).toEqual({ not: { enum: ['s3', 'google drive'] } }); + }); + + // More tests will be added here... +}); diff --git a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts new file mode 100644 index 000000000..199f318b2 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts @@ -0,0 +1,446 @@ +import type { LabelElement, Layout, Rule, SchemaBasedCondition } from '@jsonforms/core'; +import { JsonSchema7, RuleEffect } from '@jsonforms/core'; + +import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js'; +import { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; +import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js'; +import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js'; + +function translateRCloneOptionToJsonSchema({ + option, +}: { + option: RCloneProviderOptionResponse; +}): JsonSchema7 { + const schema: JsonSchema7 = { + type: getJsonSchemaType(option.Type || 'string'), + title: option.Name, + description: option.Help || '', + }; + + if (option.Default !== undefined && option.Default !== '') { + if ((option.Type === 'SizeSuffix' || option.Type === 'Duration') && option.Default === 'off') { + schema.default = 'off'; + } else if (schema.type === 'number' && typeof option.Default === 'number') { + schema.default = option.Default; + } else if (schema.type === 'integer' && Number.isInteger(option.Default)) { + schema.default = option.Default; + } else if (schema.type === 'boolean' && typeof option.Default === 'boolean') { + schema.default = option.Default; + } else if (schema.type === 'string') { + schema.default = String(option.Default); + } + } + + const format = getJsonFormElementForType({ + rcloneType: option.Type, + examples: option.Examples?.map((example) => example.Value), + isPassword: option.IsPassword, + }); + if (format && format !== schema.type && format !== 'combobox') { + schema.format = format; + } + + if (option.Required) { + if (schema.type === 'string') { + schema.minLength = 1; + } + } + + switch (option.Type?.toLowerCase()) { + case 'int': + break; + case 'sizesuffix': + schema.pattern = '^(off|(\\d+([KMGTPE]i?B?)?)+)$'; + schema.errorMessage = 'Invalid size format. Examples: "10G", "100M", "1.5GiB", "off".'; + break; + case 'duration': + schema.pattern = '^(off|(d+(.d+)?(ns|us|\u00b5s|ms|s|m|h))+)$'; + schema.errorMessage = + 'Invalid duration format. Examples: "10s", "1.5m", "100ms", "1h15m", "off".'; + break; + } + + return schema; +} + +function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): SettingSlice { + const basicConfigElements: UIElement[] = [ + createLabeledControl({ + scope: '#/properties/name', + label: 'Remote Name', + description: + 'Name to identify this remote configuration (e.g., my_google_drive). Use only letters, numbers, hyphens, and underscores.', + controlOptions: { + placeholder: 'Enter a name', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/type', + label: 'Storage Provider Type', + description: 'Select the cloud storage provider to use for this remote.', + controlOptions: {}, + }), + { + type: 'Label', + text: 'Documentation Link', + options: { + description: + 'For more information, refer to the [RClone Config Documentation](https://rclone.org/commands/rclone_config/).', + }, + } as LabelElement, + createLabeledControl({ + scope: '#/properties/showAdvanced', + label: 'Show Advanced Options', + description: 'Display additional configuration options for experts.', + controlOptions: { + toggle: true, + }, + layoutOptions: { + style: 'margin-top: 1em;', + }, + }), + ]; + + const basicConfigProperties: Record = { + name: { + type: 'string', + title: 'Remote Name', + description: 'Name to identify this remote configuration', + pattern: '^[a-zA-Z0-9_-]+$', + minLength: 1, + maxLength: 50, + }, + type: { + type: 'string', + title: 'Provider Type', + default: providerTypes.length > 0 ? providerTypes[0] : '', + enum: providerTypes, + }, + showAdvanced: { + type: 'boolean', + title: 'Show Advanced Options', + description: 'Whether to show advanced configuration options.', + default: false, + }, + }; + + const verticalLayoutElement: UIElement = { + type: 'VerticalLayout', + elements: basicConfigElements, + options: { step: 0 }, + }; + + return { + properties: basicConfigProperties as unknown as DataSlice, + elements: [verticalLayoutElement], + }; +} + +export function getProviderConfigSlice({ + selectedProvider, + providerOptions, + isAdvancedStep, + stepIndex, +}: { + selectedProvider: string; + providerOptions: RCloneProviderOptionResponse[]; + isAdvancedStep: boolean; + stepIndex: number; +}): SettingSlice { + const configProperties: DataSlice = {}; + + if (!selectedProvider || !providerOptions || providerOptions.length === 0) { + return { + properties: configProperties, + elements: [], + }; + } + + const filteredOptions = providerOptions.filter((option) => { + if (isAdvancedStep) { + return option.Advanced === true; + } else { + return option.Advanced !== true; + } + }); + + const uniqueOptionsByName = filteredOptions.reduce((acc, current) => { + if (!acc.find((item) => item.Name === current.Name)) { + acc.push(current); + } else { + console.warn( + `Duplicate RClone option name skipped in ${isAdvancedStep ? 'advanced' : 'standard'} slice: ${current.Name}` + ); + } + return acc; + }, [] as RCloneProviderOptionResponse[]); + + if (uniqueOptionsByName.length === 0) { + return { + properties: configProperties, + elements: [], + }; + } + + const controlElements = uniqueOptionsByName + .filter((option) => { + const providerFilter = option.Provider?.trim(); + return !(option.Hide === 1 && !providerFilter); + }) + .map((option): UIElement => { + const format = getJsonFormElementForType({ + rcloneType: option.Type, + examples: option.Examples?.map((example) => example.Value), + isPassword: option.IsPassword, + }); + + const controlOptions: Record = { + placeholder: option.Default?.toString() || '', + required: option.Required || false, + format, + }; + + if (option.Examples && option.Examples.length > 0) { + const exampleValues = option.Examples.map((example) => example.Value).join(', '); + controlOptions.placeholder = `e.g., ${exampleValues}`; + } + + if (format === 'checkbox' && (!option.Examples || option.Examples.length === 0)) { + controlOptions.toggle = true; + } + + if (format === 'combobox' && option.Examples && option.Examples.length > 0) { + const isBooleanType = getJsonSchemaType(option.Type ?? '') === 'boolean'; + controlOptions.suggestions = option.Examples.map((example) => ({ + value: isBooleanType + ? String(example.Value ?? '').toLowerCase() === 'true' + : example.Value, + label: String(example.Value ?? ''), + tooltip: example.Help || '', + })); + } + + let providerRule: Rule | undefined = undefined; + const providerFilter = option.Provider?.trim(); + + if (providerFilter) { + const isNegated = providerFilter.startsWith('!'); + const providers = (isNegated ? providerFilter.substring(1) : providerFilter) + .split(',') + .map((p) => p.trim()) + .filter((p) => p); + + if (providers.length > 0) { + const conditionSchema = isNegated + ? { not: { enum: providers } } + : { enum: providers }; + + const effect = option.Hide === 1 ? RuleEffect.HIDE : RuleEffect.SHOW; + + providerRule = { + effect: effect, + condition: { + scope: '#/properties/type', + schema: conditionSchema, + } as SchemaBasedCondition, + }; + } + } + + const labeledControl = createLabeledControl({ + scope: `#/properties/parameters/properties/${option.Name}`, + label: option.Name, + description: option.Help || undefined, + controlOptions: controlOptions, + rule: providerRule, + }); + + return labeledControl; + }); + + const paramProperties: Record = {}; + uniqueOptionsByName.forEach((option) => { + if (option) { + paramProperties[option.Name] = translateRCloneOptionToJsonSchema({ option }); + } + }); + + if (Object.keys(paramProperties).length > 0) { + if (!configProperties.parameters) { + configProperties.parameters = { type: 'object', properties: {} } as any; + } else if (!(configProperties.parameters as any).properties) { + (configProperties.parameters as any).properties = {}; + } + (configProperties.parameters as any).properties = { + ...(configProperties.parameters as any).properties, + ...paramProperties, + }; + } + + const verticalLayoutElement: UIElement = { + type: 'VerticalLayout', + elements: controlElements, + options: { step: stepIndex, showDividers: true }, + }; + + return { + properties: configProperties, + elements: [verticalLayoutElement], + }; +} + +function getJsonSchemaType(rcloneType: string): string { + switch (rcloneType?.toLowerCase()) { + case 'int': + return 'integer'; + case 'size': + case 'number': + return 'number'; + case 'sizesuffix': + case 'duration': + return 'string'; + case 'bool': + return 'boolean'; + case 'string': + case 'text': + case 'password': + default: + return 'string'; + } +} + +function getJsonFormElementForType({ + rcloneType = '', + examples = null, + isPassword = false, +}: { + rcloneType?: string; + examples?: string[] | null; + isPassword?: boolean; +}): string | undefined { + if (isPassword) { + return 'password'; + } + + switch (rcloneType?.toLowerCase()) { + case 'int': + case 'size': + return undefined; + case 'sizesuffix': + return undefined; + case 'duration': + return undefined; + case 'bool': + return 'toggle'; + case 'text': + return undefined; + case 'password': + return 'password'; + case 'string': + default: + if (examples && examples.length > 0) { + return 'combobox'; + } + return undefined; + } +} + +export function buildRcloneConfigSchema({ + providerTypes = [], + selectedProvider = '', + providerOptions = {}, + showAdvanced = false, +}: { + providerTypes?: string[]; + selectedProvider?: string; + providerOptions?: Record; + showAdvanced?: boolean; +}): { + dataSchema: { properties: DataSlice; type: 'object' }; + uiSchema: Layout; +} { + const optionsForProvider = providerOptions[selectedProvider] || []; + const slicesToMerge: SettingSlice[] = []; + + const basicSlice = getBasicConfigSlice({ providerTypes }); + slicesToMerge.push(basicSlice); + + if (selectedProvider && optionsForProvider.length > 0) { + const standardConfigSlice = getProviderConfigSlice({ + selectedProvider, + providerOptions: optionsForProvider, + isAdvancedStep: false, + stepIndex: 1, + }); + if ( + standardConfigSlice.elements.length > 0 || + Object.keys(standardConfigSlice.properties).length > 0 + ) { + slicesToMerge.push(standardConfigSlice); + } + } + + let advancedConfigSlice: SettingSlice | null = null; + if (showAdvanced && selectedProvider && optionsForProvider.length > 0) { + advancedConfigSlice = getProviderConfigSlice({ + selectedProvider, + providerOptions: optionsForProvider, + isAdvancedStep: true, + stepIndex: 2, + }); + if ( + advancedConfigSlice.elements.length > 0 || + Object.keys(advancedConfigSlice.properties).length > 0 + ) { + slicesToMerge.push(advancedConfigSlice); + } + } + + const mergedSlices = mergeSettingSlices(slicesToMerge); + + const dataSchema: { properties: DataSlice; type: 'object' } = { + type: 'object', + properties: mergedSlices.properties, + }; + + const steps = [{ label: 'Set up Remote Config', description: 'Name and provider selection' }]; + + if (selectedProvider) { + steps.push({ label: 'Set up Drive', description: 'Provider-specific configuration' }); + } + if ( + showAdvanced && + advancedConfigSlice && + (advancedConfigSlice.elements.length > 0 || + Object.keys(advancedConfigSlice.properties).length > 0) + ) { + steps.push({ label: 'Advanced Config', description: 'Optional advanced settings' }); + } + + const steppedLayoutElement: UIElement = { + type: 'SteppedLayout', + options: { + steps: steps, + }, + elements: mergedSlices.elements, + }; + + const titleLabel: UIElement = { + type: 'Label', + text: 'Configure RClone Remote', + options: { + format: 'title', + description: + 'This process will guide you through setting up your RClone remote configuration.', + }, + }; + + const uiSchema: Layout = { + type: 'VerticalLayout', + elements: [titleLabel, steppedLayoutElement], + }; + + return { dataSchema, uiSchema }; +} diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts new file mode 100644 index 000000000..c8af35aa4 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts @@ -0,0 +1,394 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import crypto from 'crypto'; +import { ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { execa } from 'execa'; +import got, { HTTPError } from 'got'; +import pRetry from 'p-retry'; + +import { sanitizeParams } from '@app/core/log.js'; +import { + CreateRCloneRemoteDto, + DeleteRCloneRemoteDto, + GetRCloneJobStatusDto, + GetRCloneRemoteConfigDto, + GetRCloneRemoteDetailsDto, + RCloneProviderOptionResponse, + RCloneProviderResponse, + RCloneRemoteConfig, + RCloneStartBackupInput, + UpdateRCloneRemoteDto, +} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; +import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; + +@Injectable() +export class RCloneApiService implements OnModuleInit, OnModuleDestroy { + private isInitialized: boolean = false; + private readonly logger = new Logger(RCloneApiService.name); + private rcloneSocketPath: string = ''; + private rcloneBaseUrl: string = ''; + private rcloneProcess: ChildProcess | null = null; + private readonly rcloneUsername: string = + process.env.RCLONE_USERNAME || crypto.randomBytes(12).toString('base64'); + private readonly rclonePassword: string = + process.env.RCLONE_PASSWORD || crypto.randomBytes(24).toString('base64'); + constructor() {} + + async onModuleInit(): Promise { + try { + const { getters } = await import('@app/store/index.js'); + // Check if Rclone Socket is running, if not, start it. + this.rcloneSocketPath = getters.paths()['rclone-socket']; + const logFilePath = join(getters.paths()['log-base'], 'rclone-unraid-api.log'); + this.logger.log(`RClone socket path: ${this.rcloneSocketPath}`); + this.logger.log(`RClone log file path: ${logFilePath}`); + + // Format the base URL for Unix socket + this.rcloneBaseUrl = `http://unix:${this.rcloneSocketPath}:`; + + // Check if the RClone socket exists, if not, create it. + const socketExists = await this.checkRcloneSocketExists(this.rcloneSocketPath); + + if (socketExists) { + const isRunning = await this.checkRcloneSocketRunning(); + if (isRunning) { + this.isInitialized = true; + return; + } else { + this.logger.warn( + 'RClone socket is not running but socket exists, removing socket before starting...' + ); + await rm(this.rcloneSocketPath, { force: true }); + } + + this.logger.warn('RClone socket is not running, starting it...'); + this.isInitialized = await this.startRcloneSocket(this.rcloneSocketPath, logFilePath); + return; + } else { + this.logger.warn('RClone socket does not exist, creating it...'); + this.isInitialized = await this.startRcloneSocket(this.rcloneSocketPath, logFilePath); + return; + } + } catch (error: unknown) { + this.logger.error(`Error initializing RCloneApiService: ${error}`); + this.isInitialized = false; + } + } + + async onModuleDestroy(): Promise { + await this.stopRcloneSocket(); + this.logger.log('RCloneApiService module destroyed'); + } + + /** + * Starts the RClone RC daemon on the specified socket path + */ + private async startRcloneSocket(socketPath: string, logFilePath: string): Promise { + try { + // Make log file exists + if (!existsSync(logFilePath)) { + this.logger.debug(`Creating log file: ${logFilePath}`); + await mkdir(dirname(logFilePath), { recursive: true }); + await writeFile(logFilePath, '', 'utf-8'); + } + this.logger.log(`Starting RClone RC daemon on socket: ${socketPath}`); + // Start the process but don't wait for it to finish + this.rcloneProcess = execa( + 'rclone', + [ + 'rcd', + '--rc-addr', + socketPath, + '--log-level', + 'INFO', + '--log-file', + logFilePath, + ...(this.rcloneUsername ? ['--rc-user', this.rcloneUsername] : []), + ...(this.rclonePassword ? ['--rc-pass', this.rclonePassword] : []), + ], + { detached: false } // Keep attached to manage lifecycle + ); + + // Handle potential errors during process spawning (e.g., command not found) + this.rcloneProcess.on('error', (error: Error) => { + this.logger.error(`RClone process failed to start: ${error.message}`); + this.rcloneProcess = null; // Clear the handle on error + this.isInitialized = false; + }); + + // Handle unexpected exit + this.rcloneProcess.on('exit', (code, signal) => { + this.logger.warn( + `RClone process exited unexpectedly with code: ${code}, signal: ${signal}` + ); + this.rcloneProcess = null; + this.isInitialized = false; + }); + + // Wait for socket to be ready using p-retry with exponential backoff + await pRetry( + async () => { + const isRunning = await this.checkRcloneSocketRunning(); + if (!isRunning) throw new Error('Rclone socket not ready'); + }, + { + retries: 6, // 7 attempts total + minTimeout: 100, + maxTimeout: 5000, + factor: 2, + maxRetryTime: 30000, + } + ); + + return true; + } catch (error: unknown) { + this.logger.error(`Error starting RClone RC daemon: ${error}`); + this.rcloneProcess?.kill(); // Attempt to kill if started but failed later + this.rcloneProcess = null; + return false; + } + } + + private async stopRcloneSocket(): Promise { + if (this.rcloneProcess && !this.rcloneProcess.killed) { + this.logger.log(`Stopping RClone RC daemon process (PID: ${this.rcloneProcess.pid})...`); + try { + const killed = this.rcloneProcess.kill('SIGTERM'); // Send SIGTERM first + if (!killed) { + this.logger.warn('Failed to kill RClone process with SIGTERM, trying SIGKILL.'); + this.rcloneProcess.kill('SIGKILL'); // Force kill if SIGTERM failed + } + this.logger.log('RClone process stopped.'); + } catch (error: unknown) { + this.logger.error(`Error stopping RClone process: ${error}`); + } finally { + this.rcloneProcess = null; // Clear the handle + } + } else { + this.logger.log('RClone process not running or already stopped.'); + } + + // Clean up the socket file if it exists + if (this.rcloneSocketPath && existsSync(this.rcloneSocketPath)) { + this.logger.log(`Removing RClone socket file: ${this.rcloneSocketPath}`); + try { + await rm(this.rcloneSocketPath, { force: true }); + } catch (error: unknown) { + this.logger.error(`Error removing RClone socket file: ${error}`); + } + } + } + + /** + * Checks if the RClone socket exists + */ + private async checkRcloneSocketExists(socketPath: string): Promise { + const socketExists = existsSync(socketPath); + if (!socketExists) { + this.logger.warn(`RClone socket does not exist at: ${socketPath}`); + return false; + } + return true; + } + + /** + * Checks if the RClone socket is running + */ + private async checkRcloneSocketRunning(): Promise { + // Use the API check instead of execa('rclone', ['about']) as rclone might not be in PATH + // or configured correctly for the execa environment vs the rcd environment. + try { + // A simple API call to check if the daemon is responsive + await this.callRcloneApi('core/pid'); + this.logger.debug('RClone socket is running and responsive.'); + return true; + } catch (error: unknown) { + // Log less verbosely during checks + // this.logger.error(`Error checking RClone socket: ${error}`); + return false; + } + } + + /** + * Get providers supported by RClone + */ + async getProviders(): Promise { + const response = (await this.callRcloneApi('config/providers')) as { + providers: RCloneProviderResponse[]; + }; + return response?.providers || []; + } + + /** + * List all remotes configured in rclone + */ + async listRemotes(): Promise { + const response = (await this.callRcloneApi('config/listremotes')) as { remotes: string[] }; + return response?.remotes || []; + } + + /** + * Get complete remote details + */ + async getRemoteDetails(input: GetRCloneRemoteDetailsDto): Promise { + await validateObject(GetRCloneRemoteDetailsDto, input); + const config = (await this.getRemoteConfig({ name: input.name })) || {}; + return config as RCloneRemoteConfig; + } + + /** + * Get configuration of a remote + */ + async getRemoteConfig(input: GetRCloneRemoteConfigDto): Promise { + await validateObject(GetRCloneRemoteConfigDto, input); + return this.callRcloneApi('config/get', { name: input.name }); + } + + /** + * Create a new remote configuration + */ + async createRemote(input: CreateRCloneRemoteDto): Promise { + await validateObject(CreateRCloneRemoteDto, input); + this.logger.log(`Creating new remote: ${input.name} of type: ${input.type}`); + const params = { + name: input.name, + type: input.type, + parameters: input.parameters, + }; + const result = await this.callRcloneApi('config/create', params); + this.logger.log(`Successfully created remote: ${input.name}`); + return result; + } + + /** + * Update an existing remote configuration + */ + async updateRemote(input: UpdateRCloneRemoteDto): Promise { + await validateObject(UpdateRCloneRemoteDto, input); + this.logger.log(`Updating remote: ${input.name}`); + const params = { + name: input.name, + ...input.parameters, + }; + return this.callRcloneApi('config/update', params); + } + + /** + * Delete a remote configuration + */ + async deleteRemote(input: DeleteRCloneRemoteDto): Promise { + await validateObject(DeleteRCloneRemoteDto, input); + this.logger.log(`Deleting remote: ${input.name}`); + return this.callRcloneApi('config/delete', { name: input.name }); + } + + /** + * Start a backup operation using sync/copy + * This copies a directory from source to destination + */ + async startBackup(input: RCloneStartBackupInput): Promise { + await validateObject(RCloneStartBackupInput, input); + this.logger.log(`Starting backup from ${input.srcPath} to ${input.dstPath}`); + const params = { + srcFs: input.srcPath, + dstFs: input.dstPath, + ...(input.options || {}), + }; + return this.callRcloneApi('sync/copy', params); + } + + /** + * Get the status of a running job + */ + async getJobStatus(input: GetRCloneJobStatusDto): Promise { + await validateObject(GetRCloneJobStatusDto, input); + return this.callRcloneApi('job/status', { jobid: input.jobId }); + } + + /** + * List all running jobs + */ + async listRunningJobs(): Promise { + return this.callRcloneApi('job/list'); + } + + /** + * Generic method to call the RClone RC API + */ + private async callRcloneApi(endpoint: string, params: Record = {}): Promise { + const url = `${this.rcloneBaseUrl}/${endpoint}`; + try { + this.logger.debug( + `Calling RClone API: ${url} with params: ${JSON.stringify(sanitizeParams(params))}` + ); + + const response = await got.post(url, { + json: params, + responseType: 'json', + enableUnixSockets: true, + headers: { + Authorization: `Basic ${Buffer.from(`${this.rcloneUsername}:${this.rclonePassword}`).toString('base64')}`, + }, + }); + + return response.body; + } catch (error: unknown) { + this.handleApiError(error, endpoint, params); + } + } + + private handleApiError(error: unknown, endpoint: string, params: Record): never { + if (error instanceof HTTPError) { + const statusCode = error.response.statusCode; + const rcloneError = this.extractRcloneError(error.response.body, params); + const detailedErrorMessage = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`; + + const sanitizedParams = sanitizeParams(params); + this.logger.error( + `Original ${detailedErrorMessage} | Params: ${JSON.stringify(sanitizedParams)}`, + error.stack + ); + + throw new Error(detailedErrorMessage); + } else if (error instanceof Error) { + const detailedErrorMessage = `Error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${error.message}`; + this.logger.error(detailedErrorMessage, error.stack); + throw error; + } else { + const detailedErrorMessage = `Unknown error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${String(error)}`; + this.logger.error(detailedErrorMessage); + throw new Error(detailedErrorMessage); + } + } + + private extractRcloneError(responseBody: unknown, fallbackParams: Record): string { + try { + let errorBody: unknown; + if (typeof responseBody === 'string') { + errorBody = JSON.parse(responseBody); + } else if (typeof responseBody === 'object' && responseBody !== null) { + errorBody = responseBody; + } + + if (errorBody && typeof errorBody === 'object' && 'error' in errorBody) { + const typedErrorBody = errorBody as { error: unknown; input?: unknown }; + let rcloneError = `Rclone Error: ${String(typedErrorBody.error)}`; + if (typedErrorBody.input) { + rcloneError += ` | Input: ${JSON.stringify(typedErrorBody.input)}`; + } else if (fallbackParams) { + rcloneError += ` | Original Params: ${JSON.stringify(fallbackParams)}`; + } + return rcloneError; + } else if (responseBody) { + return `Non-standard error response body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`; + } else { + return 'Empty error response body received.'; + } + } catch (parseOrAccessError) { + return `Failed to process error response body. Raw body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`; + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts new file mode 100644 index 000000000..d0ae11009 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { type Layout } from '@jsonforms/core'; + +import { buildRcloneConfigSchema } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.js'; +import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; +import { + RCloneConfigFormInput, + RCloneProviderOptionResponse, +} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; +import { DataSlice } from '@app/unraid-api/types/json-forms.js'; + +/** + * Service responsible for generating form UI schemas and form logic + */ +@Injectable() +export class RCloneFormService { + private readonly logger = new Logger(RCloneFormService.name); + private providerNames: string[] = []; + private providerOptions: Record = {}; + + constructor(private readonly rcloneApiService: RCloneApiService) {} + + /** + * Loads RClone provider types and options + */ + private async loadProviderInfo(): Promise { + try { + const providersResponse = await this.rcloneApiService.getProviders(); + if (providersResponse) { + // Extract provider types + this.providerNames = providersResponse.map((provider) => provider.Name); + this.providerOptions = providersResponse.reduce((acc, provider) => { + acc[provider.Name] = provider.Options; + return acc; + }, {}); + this.logger.debug(`Loaded ${this.providerNames.length} provider types`); + } + } catch (error) { + this.logger.error(`Error loading provider information: ${error}`); + throw error; + } + } + + /** + * Returns both data schema and UI schema for the form + */ + async getFormSchemas(options: RCloneConfigFormInput): Promise<{ + dataSchema: { properties: DataSlice; type: 'object' }; + uiSchema: Layout; + }> { + const { providerType: selectedProvider = '', showAdvanced = false } = options; + + // Ensure provider info is loaded + if (Object.keys(this.providerOptions).length === 0) { + await this.loadProviderInfo(); + } + + return buildRcloneConfigSchema({ + providerTypes: this.providerNames, + selectedProvider, + providerOptions: this.providerOptions, + showAdvanced, + }); + } +} diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts new file mode 100644 index 000000000..97cc7d04f --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts @@ -0,0 +1,208 @@ +import { Field, ID, InputType, ObjectType } from '@nestjs/graphql'; + +import { type Layout } from '@jsonforms/core'; +import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; +import { GraphQLJSON } from 'graphql-scalars'; + +import { DataSlice } from '@app/unraid-api/types/json-forms.js'; + +@ObjectType() +export class RCloneDrive { + @Field(() => String, { description: 'Provider name' }) + name!: string; + + @Field(() => GraphQLJSON, { description: 'Provider options and configuration schema' }) + options!: Record; +} + +/** + * Raw response format from rclone API + */ +export interface RCloneProviderResponse { + Name: string; + Description: string; + Prefix: string; + Options: RCloneProviderOptionResponse[]; + CommandHelp?: string | null; + Aliases?: string[] | null; + Hide?: boolean; + MetadataInfo?: Record; +} + +/** + * Raw option format from rclone API + */ +export interface RCloneProviderOptionResponse { + Name: string; + Help: string; + Provider: string; + Default?: unknown; + Value?: unknown; + ShortOpt?: string; + Hide?: number; + Required?: boolean; + IsPassword?: boolean; + NoPrefix?: boolean; + Advanced?: boolean; + DefaultStr?: string; + ValueStr?: string; + Type?: string; + Examples?: Array<{ Value: string; Help: string; Provider: string }>; +} + +/** + * Complete remote configuration as returned by rclone + */ +export interface RCloneRemoteConfig { + type: string; + [key: string]: unknown; +} + +@InputType() +export class RCloneConfigFormInput { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + providerType?: string; + + @Field(() => Boolean, { defaultValue: false, nullable: true }) + @IsOptional() + @IsBoolean() + showAdvanced?: boolean; + + @Field(() => GraphQLJSON, { nullable: true }) + @IsOptional() + @IsObject() + parameters?: Record; +} + +@ObjectType() +export class RCloneBackupConfigForm { + @Field(() => ID) + id!: string; + + @Field(() => GraphQLJSON) + dataSchema!: { properties: DataSlice; type: 'object' }; + + @Field(() => GraphQLJSON) + uiSchema!: Layout; +} + +@ObjectType() +export class RCloneBackupSettings { + @Field(() => RCloneBackupConfigForm) + configForm!: RCloneBackupConfigForm; + + @Field(() => [RCloneDrive]) + drives!: RCloneDrive[]; + + @Field(() => [RCloneRemote]) + remotes!: RCloneRemote[]; +} + +@ObjectType() +export class RCloneRemote { + @Field(() => String) + name!: string; + + @Field(() => String) + type!: string; + + @Field(() => GraphQLJSON) + parameters!: Record; + + @Field(() => GraphQLJSON, { description: 'Complete remote configuration' }) + config!: RCloneRemoteConfig; +} + +@InputType() +export class CreateRCloneRemoteInput { + @Field(() => String) + @IsString() + name!: string; + + @Field(() => String) + @IsString() + type!: string; + + @Field(() => GraphQLJSON) + @IsObject() + parameters!: Record; +} + +@InputType() +export class DeleteRCloneRemoteInput { + @Field(() => String) + @IsString() + name!: string; +} + +@InputType() +export class RCloneStartBackupInput { + @Field(() => String) + @IsString() + srcPath!: string; + + @Field(() => String) + @IsString() + dstPath!: string; + + @Field(() => GraphQLJSON, { nullable: true }) + @IsOptional() + @IsObject() + options?: Record; +} + +@InputType() +export class CreateRCloneRemoteDto { + @Field(() => String) + @IsString() + name!: string; + + @Field(() => String) + @IsString() + type!: string; + + @Field(() => GraphQLJSON) + @IsObject() + parameters!: Record; +} + +@InputType() +export class UpdateRCloneRemoteDto { + @Field(() => String) + @IsString() + name!: string; + + @Field(() => GraphQLJSON) + @IsObject() + parameters!: Record; +} + +@InputType() +export class DeleteRCloneRemoteDto { + @Field(() => String) + @IsString() + name!: string; +} + +@InputType() +export class GetRCloneRemoteConfigDto { + @Field(() => String) + @IsString() + name!: string; +} + +@InputType() +export class GetRCloneRemoteDetailsDto { + @Field(() => String) + @IsString() + name!: string; +} + +@InputType() +export class GetRCloneJobStatusDto { + @Field(() => String) + @IsString() + jobId!: string; +} diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.module.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.module.ts new file mode 100644 index 000000000..ff4619f11 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; + +import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; +import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js'; +import { RCloneMutationsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.js'; +import { RCloneBackupSettingsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.resolver.js'; +import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js'; + +@Module({ + imports: [], + providers: [ + RCloneService, + RCloneApiService, + RCloneFormService, + RCloneBackupSettingsResolver, + RCloneMutationsResolver, + ], + exports: [RCloneService, RCloneApiService], +}) +export class RCloneModule {} diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.ts new file mode 100644 index 000000000..57419ef5b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.ts @@ -0,0 +1,63 @@ +import { Logger } from '@nestjs/common'; +import { Args, ResolveField, Resolver } from '@nestjs/graphql'; + +import { + AuthActionVerb, + AuthPossession, + UsePermissions, +} from '@app/unraid-api/graph/directives/use-permissions.directive.js'; +import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js'; +import { RCloneMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; +import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; +import { + CreateRCloneRemoteInput, + DeleteRCloneRemoteInput, + RCloneRemote, +} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; + +/** + * Nested Resolvers for Mutations MUST use @ResolveField() instead of @Mutation() + */ +@Resolver(() => RCloneMutations) +export class RCloneMutationsResolver { + private readonly logger = new Logger(RCloneMutationsResolver.name); + + constructor(private readonly rcloneApiService: RCloneApiService) {} + + @ResolveField(() => RCloneRemote, { description: 'Create a new RClone remote' }) + @UsePermissions({ + action: AuthActionVerb.CREATE, + resource: Resource.FLASH, + possession: AuthPossession.ANY, + }) + async createRCloneRemote(@Args('input') input: CreateRCloneRemoteInput): Promise { + try { + const config = await this.rcloneApiService.createRemote(input); + return { + name: input.name, + type: input.type, + parameters: {}, + config, + }; + } catch (error) { + this.logger.error(`Error creating remote: ${error}`); + throw new Error(`Failed to create remote: ${error}`); + } + } + + @ResolveField(() => Boolean, { description: 'Delete an existing RClone remote' }) + @UsePermissions({ + action: AuthActionVerb.DELETE, + resource: Resource.FLASH, + possession: AuthPossession.ANY, + }) + async deleteRCloneRemote(@Args('input') input: DeleteRCloneRemoteInput): Promise { + try { + await this.rcloneApiService.deleteRemote(input); + return true; + } catch (error) { + this.logger.error(`Error deleting remote: ${error}`); + throw new Error(`Failed to delete remote: ${error}`); + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts new file mode 100644 index 000000000..ac84a6dad --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts @@ -0,0 +1,64 @@ +import { Logger } from '@nestjs/common'; +import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; + +import { + AuthActionVerb, + AuthPossession, + UsePermissions, +} from '@app/unraid-api/graph/directives/use-permissions.directive.js'; +import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js'; +import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; +import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js'; +import { + RCloneBackupConfigForm, + RCloneBackupSettings, + RCloneConfigFormInput, + RCloneRemote, +} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; +import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js'; +import { DataSlice } from '@app/unraid-api/types/json-forms.js'; + +@Resolver(() => RCloneBackupSettings) +export class RCloneBackupSettingsResolver { + private readonly logger = new Logger(RCloneBackupSettingsResolver.name); + + constructor( + private readonly rcloneService: RCloneService, + private readonly rcloneApiService: RCloneApiService, + private readonly rcloneFormService: RCloneFormService + ) {} + + @Query(() => RCloneBackupSettings) + @UsePermissions({ + action: AuthActionVerb.READ, + resource: Resource.FLASH, + possession: AuthPossession.ANY, + }) + async rclone(): Promise { + return {} as RCloneBackupSettings; + } + + @ResolveField(() => RCloneBackupConfigForm) + async configForm( + @Parent() _parent: RCloneBackupSettings, + @Args('formOptions', { type: () => RCloneConfigFormInput, nullable: true }) + formOptions?: RCloneConfigFormInput + ): Promise { + const form = await this.rcloneFormService.getFormSchemas(formOptions ?? {}); + return { + id: 'rcloneBackupConfigForm', + dataSchema: form.dataSchema as { properties: DataSlice; type: 'object' }, + uiSchema: form.uiSchema, + }; + } + + @ResolveField(() => [RCloneRemote]) + async remotes(@Parent() _parent: RCloneBackupSettings): Promise { + try { + return await this.rcloneService.getRemoteDetails(); + } catch (error) { + this.logger.error(`Error listing remotes: ${error}`); + return []; + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts new file mode 100644 index 000000000..3bb4b95ea --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { type Layout } from '@jsonforms/core'; + +import type { SettingSlice } from '@app/unraid-api/types/json-forms.js'; +import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; +import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js'; +import { RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; + +/** + * Types for rclone backup configuration UI + */ +export interface RcloneBackupConfigValues { + configStep: number; + showAdvanced: boolean; + name?: string; + type?: string; + parameters?: Record; +} + +@Injectable() +export class RCloneService { + private readonly logger = new Logger(RCloneService.name); + private _providerTypes: string[] = []; + private _providerOptions: Record = {}; + + constructor( + private readonly rcloneApiService: RCloneApiService, + private readonly rcloneFormService: RCloneFormService + ) {} + + /** + * Get provider types + */ + get providerTypes(): string[] { + return this._providerTypes; + } + + /** + * Get provider options + */ + get providerOptions(): Record { + return this._providerOptions; + } + + /** + * Initializes the service by loading provider information + */ + async onModuleInit(): Promise { + try { + await this.loadProviderInfo(); + } catch (error) { + this.logger.error(`Failed to initialize RcloneBackupSettingsService: ${error}`); + } + } + + /** + * Loads RClone provider types and options + */ + private async loadProviderInfo(): Promise { + try { + const providersResponse = await this.rcloneApiService.getProviders(); + if (providersResponse) { + // Extract provider types + this._providerTypes = providersResponse.map((provider) => provider.Name); + this._providerOptions = providersResponse; + this.logger.debug(`Loaded ${this._providerTypes.length} provider types`); + } + } catch (error) { + this.logger.error(`Error loading provider information: ${error}`); + throw error; + } + } + + /** + * Gets current configuration values + */ + async getCurrentSettings(): Promise { + return { + configStep: 0, + showAdvanced: false, + }; + } + + /** + * Gets a list of configured remotes + */ + async getConfiguredRemotes(): Promise { + return this.rcloneApiService.listRemotes(); + } + + /** + * Gets detailed information about all configured remotes + */ + async getRemoteDetails(): Promise { + try { + const remoteNames = await this.rcloneApiService.listRemotes(); + const remoteDetails: RCloneRemote[] = []; + + for (const name of remoteNames) { + try { + const config = await this.rcloneApiService.getRemoteDetails({ name }); + const { type, ...parameters } = config; + + remoteDetails.push({ + name, + type: type || '', + parameters, + config, + }); + } catch (error) { + this.logger.error(`Error getting details for remote ${name}: ${error}`); + } + } + + return remoteDetails; + } catch (error) { + this.logger.error(`Error listing remotes: ${error}`); + return []; + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 464fb7eee..04bd2dc60 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -4,9 +4,6 @@ import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; import { ApiKeyModule } from '@app/unraid-api/graph/resolvers/api-key/api-key.module.js'; import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js'; import { ArrayModule } from '@app/unraid-api/graph/resolvers/array/array.module.js'; -import { ArrayMutationsResolver } from '@app/unraid-api/graph/resolvers/array/array.mutations.resolver.js'; -import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver.js'; -import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; import { CloudResolver } from '@app/unraid-api/graph/resolvers/cloud/cloud.resolver.js'; import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.resolver.js'; import { ConnectModule } from '@app/unraid-api/graph/resolvers/connect/connect.module.js'; @@ -14,6 +11,7 @@ import { CustomizationModule } from '@app/unraid-api/graph/resolvers/customizati import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.module.js'; import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js'; import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js'; +import { FlashBackupModule } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.module.js'; import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js'; import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js'; import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js'; @@ -24,6 +22,7 @@ import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notificat import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js'; import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js'; +import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js'; import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js'; @@ -35,7 +34,17 @@ import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js' import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; @Module({ - imports: [ArrayModule, ApiKeyModule, ConnectModule, CustomizationModule, DockerModule, DisksModule], + imports: [ + ArrayModule, + ApiKeyModule, + AuthModule, + ConnectModule, + CustomizationModule, + DockerModule, + DisksModule, + FlashBackupModule, + RCloneModule, + ], providers: [ CloudResolver, ConfigResolver, diff --git a/api/src/unraid-api/graph/utils/form-utils.ts b/api/src/unraid-api/graph/utils/form-utils.ts new file mode 100644 index 000000000..97a100475 --- /dev/null +++ b/api/src/unraid-api/graph/utils/form-utils.ts @@ -0,0 +1,46 @@ +import type { ControlElement, LabelElement, Layout, Rule } from '@jsonforms/core'; + +/** + * Creates a Layout (typically UnraidSettingsLayout) containing a Label and a Control element. + */ +export function createLabeledControl({ + scope, + label, + description, + controlOptions, + labelOptions, + layoutOptions, + rule, +}: { + scope: string; + label: string; + description?: string; + controlOptions: ControlElement['options']; + labelOptions?: LabelElement['options']; + layoutOptions?: Layout['options']; + rule?: Rule; +}): Layout { + const layout: Layout & { scope?: string } = { + type: 'UnraidSettingsLayout', // Use the specific Unraid layout type + scope: scope, // Apply scope to the layout for potential rules/visibility + options: layoutOptions, + elements: [ + { + type: 'Label', + text: label, + scope: scope, // Scope might be needed for specific label behaviors + options: { ...labelOptions, description }, + } as LabelElement, + { + type: 'Control', + scope: scope, + options: controlOptions, + } as ControlElement, + ], + }; + // Conditionally add the rule to the layout if provided + if (rule) { + layout.rule = rule; + } + return layout; +} diff --git a/api/src/unraid-api/rest/rest.controller.ts b/api/src/unraid-api/rest/rest.controller.ts index c2d87b1fc..6f213cefe 100644 --- a/api/src/unraid-api/rest/rest.controller.ts +++ b/api/src/unraid-api/rest/rest.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Logger, Param, Res } from '@nestjs/common'; +import { All, Controller, Get, Logger, Param, Req, Res } from '@nestjs/common'; +import got from 'got'; import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz'; -import type { FastifyReply } from '@app/unraid-api/types/fastify.js'; +import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js'; import { RestService } from '@app/unraid-api/rest/rest.service.js'; diff --git a/api/src/unraid-api/rest/rest.module.ts b/api/src/unraid-api/rest/rest.module.ts index 8f3b3292e..3ce7f4907 100644 --- a/api/src/unraid-api/rest/rest.module.ts +++ b/api/src/unraid-api/rest/rest.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; import { RestController } from '@app/unraid-api/rest/rest.controller.js'; import { RestService } from '@app/unraid-api/rest/rest.service.js'; @Module({ - imports: [], + imports: [RCloneModule], controllers: [RestController], providers: [RestService], }) diff --git a/api/src/unraid-api/types/json-forms.test.ts b/api/src/unraid-api/types/json-forms.test.ts new file mode 100644 index 000000000..c8c01c633 --- /dev/null +++ b/api/src/unraid-api/types/json-forms.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js'; +import { createEmptySettingSlice, mergeSettingSlices } from '@app/unraid-api/types/json-forms.js'; + +describe('mergeSettingSlices', () => { + it('should return an empty slice when merging an empty array', () => { + const slices: SettingSlice[] = []; + const expected = createEmptySettingSlice(); + expect(mergeSettingSlices(slices)).toEqual(expected); + }); + + it('should return the same slice when merging a single slice', () => { + const slice: SettingSlice = { + properties: { prop1: { type: 'string' } }, + elements: [{ type: 'Control', scope: '#/properties/prop1' }], + }; + expect(mergeSettingSlices([slice])).toEqual(slice); + }); + + it('should merge properties deeply and concatenate elements for multiple slices', () => { + const slice1: SettingSlice = { + properties: { + prop1: { type: 'string' }, + nested: { type: 'object', properties: { nestedProp1: { type: 'boolean' } } }, + }, + elements: [{ type: 'Control', scope: '#/properties/prop1' } as UIElement], + }; + const slice2: SettingSlice = { + properties: { + prop2: { type: 'number' }, + nested: { type: 'object', properties: { nestedProp2: { type: 'string' } } }, // Overlapping nested property + }, + elements: [ + { type: 'Control', scope: '#/properties/prop2' } as UIElement, + { type: 'Label', text: 'Nested' } as UIElement, + ], + }; + + const expectedProperties: DataSlice = { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + nested: { + type: 'object', + properties: { + nestedProp1: { type: 'boolean' }, + nestedProp2: { type: 'string' }, + }, + }, + }; + const expectedElements: UIElement[] = [ + { type: 'Control', scope: '#/properties/prop1' }, + { type: 'Control', scope: '#/properties/prop2' }, + { type: 'Label', text: 'Nested' }, + ]; + + const mergedSlice = mergeSettingSlices([slice1, slice2]); + + expect(mergedSlice.properties).toEqual(expectedProperties); + expect(mergedSlice.elements).toEqual(expectedElements); + }); + + it('should handle slices with only properties or only elements', () => { + const slice1: SettingSlice = { + properties: { prop1: { type: 'string' } }, + elements: [], + }; + const slice2: SettingSlice = { + properties: {}, + elements: [{ type: 'Control', scope: '#/properties/prop1' } as UIElement], + }; + const slice3: SettingSlice = { + properties: { prop2: { type: 'number' } }, + elements: [{ type: 'Label', text: 'Label' } as UIElement], + }; + + const expectedProperties: DataSlice = { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }; + const expectedElements: UIElement[] = [ + { type: 'Control', scope: '#/properties/prop1' }, + { type: 'Label', text: 'Label' }, + ]; + + const mergedSlice = mergeSettingSlices([slice1, slice2, slice3]); + + expect(mergedSlice.properties).toEqual(expectedProperties); + expect(mergedSlice.elements).toEqual(expectedElements); + }); +}); diff --git a/api/src/unraid-api/types/json-forms.ts b/api/src/unraid-api/types/json-forms.ts index b7eeb91e6..58d257f46 100644 --- a/api/src/unraid-api/types/json-forms.ts +++ b/api/src/unraid-api/types/json-forms.ts @@ -9,6 +9,7 @@ import type { SchemaBasedCondition, UISchemaElement, } from '@jsonforms/core'; +import { merge } from 'lodash-es'; /** * JSON schema properties. @@ -18,7 +19,9 @@ export type DataSlice = Record; /** * A JSONForms UI schema element. */ -export type UIElement = UISchemaElement | LabelElement | Layout | ControlElement | Categorization; +export type UIElement = (UISchemaElement | LabelElement | Layout | ControlElement | Categorization) & { + elements?: UIElement[]; +}; /** * A condition for a JSONForms rule. @@ -52,7 +55,9 @@ export function createEmptySettingSlice(): SettingSlice { function reduceSlices(slices: SettingSlice[]): SettingSlice { const result = createEmptySettingSlice(); for (const slice of slices) { - Object.assign(result.properties, slice.properties); + // Deep merge properties using lodash.merge + merge(result.properties, slice.properties); + // Append elements result.elements.push(...slice.elements); } return result; diff --git a/plugin/builder/build-txz.ts b/plugin/builder/build-txz.ts index 798de5769..829a62bb0 100644 --- a/plugin/builder/build-txz.ts +++ b/plugin/builder/build-txz.ts @@ -10,6 +10,7 @@ import { cleanupTxzFiles } from "./utils/cleanup"; import { apiDir } from "./utils/paths"; import { getVendorBundleName, getVendorFullPath } from "./build-vendor-store"; import { getAssetUrl } from "./utils/bucket-urls"; +import { ensureRclone } from "./utils/rclone-helper"; // Recursively search for manifest files @@ -173,7 +174,10 @@ const buildTxz = async (validatedEnv: TxzEnv) => { console.log(`Storing vendor archive information: ${vendorUrl} -> ${vendorFilename}`); await storeVendorArchiveInfo(version, vendorUrl, vendorFilename); - await ensureNodeJs(); + await Promise.all([ + ensureNodeJs(), + ensureRclone() + ]); // Create package - must be run from within the pre-pack directory // Use cd option to run command from prePackDir diff --git a/plugin/builder/utils/rclone-helper.ts b/plugin/builder/utils/rclone-helper.ts new file mode 100644 index 000000000..99aa9a538 --- /dev/null +++ b/plugin/builder/utils/rclone-helper.ts @@ -0,0 +1,64 @@ +import { join } from "path"; +import { existsSync, mkdirSync, createWriteStream, readFileSync } from "fs"; +import { writeFile, readFile, unlink } from "fs/promises"; +import { get } from "https"; +import { $ } from "zx"; +import { startingDir } from "./consts"; + +const RCLONE_VERSION_PATHS = [ + join(startingDir, "..", ".rclone-version"), + join(startingDir, ".rclone-version"), +]; + +const findRcloneVersion = () => { + for (const path of RCLONE_VERSION_PATHS) { + if (existsSync(path)) { + return readFileSync(path, "utf8").trim(); + } + } + throw new Error(".rclone-version file not found"); +}; + +const RCLONE_VERSION = findRcloneVersion(); +const RCLONE_FILENAME = `rclone-v${RCLONE_VERSION}-linux-amd64.zip`; +const RCLONE_URL = `https://downloads.rclone.org/v${RCLONE_VERSION}/${RCLONE_FILENAME}`; +const RCLONE_DEST = join(startingDir, "source", "dynamix.unraid.net", "usr", "local", "rclone"); +const RCLONE_VERSION_FILE = join(RCLONE_DEST, ".rclone-version"); +const RCLONE_BIN = join(RCLONE_DEST, "rclone"); + +async function fetchFile(url: string, dest: string) { + return new Promise((resolve, reject) => { + const file = createWriteStream(dest); + get(url, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`Failed to get '${url}' (${response.statusCode})`)); + return; + } + response.pipe(file); + file.on("finish", () => file.close(resolve)); + file.on("error", reject); + }).on("error", reject); + }); +} + +export async function ensureRclone() { + let currentVersion: string | null = null; + if (existsSync(RCLONE_VERSION_FILE)) { + currentVersion = (await readFile(RCLONE_VERSION_FILE, "utf8")).trim(); + } + if (currentVersion !== RCLONE_VERSION) { + mkdirSync(RCLONE_DEST, { recursive: true }); + if (!existsSync(RCLONE_FILENAME)) { + await fetchFile(RCLONE_URL, RCLONE_FILENAME); + } + await $`unzip -oj ${RCLONE_FILENAME} rclone-v${RCLONE_VERSION}-linux-amd64/rclone -d ${RCLONE_DEST}`; + await $`chmod +x ${RCLONE_BIN}`; + await writeFile(RCLONE_VERSION_FILE, RCLONE_VERSION, "utf8"); + // Clean up old rclone archives + const glob = await import("glob"); + const files = glob.sync("rclone-v*-linux-amd64.zip", { cwd: startingDir }); + for (const file of files) { + if (file !== RCLONE_FILENAME) await unlink(join(startingDir, file)); + } + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae157a0ae..ad339955e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,12 +204,6 @@ importers: graphql-tag: specifier: ^2.12.6 version: 2.12.6(graphql@16.10.0) - graphql-type-json: - specifier: ^0.3.2 - version: 0.3.2(graphql@16.10.0) - graphql-type-uuid: - specifier: ^0.2.0 - version: 0.2.0(graphql@16.10.0) graphql-ws: specifier: ^6.0.0 version: 6.0.4(graphql@16.10.0)(ws@8.18.1) @@ -687,12 +681,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dompurify: + specifier: ^3.2.5 + version: 3.2.5 kebab-case: specifier: ^2.0.1 version: 2.0.1 lucide-vue-next: specifier: ^0.511.0 version: 0.511.0(vue@3.5.13(typescript@5.8.3)) + marked: + specifier: ^15.0.0 + version: 15.0.7 reka-ui: specifier: ^2.1.1 version: 2.1.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)) @@ -710,20 +710,23 @@ importers: specifier: ^4.4.1 version: 4.4.1(@vue/compiler-sfc@3.5.13)(prettier@3.5.3) '@storybook/addon-essentials': - specifier: ^8.5.8 - version: 8.6.9(@types/react@19.0.8)(storybook@8.6.9(prettier@3.5.3)) + specifier: ^8.6.12 + version: 8.6.12(@types/react@19.0.8)(storybook@8.6.12(prettier@3.5.3)) '@storybook/addon-interactions': - specifier: ^8.5.8 - version: 8.6.9(storybook@8.6.9(prettier@3.5.3)) + specifier: ^8.6.12 + version: 8.6.12(storybook@8.6.12(prettier@3.5.3)) '@storybook/addon-links': - specifier: ^8.5.8 - version: 8.6.9(react@19.0.0)(storybook@8.6.9(prettier@3.5.3)) + specifier: ^8.6.12 + version: 8.6.12(react@19.0.0)(storybook@8.6.12(prettier@3.5.3)) '@storybook/builder-vite': - specifier: ^8.5.8 - version: 8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)) + specifier: ^8.6.12 + version: 8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)) + '@storybook/vue3': + specifier: ^8.6.12 + version: 8.6.12(storybook@8.6.12(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3)) '@storybook/vue3-vite': - specifier: ^8.5.8 - version: 8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3)) + specifier: ^8.6.12 + version: 8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3)) '@tailwindcss/typography': specifier: ^0.5.15 version: 0.5.16(tailwindcss@3.4.17) @@ -766,6 +769,9 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.3) + concurrently: + specifier: ^9.1.2 + version: 9.1.2 eslint: specifier: ^9.17.0 version: 9.23.0(jiti@2.4.2) @@ -790,6 +796,9 @@ importers: postcss: specifier: ^8.4.49 version: 8.5.3 + postcss-import: + specifier: ^16.1.0 + version: 16.1.0(postcss@8.5.3) prettier: specifier: 3.5.3 version: 3.5.3 @@ -800,8 +809,8 @@ importers: specifier: ^6.0.1 version: 6.0.1 storybook: - specifier: ^8.5.8 - version: 8.6.9(prettier@3.5.3) + specifier: ^8.6.12 + version: 8.6.12(prettier@3.5.3) tailwind-rem-to-rem: specifier: github:unraid/tailwind-rem-to-rem version: '@unraid/tailwind-rem-to-rem@https://codeload.github.com/unraid/tailwind-rem-to-rem/tar.gz/4b907d0cdb3abda88de9813e33c13c3e7b1300c4(tailwindcss@3.4.17)' @@ -3596,105 +3605,105 @@ packages: '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} - '@storybook/addon-actions@8.6.9': - resolution: {integrity: sha512-H2v17sMbSl8jhSulPxcOyChsFbzik9E7mgCWIf4P114KcIUokWLVuALnSOeqHME6lY0pPBZs3DgvVVMVMm7zNw==} + '@storybook/addon-actions@8.6.12': + resolution: {integrity: sha512-B5kfiRvi35oJ0NIo53CGH66H471A3XTzrfaa6SxXEJsgxxSeKScG5YeXcCvLiZfvANRQ7QDsmzPUgg0o3hdMXw==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-backgrounds@8.6.9': - resolution: {integrity: sha512-DiNpKJq4sEqTCGwwGs8fwi1hxBniCQMxsJFfrYlIx0HTyfA7AMROqP9fyv1aCV1JWDiwlL+cwCurkoyhpuZioQ==} + '@storybook/addon-backgrounds@8.6.12': + resolution: {integrity: sha512-lmIAma9BiiCTbJ8YfdZkXjpnAIrOUcgboLkt1f6XJ78vNEMnLNzD9gnh7Tssz1qrqvm34v9daDjIb+ggdiKp3Q==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-controls@8.6.9': - resolution: {integrity: sha512-YXBYsbHqdYhmrbGI+wv9LAr/LlKnPt9f9GL+9rw82lnYadWObYxzUxs+PPLNO5tc14fd2g+FMVHOfovaRdFvrQ==} + '@storybook/addon-controls@8.6.12': + resolution: {integrity: sha512-9VSRPJWQVb9wLp21uvpxDGNctYptyUX0gbvxIWOHMH3R2DslSoq41lsC/oQ4l4zSHVdL+nq8sCTkhBxIsjKqdQ==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-docs@8.6.9': - resolution: {integrity: sha512-yAP59G5Vd+E6O9KLfBR5ALdOFA5yEZ0n1f8Ne9jwF+NGu1U8KNIfWnZmBYaBGe+bpYn0CWV5AfdFvw83bzHYpw==} + '@storybook/addon-docs@8.6.12': + resolution: {integrity: sha512-kEezQjAf/p3SpDzLABgg4fbT48B6dkT2LiZCKTRmCrJVtuReaAr4R9MMM6Jsph6XjbIj/SvOWf3CMeOPXOs9sg==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-essentials@8.6.9': - resolution: {integrity: sha512-n3DSSIjDsVDw7uOatP2remC5SVSIfjwHcLGor85xLd1SQUh98wednM1Iby19qc/QR69UuOL0nB/d5yG1ifh0sA==} + '@storybook/addon-essentials@8.6.12': + resolution: {integrity: sha512-Y/7e8KFlttaNfv7q2zoHMPdX6hPXHdsuQMAjYl5NG9HOAJREu4XBy4KZpbcozRe4ApZ78rYsN/MO1EuA+bNMIA==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-highlight@8.6.9': - resolution: {integrity: sha512-I0gBHgaH74wX6yf5S7zUmdfr25hwPONpSAqPPGBSNYu0Jj9Je+ANr1y4T1I3cOaEvf73QntDhCgHC6/iqY90Fw==} + '@storybook/addon-highlight@8.6.12': + resolution: {integrity: sha512-9FITVxdoycZ+eXuAZL9ElWyML/0fPPn9UgnnAkrU7zkMi+Segq/Tx7y+WWanC5zfWZrXAuG6WTOYEXeWQdm//w==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-interactions@8.6.9': - resolution: {integrity: sha512-KpSVjcDD+5vmGA78MM2blsfy8J/PfuIMb74nJufgjci2xlzUxB8dGEFJACZPfqM5kUuUv/AhHHsAzP1r/wr83Q==} + '@storybook/addon-interactions@8.6.12': + resolution: {integrity: sha512-cTAJlTq6uVZBEbtwdXkXoPQ4jHOAGKQnYSezBT4pfNkdjn/FnEeaQhMBDzf14h2wr5OgBnJa6Lmd8LD9ficz4A==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-links@8.6.9': - resolution: {integrity: sha512-cYYlsaMHvEJzGqJ3BO5BpXaa00AxYtEKjJEFP7q/LDZBxMnrChzDygFUTAAbUTHF4U3mNCrl1KuyoUL3nMQquA==} + '@storybook/addon-links@8.6.12': + resolution: {integrity: sha512-AfKujFHoAxhxq4yu+6NwylltS9lf5MPs1eLLXvOlwo3l7Y/c68OdxJ7j68vLQhs9H173WVYjKyjbjFxJWf/YYg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.9 + storybook: ^8.6.12 peerDependenciesMeta: react: optional: true - '@storybook/addon-measure@8.6.9': - resolution: {integrity: sha512-2GrHtaYZgM7qeil5/XfNJrdnan7hoLLUyU7w7fph0EVl7tiwmhtp4He0PX9hrT/Abk2HxeCP4WU2fAGwIuTkYg==} + '@storybook/addon-measure@8.6.12': + resolution: {integrity: sha512-tACmwqqOvutaQSduw8SMb62wICaT1rWaHtMN3vtWXuxgDPSdJQxLP+wdVyRYMAgpxhLyIO7YRf++Hfha9RHgFg==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-outline@8.6.9': - resolution: {integrity: sha512-YXfiSmjdpXGNYns9NZfdiEbwRfOW/Naym0dIH7s1LAlZZPJvtEYe2hNUOjBfAEm8ZhC1fA1+pZFnspOQHPENlA==} + '@storybook/addon-outline@8.6.12': + resolution: {integrity: sha512-1ylwm+n1s40S91No0v9T4tCjZORu3GbnjINlyjYTDLLhQHyBQd3nWR1Y1eewU4xH4cW9SnSLcMQFS/82xHqU6A==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-toolbars@8.6.9': - resolution: {integrity: sha512-WOO3CHyzqEql9xnNzi7BUkPRPGHGMCtAR+szGeWqmuj3GZLqXwDOb8HDa3aVMIhVEKhk5jN2zGQmxH53vReBNQ==} + '@storybook/addon-toolbars@8.6.12': + resolution: {integrity: sha512-HEcSzo1DyFtIu5/ikVOmh5h85C1IvK9iFKSzBR6ice33zBOaehVJK+Z5f487MOXxPsZ63uvWUytwPyViGInj+g==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/addon-viewport@8.6.9': - resolution: {integrity: sha512-1xkozyB1zs3eSNTc8ePAMcajUfbKvNMTjs5LYdts2N1Ss0xeZ+K/gphfRg0GaYsNvRYi5piufag/niHCGkT3hA==} + '@storybook/addon-viewport@8.6.12': + resolution: {integrity: sha512-EXK2LArAnABsPP0leJKy78L/lbMWow+EIJfytEP5fHaW4EhMR6h7Hzaqzre6U0IMMr/jVFa1ci+m0PJ0eQc2bw==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/blocks@8.6.9': - resolution: {integrity: sha512-+vSRkHLD7ho3Wd1WVA1KrYAnv7BnGHOhHWHAgTR5IdeMdgzQxm6+HHeqGB5sncilA0AjVC6udBIgHbCSuD61dA==} + '@storybook/blocks@8.6.12': + resolution: {integrity: sha512-DohlTq6HM1jDbHYiXL4ZvZ00VkhpUp5uftzj/CZDLY1fYHRjqtaTwWm2/OpceivMA8zDitLcq5atEZN+f+siTg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^8.6.9 + storybook: ^8.6.12 peerDependenciesMeta: react: optional: true react-dom: optional: true - '@storybook/builder-vite@8.6.9': - resolution: {integrity: sha512-8U11A7sLPvvcnJQ3pXyoX1LdJDpa4+JOYcASL9A+DL591jkfYKxhim7R4BOHO55aetmqQAoA/LEAD5runu7zoQ==} + '@storybook/builder-vite@8.6.12': + resolution: {integrity: sha512-Gju21ud/3Qw4v2vLNaa5SuJECsI9ICNRr2G0UyCCzRvCHg8jpA9lDReu2NqhLDyFIuDG+ZYT38gcaHEUoNQ8KQ==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 vite: ^4.0.0 || ^5.0.0 || ^6.0.0 - '@storybook/components@8.6.9': - resolution: {integrity: sha512-CqWUAYK/RgV++sXfiDG63DM2JF2FeidvnMO5/bki2hFbEqgs0/yy7BKUjhsGmuri5y+r9B2FJhW0WnE6PI8NWw==} + '@storybook/components@8.6.12': + resolution: {integrity: sha512-FiaE8xvCdvKC2arYusgtlDNZ77b8ysr8njAYQZwwaIHjy27TbR2tEpLDCmUwSbANNmivtc/xGEiDDwcNppMWlQ==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@storybook/core@8.6.9': - resolution: {integrity: sha512-psYxJAlj34ZaDAk+OvT/He6ZuUh0eGiHVtZNe0xWbNp5pQvOBjf+dg48swdI6KEbVs3aeU+Wnyra/ViU2RtA+Q==} + '@storybook/core@8.6.12': + resolution: {integrity: sha512-t+ZuDzAlsXKa6tLxNZT81gEAt4GNwsKP/Id2wluhmUWD/lwYW0uum1JiPUuanw8xD6TdakCW/7ULZc7aQUBLCQ==} peerDependencies: prettier: ^2 || ^3 peerDependenciesMeta: prettier: optional: true - '@storybook/csf-plugin@8.6.9': - resolution: {integrity: sha512-IQnhyaVUkcRR9e4xiHN83xMQtTMH+lJp472iMifUIqxx/Yw137BTef2DEEp6EnRct4yKrch24+Nl65LWg0mRpQ==} + '@storybook/csf-plugin@8.6.12': + resolution: {integrity: sha512-6s8CnP1aoKPb3XtC0jRLUp8M5vTA8RhGAwQDKUsFpCC7g89JR9CaKs9FY2ZSzsNbjR15uASi7b3K8BzeYumYQg==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -3706,50 +3715,50 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - '@storybook/instrumenter@8.6.9': - resolution: {integrity: sha512-Gp6OSiu9KA/p1HWd7VW9TtpWX32ZBfqRVrOm4wW1AM6B4XACbQWFE/aQ25HwU834yfdJkr2BW+uUH8DBAQ6kTw==} + '@storybook/instrumenter@8.6.12': + resolution: {integrity: sha512-VK5fYAF8jMwWP/u3YsmSwKGh+FeSY8WZn78flzRUwirp2Eg1WWjsqPRubAk7yTpcqcC/km9YMF3KbqfzRv2s/A==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/manager-api@8.6.9': - resolution: {integrity: sha512-mxq9B9rxAraOCBapGKsUDfI+8yNtFhTgKMZCxmHoUCxvAHaIt4S9JcdX0qQQKUsBTr/b2hHm0O7A8DYrbgBRfw==} + '@storybook/manager-api@8.6.12': + resolution: {integrity: sha512-O0SpISeJLNTQvhSBOsWzzkCgs8vCjOq1578rwqHlC6jWWm4QmtfdyXqnv7rR1Hk08kQ+Dzqh0uhwHx0nfwy4nQ==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@storybook/preview-api@8.6.9': - resolution: {integrity: sha512-hW3Z8NBrGs2bNunaHgrLjpfrOcWsxH0ejAqaba8MolPXjzNs0lTFF/Ela7pUsh2m1R4/kiD+WfddQzyipUo4Mg==} + '@storybook/preview-api@8.6.12': + resolution: {integrity: sha512-84FE3Hrs0AYKHqpDZOwx1S/ffOfxBdL65lhCoeI8GoWwCkzwa9zEP3kvXBo/BnEDO7nAfxvMhjASTZXbKRJh5Q==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@storybook/react-dom-shim@8.6.9': - resolution: {integrity: sha512-SjqP6r5yy87OJRAiq1JzFazn6VWfptOA2HaxOiP8zRhJgG41K0Vseh8tbZdycj1AzJYSCcnKaIcfd/GEo/41+g==} + '@storybook/react-dom-shim@8.6.12': + resolution: {integrity: sha512-51QvoimkBzYs8s3rCYnY5h0cFqLz/Mh0vRcughwYaXckWzDBV8l67WBO5Xf5nBsukCbWyqBVPpEQLww8s7mrLA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/test@8.6.9': - resolution: {integrity: sha512-lIJA6jup3ZZNkKFyUiy1q2tHWZv5q5bTaLxTnI85XIWr+sFCZG5oo3pOQESBkX4V95rv8sq9gEmEWySZvW7MBw==} + '@storybook/test@8.6.12': + resolution: {integrity: sha512-0BK1Eg+VD0lNMB1BtxqHE3tP9FdkUmohtvWG7cq6lWvMrbCmAmh3VWai3RMCCDOukPFpjabOr8BBRLVvhNpv2w==} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 - '@storybook/theming@8.6.9': - resolution: {integrity: sha512-FQafe66itGnIh0V42R65tgFKyz0RshpIs0pTrxrdByuB2yKsep+f8ZgKLJE3fCKw/Egw4bUuICo2m8d7uOOumA==} + '@storybook/theming@8.6.12': + resolution: {integrity: sha512-6VjZg8HJ2Op7+KV7ihJpYrDnFtd9D1jrQnUS8LckcpuBXrIEbaut5+34ObY8ssQnSqkk2GwIZBBBQYQBCVvkOw==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@storybook/vue3-vite@8.6.9': - resolution: {integrity: sha512-TTPzFwK7Yb1diC0o4uUrdmoDYVHRqRlH/xD8sZzud+N7MK+OJ7HPyCN8bw3e6HdsqLdJmfos45hRVTBgTYLOYA==} + '@storybook/vue3-vite@8.6.12': + resolution: {integrity: sha512-ihYH2TiV14B8V1mrCVVrbjuf+F6+V/78oWofVkvnUQnpwH4CnAySGf6bz6c6/Y6qEr9r30ECUe6/sS0TMt1ZAQ==} engines: {node: '>=18.0.0'} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 vite: ^4.0.0 || ^5.0.0 || ^6.0.0 - '@storybook/vue3@8.6.9': - resolution: {integrity: sha512-asGlWyITyMGytNO+yXrWKcU+Ygk9G9zlmb0mVoTFxZGLiq/Wk3OmHmOlf5g0LyU8bkps43ZdkovEXfvMwQVm6A==} + '@storybook/vue3@8.6.12': + resolution: {integrity: sha512-mgGRMrFghDW5nHCDbdbhC4YUrOs7mCzwEuLZtdcvpB8TUPP62lTSnv3Gvcz8r12HjyIK6Jow9WgjTtdownGzkA==} engines: {node: '>=18.0.0'} peerDependencies: - storybook: ^8.6.9 + storybook: ^8.6.12 vue: ^3.0.0 '@stylistic/eslint-plugin@4.2.0': @@ -5592,6 +5601,11 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + concurrently@9.1.2: + resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==} + engines: {node: '>=18'} + hasBin: true + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -6292,6 +6306,9 @@ packages: dompurify@3.2.4: resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==} + dompurify@3.2.5: + resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -7476,16 +7493,6 @@ packages: peerDependencies: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql-type-json@0.3.2: - resolution: {integrity: sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==} - peerDependencies: - graphql: '>=0.8.0' - - graphql-type-uuid@0.2.0: - resolution: {integrity: sha512-AHTJn95nj5y/M4d2nqWYzWJNd/C/9XA7aYIdVxnySeDFXv/lq5ZlL9RtH/iNvD1pbEZIkZmnPHqJKPYQDelEUg==} - peerDependencies: - graphql: '>=0.8.0' - graphql-ws@6.0.4: resolution: {integrity: sha512-8b4OZtNOvv8+NZva8HXamrc0y1jluYC0+13gdh7198FKjVzXyTvVc95DCwGzaKEfn3YuWZxUqjJlHe3qKM/F2g==} engines: {node: '>=20'} @@ -9712,6 +9719,12 @@ packages: peerDependencies: postcss: ^8.0.0 + postcss-import@16.1.0: + resolution: {integrity: sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==} + engines: {node: '>=18.0.0'} + peerDependencies: + postcss: ^8.0.0 + postcss-js@4.0.1: resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} @@ -10082,7 +10095,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.13.0: @@ -10841,8 +10853,8 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - storybook@8.6.9: - resolution: {integrity: sha512-Iw4+R4V3yX7MhXJaLBAT4oLtZ+SaTzX8KvUNZiQzvdD+TrFKVA3QKV8gvWjstGyU2dd+afE1Ph6EG5Xa2Az2CA==} + storybook@8.6.12: + resolution: {integrity: sha512-Z/nWYEHBTLK1ZBtAWdhxC0l5zf7ioJ7G4+zYqtTdYeb67gTnxNj80gehf8o8QY9L2zA2+eyMRGLC2V5fI7Z3Tw==} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -11265,6 +11277,10 @@ packages: resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} engines: {node: '>=18'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + trim-newlines@3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} @@ -15448,125 +15464,125 @@ snapshots: '@speed-highlight/core@1.2.7': {} - '@storybook/addon-actions@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-actions@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 '@types/uuid': 9.0.8 dequal: 2.0.3 polished: 4.3.1 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) uuid: 9.0.1 - '@storybook/addon-backgrounds@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-backgrounds@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 memoizerific: 1.11.3 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 - '@storybook/addon-controls@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-controls@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 dequal: 2.0.3 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 - '@storybook/addon-docs@8.6.9(@types/react@19.0.8)(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-docs@8.6.12(@types/react@19.0.8)(storybook@8.6.12(prettier@3.5.3))': dependencies: '@mdx-js/react': 3.1.0(@types/react@19.0.8)(react@19.0.0) - '@storybook/blocks': 8.6.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.9(prettier@3.5.3)) - '@storybook/csf-plugin': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/react-dom-shim': 8.6.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.9(prettier@3.5.3)) + '@storybook/blocks': 8.6.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.12(prettier@3.5.3)) + '@storybook/csf-plugin': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/react-dom-shim': 8.6.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.12(prettier@3.5.3)) react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-essentials@8.6.9(@types/react@19.0.8)(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-essentials@8.6.12(@types/react@19.0.8)(storybook@8.6.12(prettier@3.5.3))': dependencies: - '@storybook/addon-actions': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/addon-backgrounds': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/addon-controls': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/addon-docs': 8.6.9(@types/react@19.0.8)(storybook@8.6.9(prettier@3.5.3)) - '@storybook/addon-highlight': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/addon-measure': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/addon-outline': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/addon-toolbars': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/addon-viewport': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - storybook: 8.6.9(prettier@3.5.3) + '@storybook/addon-actions': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/addon-backgrounds': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/addon-controls': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/addon-docs': 8.6.12(@types/react@19.0.8)(storybook@8.6.12(prettier@3.5.3)) + '@storybook/addon-highlight': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/addon-measure': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/addon-outline': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/addon-toolbars': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/addon-viewport': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-highlight@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-highlight@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/addon-interactions@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-interactions@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/test': 8.6.9(storybook@8.6.9(prettier@3.5.3)) + '@storybook/instrumenter': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/test': 8.6.12(storybook@8.6.12(prettier@3.5.3)) polished: 4.3.1 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 - '@storybook/addon-links@8.6.9(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-links@8.6.12(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 optionalDependencies: react: 19.0.0 - '@storybook/addon-measure@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-measure@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) tiny-invariant: 1.3.3 - '@storybook/addon-outline@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-outline@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 - '@storybook/addon-toolbars@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-toolbars@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/addon-viewport@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/addon-viewport@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: memoizerific: 1.11.3 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/blocks@8.6.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))': + '@storybook/blocks@8.6.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/icons': 1.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 optionalDependencies: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@storybook/builder-vite@8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))': + '@storybook/builder-vite@8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))': dependencies: - '@storybook/csf-plugin': 8.6.9(storybook@8.6.9(prettier@3.5.3)) + '@storybook/csf-plugin': 8.6.12(storybook@8.6.12(prettier@3.5.3)) browser-assert: 1.2.1 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 vite: 6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1) - '@storybook/components@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/components@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/core@8.6.9(prettier@3.5.3)(storybook@8.6.9(prettier@3.5.3))': + '@storybook/core@8.6.12(prettier@3.5.3)(storybook@8.6.12(prettier@3.5.3))': dependencies: - '@storybook/theming': 8.6.9(storybook@8.6.9(prettier@3.5.3)) + '@storybook/theming': 8.6.12(storybook@8.6.12(prettier@3.5.3)) better-opn: 3.0.2 browser-assert: 1.2.1 esbuild: 0.25.1 @@ -15585,9 +15601,9 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/csf-plugin@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) unplugin: 1.16.1 '@storybook/global@5.0.0': {} @@ -15597,48 +15613,48 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@storybook/instrumenter@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/instrumenter@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 '@vitest/utils': 2.1.9 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/manager-api@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/manager-api@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/preview-api@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/preview-api@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/react-dom-shim@8.6.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))': + '@storybook/react-dom-shim@8.6.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))': dependencies: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/test@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.6.9(storybook@8.6.9(prettier@3.5.3)) + '@storybook/instrumenter': 8.6.12(storybook@8.6.12(prettier@3.5.3)) '@testing-library/dom': 10.4.0 '@testing-library/jest-dom': 6.5.0 '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) '@vitest/expect': 2.0.5 '@vitest/spy': 2.0.5 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/theming@8.6.9(storybook@8.6.9(prettier@3.5.3))': + '@storybook/theming@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) - '@storybook/vue3-vite@8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3))': + '@storybook/vue3-vite@8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3))': dependencies: - '@storybook/builder-vite': 8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)) - '@storybook/vue3': 8.6.9(storybook@8.6.9(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3)) + '@storybook/builder-vite': 8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)) + '@storybook/vue3': 8.6.12(storybook@8.6.12(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3)) find-package-json: 1.2.0 magic-string: 0.30.17 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) typescript: 5.8.3 vite: 6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1) vue-component-meta: 2.2.8(typescript@5.8.3) @@ -15646,15 +15662,15 @@ snapshots: transitivePeerDependencies: - vue - '@storybook/vue3@8.6.9(storybook@8.6.9(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3))': + '@storybook/vue3@8.6.12(storybook@8.6.12(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3))': dependencies: - '@storybook/components': 8.6.9(storybook@8.6.9(prettier@3.5.3)) + '@storybook/components': 8.6.12(storybook@8.6.12(prettier@3.5.3)) '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/preview-api': 8.6.9(storybook@8.6.9(prettier@3.5.3)) - '@storybook/theming': 8.6.9(storybook@8.6.9(prettier@3.5.3)) + '@storybook/manager-api': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/preview-api': 8.6.12(storybook@8.6.12(prettier@3.5.3)) + '@storybook/theming': 8.6.12(storybook@8.6.12(prettier@3.5.3)) '@vue/compiler-core': 3.5.13 - storybook: 8.6.9(prettier@3.5.3) + storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.5.13(typescript@5.8.3) @@ -15662,7 +15678,7 @@ snapshots: '@stylistic/eslint-plugin@4.2.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@typescript-eslint/utils': 8.28.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.23.0(jiti@2.4.2) eslint-visitor-keys: 4.2.0 espree: 10.3.0 @@ -17780,6 +17796,16 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + concurrently@9.1.2: + dependencies: + chalk: 4.1.2 + lodash: 4.17.21 + rxjs: 7.8.2 + shell-quote: 1.8.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + confbox@0.1.8: {} confbox@0.2.1: {} @@ -18524,6 +18550,10 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.2.5: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -18982,7 +19012,7 @@ snapshots: eslint-plugin-import-x@4.8.1(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3): dependencies: '@types/doctrine': 0.0.9 - '@typescript-eslint/utils': 8.28.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3) debug: 4.4.0(supports-color@9.4.0) doctrine: 3.0.0 eslint: 9.23.0(jiti@2.4.2) @@ -20026,14 +20056,6 @@ snapshots: graphql: 16.10.0 tslib: 2.8.1 - graphql-type-json@0.3.2(graphql@16.10.0): - dependencies: - graphql: 16.10.0 - - graphql-type-uuid@0.2.0(graphql@16.10.0): - dependencies: - graphql: 16.10.0 - graphql-ws@6.0.4(graphql@16.10.0)(ws@8.18.1): dependencies: graphql: 16.10.0 @@ -22577,6 +22599,13 @@ snapshots: read-cache: 1.0.0 resolve: 1.22.10 + postcss-import@16.1.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.10 + postcss-js@4.0.1(postcss@8.5.3): dependencies: camelcase-css: 2.0.1 @@ -23847,9 +23876,9 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@8.6.9(prettier@3.5.3): + storybook@8.6.12(prettier@3.5.3): dependencies: - '@storybook/core': 8.6.9(prettier@3.5.3)(storybook@8.6.9(prettier@3.5.3)) + '@storybook/core': 8.6.12(prettier@3.5.3)(storybook@8.6.12(prettier@3.5.3)) optionalDependencies: prettier: 3.5.3 transitivePeerDependencies: @@ -24311,6 +24340,8 @@ snapshots: dependencies: punycode: 2.3.1 + tree-kill@1.2.2: {} + trim-newlines@3.0.1: {} ts-api-utils@2.1.0(typescript@5.8.3): @@ -25270,7 +25301,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.18 + which-typed-array: 1.1.19 which-collection@1.0.2: dependencies: diff --git a/unraid-ui/.gitignore b/unraid-ui/.gitignore index 0755904a6..b15d1ce8a 100644 --- a/unraid-ui/.gitignore +++ b/unraid-ui/.gitignore @@ -1,2 +1,3 @@ !.env.development -dist-wc/ \ No newline at end of file +dist-wc/ +.storybook/static/* \ No newline at end of file diff --git a/unraid-ui/.storybook/main.ts b/unraid-ui/.storybook/main.ts index e47fa6ebf..aace76651 100644 --- a/unraid-ui/.storybook/main.ts +++ b/unraid-ui/.storybook/main.ts @@ -1,30 +1,26 @@ -import { dirname, join } from "path"; -import type { StorybookConfig } from "@storybook/vue3-vite"; - +import { dirname, join } from 'path'; +import type { StorybookConfig } from '@storybook/vue3-vite'; const config: StorybookConfig = { - stories: ["../stories/**/*.stories.@(js|jsx|ts|tsx)"], - addons: [ - "@storybook/addon-links", - "@storybook/addon-essentials", - "@storybook/addon-interactions" - ], + stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], framework: { - name: "@storybook/vue3-vite", + name: '@storybook/vue3-vite', options: { - docgen: "vue-component-meta", + docgen: 'vue-component-meta', }, }, core: { - builder: "@storybook/builder-vite", + builder: '@storybook/builder-vite', }, docs: { - autodocs: "tag", + autodocs: 'tag', }, + staticDirs: ['./static'], async viteFinal(config) { - config.root = dirname(require.resolve('@storybook/builder-vite')); return { ...config, + root: dirname(require.resolve('@storybook/builder-vite')), resolve: { alias: { '@': join(dirname(new URL(import.meta.url).pathname), '../src'), @@ -32,6 +28,9 @@ const config: StorybookConfig = { '@/lib': join(dirname(new URL(import.meta.url).pathname), '../src/lib'), }, }, + optimizeDeps: { + include: [...(config.optimizeDeps?.include ?? []), '@unraid/tailwind-rem-to-rem'], + }, css: { postcss: { plugins: [ @@ -46,4 +45,4 @@ const config: StorybookConfig = { }, }; -export default config; \ No newline at end of file +export default config; diff --git a/unraid-ui/.storybook/preview.ts b/unraid-ui/.storybook/preview.ts index e8d22b379..727d4538e 100644 --- a/unraid-ui/.storybook/preview.ts +++ b/unraid-ui/.storybook/preview.ts @@ -1,8 +1,10 @@ import type { Preview } from '@storybook/vue3'; -import '../src/styles/globals.css'; import { registerAllComponents } from '../src/register'; +import '@/styles/index.css'; -registerAllComponents({}); +registerAllComponents({ + pathToSharedCss: '/index.css', +}); const preview: Preview = { parameters: { @@ -18,9 +20,11 @@ const preview: Preview = { decorators: [ (story) => ({ components: { story }, + template: `
-
+ +
`, @@ -28,4 +32,4 @@ const preview: Preview = { ], }; -export default preview; +export default preview; \ No newline at end of file diff --git a/unraid-ui/.storybook/tsconfig.json b/unraid-ui/.storybook/tsconfig.json deleted file mode 100644 index ed519017b..000000000 --- a/unraid-ui/.storybook/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "paths": { - "@/*": ["../src/*"] - } - }, - "include": ["../stories/**/*", "../src/**/*"] -} diff --git a/unraid-ui/eslint.config.ts b/unraid-ui/eslint.config.ts index fba74a205..bbf873f2e 100644 --- a/unraid-ui/eslint.config.ts +++ b/unraid-ui/eslint.config.ts @@ -6,89 +6,146 @@ import prettier from 'eslint-plugin-prettier'; import vuePlugin from 'eslint-plugin-vue'; import tseslint from 'typescript-eslint'; -export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, { - files: ['**/*.ts', '**/*.tsx', '**/*.vue'], - languageOptions: { - parser: require('vue-eslint-parser'), - parserOptions: { +// Common rules shared across file types +const commonRules = { + '@typescript-eslint/no-unused-vars': ['off'], + 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }], + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { allowSameFolder: false, rootDir: 'src', prefix: '@' }, + ], + 'prettier/prettier': 'error', + 'no-restricted-globals': [ + 'error', + { + name: '__dirname', + message: 'Use import.meta.url instead of __dirname in ESM', + }, + { + name: '__filename', + message: 'Use import.meta.url instead of __filename in ESM', + }, + ], + 'eol-last': ['error', 'always'], + '@typescript-eslint/no-explicit-any': [ + 'error', + { + ignoreRestArgs: true, + fixToUnknown: false, + }, + ], +}; + +// Vue-specific rules +const vueRules = { + 'vue/multi-word-component-names': 'off', + 'vue/html-self-closing': [ + 'error', + { + html: { + void: 'always', + normal: 'always', + component: 'always', + }, + }, + ], + 'vue/component-name-in-template-casing': ['error', 'PascalCase'], + 'vue/component-definition-name-casing': ['error', 'PascalCase'], + 'vue/no-unsupported-features': [ + 'error', + { + version: '^3.3.0', + }, + ], + 'vue/no-undef-components': ['error'], + 'vue/no-unused-properties': [ + 'error', + { + groups: ['props'], + deepData: false, + }, + ], +}; + +// Common language options +const commonLanguageOptions = { + ecmaVersion: 'latest', + sourceType: 'module', +}; + +// Define globals separately +const commonGlobals = { + browser: true, + window: true, + document: true, + console: true, + Event: true, + HTMLElement: true, + HTMLInputElement: true, + CustomEvent: true, + es2022: true, +}; + +export default [ + // Base config from recommended configs + eslint.configs.recommended, + ...tseslint.configs.recommended, + + // TypeScript Files (.ts) + { + files: ['**/*.ts'], + languageOptions: { parser: tseslint.parser, - ecmaVersion: 'latest', - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - }, - globals: { - browser: true, - window: true, - document: true, - es2022: true, - HTMLElement: true, - }, - }, - plugins: { - 'no-relative-import-paths': noRelativeImportPaths, - prettier: prettier, - import: importPlugin, - vue: vuePlugin, - }, - rules: { - '@typescript-eslint/no-unused-vars': ['off'], - 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }], - 'no-relative-import-paths/no-relative-import-paths': [ - 'error', - { allowSameFolder: false, rootDir: 'src', prefix: '@' }, - ], - 'prettier/prettier': 'error', - 'no-restricted-globals': [ - 'error', - { - name: '__dirname', - message: 'Use import.meta.url instead of __dirname in ESM', - }, - { - name: '__filename', - message: 'Use import.meta.url instead of __filename in ESM', - }, - ], - 'eol-last': ['error', 'always'], - // Vue specific rules - 'vue/multi-word-component-names': 'off', - 'vue/html-self-closing': [ - 'error', - { - html: { - void: 'always', - normal: 'always', - component: 'always', + parserOptions: { + ...commonLanguageOptions, + ecmaFeatures: { + jsx: true, }, }, - ], - 'vue/component-name-in-template-casing': ['error', 'PascalCase'], - 'vue/component-definition-name-casing': ['error', 'PascalCase'], - 'vue/no-unsupported-features': [ - 'error', - { - version: '^3.3.0', + globals: { + ...commonGlobals }, - ], - 'vue/no-undef-components': ['error'], - 'vue/no-unused-properties': [ - 'error', - { - groups: ['props'], - deepData: false, - }, - ], - // Allow empty object types and any types in Vue component definitions - '@typescript-eslint/no-explicit-any': [ - 'error', - { - ignoreRestArgs: true, - fixToUnknown: false, - }, - ], + }, + plugins: { + 'no-relative-import-paths': noRelativeImportPaths, + prettier: prettier, + import: importPlugin, + }, + rules: { + ...commonRules, + }, }, - - ignores: ['src/graphql/generated/client/**/*'], -}); + + // Vue Files (.vue) + { + files: ['**/*.vue'], + languageOptions: { + parser: require('vue-eslint-parser'), + parserOptions: { + ...commonLanguageOptions, + parser: tseslint.parser, + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + ...commonGlobals + }, + }, + plugins: { + 'no-relative-import-paths': noRelativeImportPaths, + prettier: prettier, + import: importPlugin, + vue: vuePlugin, + }, + rules: { + ...commonRules, + ...vueRules, + }, + }, + + // Ignores + { + ignores: ['src/graphql/generated/client/**/*'], + }, +]; diff --git a/unraid-ui/package.json b/unraid-ui/package.json index 1be9af6e8..e292b208a 100644 --- a/unraid-ui/package.json +++ b/unraid-ui/package.json @@ -21,9 +21,9 @@ "test:ui": "vitest --ui", "coverage": "vitest run --coverage", "// Build": "", - "prebuild": "npm run clean", "build": "vite build", - "build:watch": "vite build -c vite.web-component.ts --mode production --watch", + "build:watch": "concurrently \"pnpm build:wc --watch\" \"pnpm build --watch\"", + "build:watch:main": "vite build --watch", "build:wc": "REM_PLUGIN=true vite build -c vite.web-component.ts --mode production", "build:all": "vite build && vite build -c vite.web-component.ts --mode production", "clean": "rimraf dist", @@ -34,7 +34,10 @@ "preunraid:deploy": "pnpm build:wc", "unraid:deploy": "just deploy", "// Storybook": "", + "prestorybook": "pnpm storybook:css", "storybook": "storybook dev -p 6006", + "storybook:css": "node scripts/build-style.mjs", + "prebuild-storybook": "pnpm storybook:css", "build-storybook": "storybook build" }, "peerDependencies": { @@ -51,20 +54,23 @@ "@vueuse/core": "^13.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.2.5", "kebab-case": "^2.0.1", "lucide-vue-next": "^0.511.0", - "reka-ui": "^2.1.1", "shadcn-vue": "^2.0.0", + "marked": "^15.0.0", + "reka-ui": "^2.1.1", "tailwind-merge": "^2.6.0", "vue-sonner": "^1.3.0" }, "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.4.1", - "@storybook/addon-essentials": "^8.5.8", - "@storybook/addon-interactions": "^8.5.8", - "@storybook/addon-links": "^8.5.8", - "@storybook/builder-vite": "^8.5.8", - "@storybook/vue3-vite": "^8.5.8", + "@storybook/addon-essentials": "^8.6.12", + "@storybook/addon-interactions": "^8.6.12", + "@storybook/addon-links": "^8.6.12", + "@storybook/builder-vite": "^8.6.12", + "@storybook/vue3": "^8.6.12", + "@storybook/vue3-vite": "^8.6.12", "@tailwindcss/typography": "^0.5.15", "@testing-library/vue": "^8.0.0", "@types/jsdom": "^21.1.7", @@ -79,6 +85,7 @@ "@vue/test-utils": "^2.4.0", "@vue/tsconfig": "^0.7.0", "autoprefixer": "^10.4.20", + "concurrently": "^9.1.2", "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.0", "eslint-plugin-import": "^2.31.0", @@ -87,10 +94,11 @@ "eslint-plugin-vue": "^10.0.0", "happy-dom": "^17.0.0", "postcss": "^8.4.49", + "postcss-import": "^16.1.0", "prettier": "3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", "rimraf": "^6.0.1", - "storybook": "^8.5.8", + "storybook": "^8.6.12", "tailwind-rem-to-rem": "github:unraid/tailwind-rem-to-rem", "tailwindcss": "^3.0.0", "tailwindcss-animate": "^1.0.7", diff --git a/unraid-ui/scripts/build-style.mjs b/unraid-ui/scripts/build-style.mjs new file mode 100644 index 000000000..aaadf385b --- /dev/null +++ b/unraid-ui/scripts/build-style.mjs @@ -0,0 +1,30 @@ +import fs from 'fs/promises'; +import autoprefixer from 'autoprefixer'; +import postcss from 'postcss'; +import postcssImport from 'postcss-import'; +import tailwindcss from 'tailwindcss'; + +/** + * Helper script for storybook to build the CSS file for the components. This is used to ensure that modals render using the shadow styles. + */ + +process.env.VITE_TAILWIND_BASE_FONT_SIZE = 16; + +const inputPath = './src/styles/index.css'; +const outputPath = './.storybook/static/index.css'; // served from root: /index.css + +const css = await fs.readFile(inputPath, 'utf8'); + +const result = await postcss([ + postcssImport(), + tailwindcss({ config: './tailwind.config.ts' }), + autoprefixer(), +]).process(css, { + from: inputPath, + to: outputPath, +}); + +await fs.mkdir('./.storybook/static', { recursive: true }); +await fs.writeFile(outputPath, result.css); + +console.log('✅ CSS built for Storybook:', outputPath); \ No newline at end of file diff --git a/unraid-ui/src/components.ts b/unraid-ui/src/components.ts new file mode 100644 index 000000000..97d6da9d7 --- /dev/null +++ b/unraid-ui/src/components.ts @@ -0,0 +1,21 @@ +export * from '@/components/common/badge'; +export * from '@/components/brand'; +export * from '@/components/common/button'; +export * from '@/components/layout'; +export * from '@/components/common/dropdown-menu'; +export * from '@/components/common/loading'; +export * from '@/components/form/input'; +export * from '@/components/form/label'; +export * from '@/components/form/number'; +export * from '@/components/form/lightswitch'; +export * from '@/components/form/select'; +export * from '@/components/form/switch'; +export * from '@/components/common/scroll-area'; +export * from '@/components/common/stepper'; +export * from '@/components/common/sheet'; +export * from '@/components/common/tabs'; +export * from '@/components/common/tooltip'; +export * from '@/components/common/toast'; +export * from '@/components/common/popover'; +export * from '@/components/modals'; +export * from '@/components/common/accordion'; diff --git a/unraid-ui/src/components/common/dropdown-menu/index.ts b/unraid-ui/src/components/common/dropdown-menu/index.ts index 2eb0f8141..02b99fd51 100644 --- a/unraid-ui/src/components/common/dropdown-menu/index.ts +++ b/unraid-ui/src/components/common/dropdown-menu/index.ts @@ -1,5 +1,6 @@ -export { default as DropdownMenu } from './DropdownMenu.vue'; +import DropdownMenu from '@/components/common/dropdown-menu/DropdownMenu.vue'; +export { DropdownMenu }; export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'; export { default as DropdownMenuContent } from './DropdownMenuContent.vue'; export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'; diff --git a/unraid-ui/src/components/common/sheet/SheetFooter.vue b/unraid-ui/src/components/common/sheet/SheetFooter.vue index 4ee0475c1..5f3b7dff9 100644 --- a/unraid-ui/src/components/common/sheet/SheetFooter.vue +++ b/unraid-ui/src/components/common/sheet/SheetFooter.vue @@ -6,7 +6,7 @@ const props = defineProps<{ class?: HTMLAttributes['class'] }>(); diff --git a/unraid-ui/src/components/common/tooltip/TooltipContent.vue b/unraid-ui/src/components/common/tooltip/TooltipContent.vue index 589bd8d6f..eb9e711a3 100644 --- a/unraid-ui/src/components/common/tooltip/TooltipContent.vue +++ b/unraid-ui/src/components/common/tooltip/TooltipContent.vue @@ -33,7 +33,7 @@ const { teleportTarget } = useTeleport();