From a7a873e5652efd9d0ff5ed65bb9400e5499d4ce4 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 9 Sep 2025 12:19:50 -0400 Subject: [PATCH 1/5] chore: rclone initialization version check --- .../resolvers/rclone-api.service.test.ts | 93 ++++++++++++++++++- .../resolvers/rclone/rclone-api.service.ts | 38 ++++++-- 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts index 3370fffa4e..4ce9576ae1 100644 --- a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts +++ b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts @@ -97,7 +97,7 @@ describe('RCloneApiService', () => { mockPRetry.mockResolvedValue(undefined); service = new RCloneApiService(); - await service.onModuleInit(); + await service.onApplicationBootstrap(); // Reset the mock after initialization to prepare for test-specific responses mockGot.post.mockClear(); @@ -434,4 +434,95 @@ describe('RCloneApiService', () => { ); }); }); + + describe('checkRcloneBinaryExists', () => { + beforeEach(() => { + // Create a new service instance without initializing for these tests + service = new RCloneApiService(); + }); + + it('should return true when rclone version is 1.70.0', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: 'rclone v1.70.0\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)', + stderr: '', + } as any); + + const result = await (service as any).checkRcloneBinaryExists(); + + expect(result).toBe(true); + }); + + it('should return true when rclone version is newer than 1.70.0', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: 'rclone v1.75.2\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)', + stderr: '', + } as any); + + const result = await (service as any).checkRcloneBinaryExists(); + + expect(result).toBe(true); + }); + + it('should return false when rclone version is older than 1.70.0', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: 'rclone v1.69.0\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)', + stderr: '', + } as any); + + const result = await (service as any).checkRcloneBinaryExists(); + + expect(result).toBe(false); + }); + + it('should return false when rclone version is much older', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: 'rclone v1.50.0\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)', + stderr: '', + } as any); + + const result = await (service as any).checkRcloneBinaryExists(); + + expect(result).toBe(false); + }); + + it('should return false when version cannot be parsed', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: 'rclone unknown version format', + stderr: '', + } as any); + + const result = await (service as any).checkRcloneBinaryExists(); + + expect(result).toBe(false); + }); + + it('should return false when rclone binary is not found', async () => { + const error = new Error('Command not found') as any; + error.code = 'ENOENT'; + mockExeca.mockRejectedValueOnce(error); + + const result = await (service as any).checkRcloneBinaryExists(); + + expect(result).toBe(false); + }); + + it('should return false and log error for other exceptions', async () => { + mockExeca.mockRejectedValueOnce(new Error('Some other error')); + + const result = await (service as any).checkRcloneBinaryExists(); + + expect(result).toBe(false); + }); + + it('should handle beta/rc versions correctly', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: 'rclone v1.70.0-beta.1\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)', + stderr: '', + } as any); + + const result = await (service as any).checkRcloneBinaryExists(); + + expect(result).toBe(true); + }); + }); }); 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 index 89fd362e9f..80439caaf1 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts @@ -1,4 +1,10 @@ -import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; import crypto from 'crypto'; import { ChildProcess } from 'node:child_process'; import { mkdir, rm, writeFile } from 'node:fs/promises'; @@ -7,6 +13,7 @@ import { dirname, join } from 'node:path'; import { execa } from 'execa'; import got, { HTTPError } from 'got'; import pRetry from 'p-retry'; +import semver from 'semver'; import { sanitizeParams } from '@app/core/log.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; @@ -25,7 +32,7 @@ import { import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; @Injectable() -export class RCloneApiService implements OnModuleInit, OnModuleDestroy { +export class RCloneApiService implements OnApplicationBootstrap, OnModuleDestroy { private isInitialized: boolean = false; private readonly logger = new Logger(RCloneApiService.name); private rcloneSocketPath: string = ''; @@ -44,7 +51,7 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { return this.isInitialized; } - async onModuleInit(): Promise { + async onApplicationBootstrap(): Promise { // RClone startup disabled - early return if (ENVIRONMENT === 'production') { this.logger.debug('RClone startup is disabled'); @@ -239,12 +246,31 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { } /** - * Checks if the RClone binary is available on the system + * Checks if the RClone binary is available on the system and meets minimum version requirements */ private async checkRcloneBinaryExists(): Promise { try { - await execa('rclone', ['version']); - this.logger.debug('RClone binary is available on the system.'); + const result = await execa('rclone', ['version']); + const versionOutput = result.stdout; + + // Parse version from output (format: "rclone vX.XX.X") + const versionMatch = versionOutput.match(/rclone v(\d+\.\d+\.\d+)/); + if (!versionMatch) { + this.logger.error('Unable to parse RClone version from output'); + return false; + } + + const currentVersion = versionMatch[1]; + const minimumVersion = '1.70.0'; + + if (!semver.gte(currentVersion, minimumVersion)) { + this.logger.error( + `RClone version ${currentVersion} is too old. Minimum required version is ${minimumVersion}` + ); + return false; + } + + this.logger.debug(`RClone binary is available on the system (version ${currentVersion}).`); return true; } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { From cc4cdef3551ed26d6f93d31de67bd76b83caa844 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 10 Sep 2025 10:14:17 -0400 Subject: [PATCH 2/5] refactor: simplify permissions in settings.json and enhance mock implementation in rclone-api tests --- .claude/settings.json | 122 +----------------- .../resolvers/rclone-api.service.test.ts | 97 ++++++++------ 2 files changed, 57 insertions(+), 162 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 440eb2ffda..9908bb5af4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,123 +1,3 @@ { - "permissions": { - "allow": [ - "# Development Commands", - "Bash(pnpm install)", - "Bash(pnpm dev)", - "Bash(pnpm build)", - "Bash(pnpm test)", - "Bash(pnpm test:*)", - "Bash(pnpm lint)", - "Bash(pnpm lint:fix)", - "Bash(pnpm type-check)", - "Bash(pnpm codegen)", - "Bash(pnpm storybook)", - "Bash(pnpm --filter * dev)", - "Bash(pnpm --filter * build)", - "Bash(pnpm --filter * test)", - "Bash(pnpm --filter * lint)", - "Bash(pnpm --filter * codegen)", - - "# Git Commands (read-only)", - "Bash(git status)", - "Bash(git diff)", - "Bash(git log)", - "Bash(git branch)", - "Bash(git remote -v)", - - "# Search Commands", - "Bash(rg *)", - - "# File System (read-only)", - "Bash(ls)", - "Bash(ls -la)", - "Bash(pwd)", - "Bash(find . -name)", - "Bash(find . -type)", - - "# Node/NPM Commands", - "Bash(node --version)", - "Bash(pnpm --version)", - "Bash(npx --version)", - - "# Environment Commands", - "Bash(echo $*)", - "Bash(which *)", - - "# Process Commands", - "Bash(ps aux | grep)", - "Bash(lsof -i)", - - "# Documentation Domains", - "WebFetch(domain:tailwindcss.com)", - "WebFetch(domain:github.com)", - "WebFetch(domain:reka-ui.com)", - "WebFetch(domain:nodejs.org)", - "WebFetch(domain:pnpm.io)", - "WebFetch(domain:vitejs.dev)", - "WebFetch(domain:nuxt.com)", - "WebFetch(domain:nestjs.com)", - - "# IDE Integration", - "mcp__ide__getDiagnostics", - - "# Browser MCP (for testing)", - "mcp__browsermcp__browser_navigate", - "mcp__browsermcp__browser_click", - "mcp__browsermcp__browser_screenshot" - ], - "deny": [ - "# Dangerous Commands", - "Bash(rm -rf)", - "Bash(chmod 777)", - "Bash(curl)", - "Bash(wget)", - "Bash(ssh)", - "Bash(scp)", - "Bash(sudo)", - "Bash(su)", - "Bash(pkill)", - "Bash(kill)", - "Bash(killall)", - "Bash(python)", - "Bash(python3)", - "Bash(pip)", - "Bash(npm)", - "Bash(yarn)", - "Bash(apt)", - "Bash(brew)", - "Bash(systemctl)", - "Bash(service)", - "Bash(docker)", - "Bash(docker-compose)", - - "# File Modification (use Edit/Write tools instead)", - "Bash(sed)", - "Bash(awk)", - "Bash(perl)", - "Bash(echo > *)", - "Bash(echo >> *)", - "Bash(cat > *)", - "Bash(cat >> *)", - "Bash(tee)", - - "# Git Write Commands (require explicit user action)", - "Bash(git add)", - "Bash(git commit)", - "Bash(git push)", - "Bash(git pull)", - "Bash(git merge)", - "Bash(git rebase)", - "Bash(git checkout)", - "Bash(git reset)", - "Bash(git clean)", - - "# Package Management Write Commands", - "Bash(pnpm add)", - "Bash(pnpm remove)", - "Bash(pnpm update)", - "Bash(pnpm upgrade)" - ] - }, - "enableAllProjectMcpServers": false + "permissions": {} } \ No newline at end of file diff --git a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts index 4ce9576ae1..e4adb7452b 100644 --- a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts +++ b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts @@ -12,7 +12,22 @@ import { UpdateRCloneRemoteDto, } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; -vi.mock('got'); +vi.mock('got', () => { + const mockPost = vi.fn(); + const gotMock = { + post: mockPost, + }; + return { + default: gotMock, + HTTPError: class HTTPError extends Error { + response?: any; + constructor(response?: any) { + super('HTTP Error'); + this.response = response; + } + }, + }; +}); vi.mock('execa'); vi.mock('p-retry'); vi.mock('node:fs', () => ({ @@ -60,7 +75,7 @@ vi.mock('@nestjs/common', async (importOriginal) => { describe('RCloneApiService', () => { let service: RCloneApiService; - let mockGot: any; + let mockGotPost: any; let mockExeca: any; let mockPRetry: any; let mockExistsSync: any; @@ -68,19 +83,19 @@ describe('RCloneApiService', () => { beforeEach(async () => { vi.clearAllMocks(); - const { default: got } = await import('got'); + const got = await import('got'); const { execa } = await import('execa'); const pRetry = await import('p-retry'); const { existsSync } = await import('node:fs'); const { fileExists } = await import('@app/core/utils/files/file-exists.js'); - mockGot = vi.mocked(got); + mockGotPost = vi.mocked(got.default.post); mockExeca = vi.mocked(execa); mockPRetry = vi.mocked(pRetry.default); mockExistsSync = vi.mocked(existsSync); // Mock successful RClone API response for socket check - mockGot.post = vi.fn().mockResolvedValue({ body: { pid: 12345 } }); + mockGotPost.mockResolvedValue({ body: { pid: 12345 } }); // Mock RClone binary exists check vi.mocked(fileExists).mockResolvedValue(true); @@ -100,7 +115,7 @@ describe('RCloneApiService', () => { await service.onApplicationBootstrap(); // Reset the mock after initialization to prepare for test-specific responses - mockGot.post.mockClear(); + mockGotPost.mockClear(); }); describe('getProviders', () => { @@ -109,15 +124,15 @@ describe('RCloneApiService', () => { { name: 'aws', prefix: 's3', description: 'Amazon S3' }, { name: 'google', prefix: 'drive', description: 'Google Drive' }, ]; - mockGot.post.mockResolvedValue({ + mockGotPost.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(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/config\/providers$/), expect.objectContaining({ json: {}, responseType: 'json', @@ -130,7 +145,7 @@ describe('RCloneApiService', () => { }); it('should return empty array when no providers', async () => { - mockGot.post.mockResolvedValue({ body: {} }); + mockGotPost.mockResolvedValue({ body: {} }); const result = await service.getProviders(); @@ -141,15 +156,15 @@ describe('RCloneApiService', () => { describe('listRemotes', () => { it('should return list of remotes', async () => { const mockRemotes = ['backup-s3', 'drive-storage']; - mockGot.post.mockResolvedValue({ + mockGotPost.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(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/config\/listremotes$/), expect.objectContaining({ json: {}, responseType: 'json', @@ -162,7 +177,7 @@ describe('RCloneApiService', () => { }); it('should return empty array when no remotes', async () => { - mockGot.post.mockResolvedValue({ body: {} }); + mockGotPost.mockResolvedValue({ body: {} }); const result = await service.listRemotes(); @@ -174,13 +189,13 @@ describe('RCloneApiService', () => { it('should return remote details', async () => { const input: GetRCloneRemoteDetailsDto = { name: 'test-remote' }; const mockConfig = { type: 's3', provider: 'AWS' }; - mockGot.post.mockResolvedValue({ body: mockConfig }); + mockGotPost.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(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/config\/get$/), expect.objectContaining({ json: { name: 'test-remote' }, responseType: 'json', @@ -197,7 +212,7 @@ describe('RCloneApiService', () => { 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 }); + mockGotPost.mockResolvedValue({ body: mockConfig }); const result = await service.getRemoteConfig(input); @@ -213,13 +228,13 @@ describe('RCloneApiService', () => { parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' }, }; const mockResponse = { success: true }; - mockGot.post.mockResolvedValue({ body: mockResponse }); + mockGotPost.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(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/config\/create$/), expect.objectContaining({ json: { name: 'new-remote', @@ -243,13 +258,13 @@ describe('RCloneApiService', () => { parameters: { access_key_id: 'NEW_AKIA...' }, }; const mockResponse = { success: true }; - mockGot.post.mockResolvedValue({ body: mockResponse }); + mockGotPost.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(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/config\/update$/), expect.objectContaining({ json: { name: 'existing-remote', @@ -269,13 +284,13 @@ describe('RCloneApiService', () => { it('should delete a remote', async () => { const input: DeleteRCloneRemoteDto = { name: 'remote-to-delete' }; const mockResponse = { success: true }; - mockGot.post.mockResolvedValue({ body: mockResponse }); + mockGotPost.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(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/config\/delete$/), expect.objectContaining({ json: { name: 'remote-to-delete' }, responseType: 'json', @@ -296,13 +311,13 @@ describe('RCloneApiService', () => { options: { delete_on: 'dst' }, }; const mockResponse = { jobid: 'job-123' }; - mockGot.post.mockResolvedValue({ body: mockResponse }); + mockGotPost.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(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/sync\/copy$/), expect.objectContaining({ json: { srcFs: '/source/path', @@ -323,13 +338,13 @@ describe('RCloneApiService', () => { it('should return job status', async () => { const input: GetRCloneJobStatusDto = { jobId: 'job-123' }; const mockStatus = { status: 'running', progress: 0.5 }; - mockGot.post.mockResolvedValue({ body: mockStatus }); + mockGotPost.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(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/job\/status$/), expect.objectContaining({ json: { jobid: 'job-123' }, responseType: 'json', @@ -348,13 +363,13 @@ describe('RCloneApiService', () => { { id: 'job-1', status: 'running' }, { id: 'job-2', status: 'finished' }, ]; - mockGot.post.mockResolvedValue({ body: mockJobs }); + mockGotPost.mockResolvedValue({ body: mockJobs }); const result = await service.listRunningJobs(); expect(result).toEqual(mockJobs); - expect(mockGot.post).toHaveBeenCalledWith( - 'http://unix:/tmp/rclone.sock:/job/list', + expect(mockGotPost).toHaveBeenCalledWith( + expect.stringMatching(/\/job\/list$/), expect.objectContaining({ json: {}, responseType: 'json', @@ -378,7 +393,7 @@ describe('RCloneApiService', () => { }, }; Object.setPrototypeOf(httpError, HTTPError.prototype); - mockGot.post.mockRejectedValue(httpError); + mockGotPost.mockRejectedValue(httpError); await expect(service.getProviders()).rejects.toThrow( 'Rclone API Error (config/providers, HTTP 500): Rclone Error: Internal server error' @@ -395,7 +410,7 @@ describe('RCloneApiService', () => { }, }; Object.setPrototypeOf(httpError, HTTPError.prototype); - mockGot.post.mockRejectedValue(httpError); + mockGotPost.mockRejectedValue(httpError); await expect(service.getProviders()).rejects.toThrow( 'Rclone API Error (config/providers, HTTP 404): Failed to process error response body. Raw body:' @@ -412,7 +427,7 @@ describe('RCloneApiService', () => { }, }; Object.setPrototypeOf(httpError, HTTPError.prototype); - mockGot.post.mockRejectedValue(httpError); + mockGotPost.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' @@ -421,13 +436,13 @@ describe('RCloneApiService', () => { it('should handle non-HTTP errors', async () => { const networkError = new Error('Network connection failed'); - mockGot.post.mockRejectedValue(networkError); + mockGotPost.mockRejectedValue(networkError); await expect(service.getProviders()).rejects.toThrow('Network connection failed'); }); it('should handle unknown errors', async () => { - mockGot.post.mockRejectedValue('unknown error'); + mockGotPost.mockRejectedValue('unknown error'); await expect(service.getProviders()).rejects.toThrow( 'Unknown error calling RClone API (config/providers) with params {}: unknown error' From 669c349a87299442276d3fab8130c750775f4db1 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 10 Sep 2025 10:27:41 -0400 Subject: [PATCH 3/5] Potential fix for code scanning alert no. 223: Unused variable, import, function or class Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts | 1 - 1 file changed, 1 deletion(-) 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 index 80439caaf1..dc105d30ba 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts @@ -3,7 +3,6 @@ import { Logger, OnApplicationBootstrap, OnModuleDestroy, - OnModuleInit, } from '@nestjs/common'; import crypto from 'crypto'; import { ChildProcess } from 'node:child_process'; From d082aa06693ac45b0a267633fb46ee624562bc37 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 10 Sep 2025 10:33:49 -0400 Subject: [PATCH 4/5] fix: improve RClone version check by coercing prerelease versions --- .../resolvers/rclone/rclone-api.service.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) 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 index dc105d30ba..81267438db 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts @@ -250,26 +250,36 @@ export class RCloneApiService implements OnApplicationBootstrap, OnModuleDestroy private async checkRcloneBinaryExists(): Promise { try { const result = await execa('rclone', ['version']); - const versionOutput = result.stdout; + const versionOutput = result.stdout.trim(); - // Parse version from output (format: "rclone vX.XX.X") - const versionMatch = versionOutput.match(/rclone v(\d+\.\d+\.\d+)/); + // Extract raw version string (format: "rclone vX.XX.X" or "rclone vX.XX.X-beta.X") + const versionMatch = versionOutput.match(/rclone v([\d.\-\w]+)/); if (!versionMatch) { this.logger.error('Unable to parse RClone version from output'); return false; } - const currentVersion = versionMatch[1]; + const rawVersion = versionMatch[1]; + + // Use semver.coerce to get base semver from prerelease versions + const coercedVersion = semver.coerce(rawVersion); + if (!coercedVersion) { + this.logger.error(`Failed to parse RClone version: raw="${rawVersion}"`); + return false; + } + const minimumVersion = '1.70.0'; - if (!semver.gte(currentVersion, minimumVersion)) { + if (!semver.gte(coercedVersion, minimumVersion)) { this.logger.error( - `RClone version ${currentVersion} is too old. Minimum required version is ${minimumVersion}` + `RClone version ${rawVersion} (coerced: ${coercedVersion}) is too old. Minimum required version is ${minimumVersion}` ); return false; } - this.logger.debug(`RClone binary is available on the system (version ${currentVersion}).`); + this.logger.debug( + `RClone binary is available on the system (version ${rawVersion}, coerced: ${coercedVersion}).` + ); return true; } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { From c11b723253098fdb344ee1819bd99d97361b99a5 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 10 Sep 2025 10:37:17 -0400 Subject: [PATCH 5/5] refactor: streamline imports in rclone-api.service.ts for improved readability --- .../graph/resolvers/rclone/rclone-api.service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 index 81267438db..b52d20cd36 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts @@ -1,9 +1,4 @@ -import { - Injectable, - Logger, - OnApplicationBootstrap, - OnModuleDestroy, -} from '@nestjs/common'; +import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; import crypto from 'crypto'; import { ChildProcess } from 'node:child_process'; import { mkdir, rm, writeFile } from 'node:fs/promises';