Skip to content

fix: improve FTPClient#304

Merged
nytamin merged 3 commits into
mainfrom
fix/ftp-client
Jun 1, 2026
Merged

fix: improve FTPClient#304
nytamin merged 3 commits into
mainfrom
fix/ftp-client

Conversation

@nytamin

@nytamin nytamin commented Apr 22, 2026

Copy link
Copy Markdown
Member

About Me

This pull request is posted on behalf of the NRK.

Type of Contribution

This is a: Code improvement

New Behavior

This PR refactors the FTPClient to further avoid calling multiple operations at the same time.

Also runs some operations in batches, to improve performance and reliability.

Status

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR serializes FTP operations via a new SafeFTPClient (PQueue), batches multi-step FTP calls, tightens FTP client cache keying and mismatch handling, conditionally clears package-removal state, and skips JSON writes when missing file + undefined result.

Changes

FTP Client Queuing and Handler Integration

Layer / File(s) Summary
SafeFTPClient wrapper and integration
shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts
SafeFTPClient class wraps FTP.Client with internal PQueue (concurrency 1) for serialized execution and provides batch helper; FTPClient uses SafeFTPClient.
FTPClient batched operations
shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts
Core FTPClient methods (init, getFileInfo, upload, removeDirIfExists) execute their multi-step FTP sequences inside client.batch for serialized execution.
FTP handler caching and state management
shared/packages/worker/src/worker/accessorHandlers/ftp.ts
prepareFTPClient builds cache keys from accessorId, full options, and accessor.basePath; on mismatch it logs masked details and destroys stale clients before recreating. ensurePackageFulfilled and prepareForOperation conditionally clear pending removal only when removeDelay is non-negative; readFileIfExists checks existence before reading.

JSON File Write Optimization

Layer / File(s) Summary
JSON write no-op condition
shared/packages/worker/src/worker/accessorHandlers/lib/json-write-file.ts
updateJSONFile early-return also skips work when the file does not exist and the callback returns undefined.

Sequence Diagrams

sequenceDiagram
  participant FTPClient
  participant SafeFTPClient
  participant PQueue
  participant FTPBase["FTP.Client"]
  FTPClient->>SafeFTPClient: call batch(callback)
  SafeFTPClient->>PQueue: add task to queue
  PQueue->>FTPBase: await FTP operation (size/cd/etc)
  FTPBase-->>PQueue: result or error
  PQueue-->>SafeFTPClient: return result with augmented stack
  SafeFTPClient-->>FTPClient: batch callback completes
Loading

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: improve FTPClient' directly relates to the main changes, which refactor FTPClient to improve concurrency handling and batching, but is somewhat generic and doesn't specify the key improvements.
Description check ✅ Passed The description clearly explains the PR is a code improvement that refactors FTPClient to avoid concurrent operations and run operations in batches for better performance and reliability, which aligns with the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ftp-client

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nytamin nytamin marked this pull request as ready for review May 14, 2026 06:37

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts (2)

121-154: ⚡ Quick win

Redundant size call and TOCTOU window between existence check and batch.

_fileExists already issues a queued size call, then this method opens a fresh batch that issues another size call against the same path. That doubles the round trips and queue entries, and because the two queue entries are not contiguous, another caller's queued operation can run between them — so the file may no longer exist (or may have changed) when the batch executes. The same pattern exists in removeDirIfExists (Line 287) and upload/uploadContent rely on init having connected but not on path state.

Consider folding the existence check into the single batch so the entire sequence is atomic from the queue's perspective, and size is fetched once:

♻️ Proposed refactor to consolidate into a single batch
 	async getFileInfo(fullPath: string): Promise<FileInfoReturnType> {
 		await this.init() // Ensure the client is connected
 
 		this.logger.silly(`Getting file info for: ${fullPath}`)
 
-		const exists = await this._fileExists(fullPath) // Ensure the file exists before trying to get its info
-
-		if (!exists.exists)
-			return {
-				success: false,
-				knownReason: exists.knownReason,
-				reason: exists.reason,
-
-				packageExists: false,
-			}
-
 		return this.client.batch(async (ftpClient) => {
-			const size = await ftpClient.size(fullPath)
+			let size: number
+			try {
+				size = await ftpClient.size(fullPath)
+			} catch (e) {
+				if (e instanceof FTP.FTPError) {
+					return {
+						success: false,
+						knownReason: e.code === 550,
+						reason:
+							e.code === 550
+								? { user: `File not found`, tech: `File "${fullPath}" not found on FTP server ([${e.code}]: ${e.message})` }
+								: { user: `Error response from FTP Server`, tech: `FTP Server: [${e.code}]: ${e.message}` },
+						packageExists: false,
+					}
+				}
+				throw e
+			}
 
 			let modDate: Date | undefined = undefined
 			try {
 				modDate = await ftpClient.lastMod(fullPath)
 			} catch {
 				// This is not supported by every FTP server, ignore any error
 			}
 			return {
 				success: true,
 				fileInfo: {
 					size,
 					modified: modDate ? modDate.getTime() : 0,
 				},
 			}
 		})
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts`
around lines 121 - 154, getFileInfo currently calls _fileExists (which queues a
size) and then opens a separate this.client.batch that calls size again,
creating duplicate requests and a TOCTOU window; refactor getFileInfo to perform
the existence check and the size/lastMod retrieval inside a single
this.client.batch callback (use the ftpClient instance to run size and lastMod
once, return the same FileInfoReturnType), removing the external _fileExists
call; apply the same consolidation pattern to removeDirIfExists and ensure
upload/uploadContent do not rely on path-state checks performed outside
init/batch so all path-dependent operations run atomically inside the queue
batch (refer to getFileInfo, _fileExists, this.client.batch, removeDirIfExists,
upload, uploadContent).

306-307: 💤 Low value

Mark ftpClient and queue as readonly.

Both fields are assigned only in the constructor and never reassigned. Aligning with the SonarCloud hints documents intent and prevents accidental reassignment.

♻️ Proposed diff
-	private ftpClient: FTP.Client
-	private queue: PQueue
+	private readonly ftpClient: FTP.Client
+	private readonly queue: PQueue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts`
around lines 306 - 307, The ftpClient and queue fields are assigned only in the
constructor and should be declared readonly to prevent reassignment; update the
class field declarations for ftpClient (FTP.Client) and queue (PQueue) in
FTPClient to use the readonly modifier (e.g., change "private ftpClient:
FTP.Client" and "private queue: PQueue" to "private readonly ftpClient:
FTP.Client" and "private readonly queue: PQueue") and ensure the constructor
continues to initialize them as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@shared/packages/worker/src/worker/accessorHandlers/ftp.ts`:
- Around line 496-502: Log messages leak FTP credentials by serializing
FTPOptions (cacheKey, options, cachedClients.options) and in the creation info
log; instead, before calling this.worker.logger.error or .info, build a
sanitized representation that omits or masks sensitive fields (use the existing
safeUrl getter or create a small sanitizer that copies options and replaces
password/username with masked values) and log that sanitized object/URL; update
both the cache-mismatch log (currently referencing
cacheKey/options/cachedClients.options) and the "Creating new FTP client..."
info log to use the sanitized view, and fix the typo "It the options" → "If the
options".

In
`@shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts`:
- Around line 329-331: The error construction in putInQueue currently includes
JSON.stringify(args) which can leak credentials when wrappers like
access(...args) (wrapper for FTPInstance.access) are called; update the
implementation so that sensitive data is not serialized — either remove args
from the orgError message, only include the method name, or redact known
sensitive keys (e.g., user, password) before any stringify; you can implement
this by adding a small sanitizer used by putInQueue (or by adding a flag on
calls from access and other wrappers at lines ~360-377) so errors log safe text
only and avoid embedding raw args.
- Around line 165-166: Remove the redundant explicit removal in the upload()
method: the code currently calls ftpClient.remove(fullPath, true) before calling
ftpClient.uploadFrom, but uploadFrom already overwrites existing files; delete
the remove(...) call so upload() behaves consistently with uploadContent(),
keeping uploadFrom(uploadFromPath, fullPath) as the sole operation for writing
the file and preserving expected overwrite semantics in the upload and
uploadContent functions.

---

Nitpick comments:
In
`@shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts`:
- Around line 121-154: getFileInfo currently calls _fileExists (which queues a
size) and then opens a separate this.client.batch that calls size again,
creating duplicate requests and a TOCTOU window; refactor getFileInfo to perform
the existence check and the size/lastMod retrieval inside a single
this.client.batch callback (use the ftpClient instance to run size and lastMod
once, return the same FileInfoReturnType), removing the external _fileExists
call; apply the same consolidation pattern to removeDirIfExists and ensure
upload/uploadContent do not rely on path-state checks performed outside
init/batch so all path-dependent operations run atomically inside the queue
batch (refer to getFileInfo, _fileExists, this.client.batch, removeDirIfExists,
upload, uploadContent).
- Around line 306-307: The ftpClient and queue fields are assigned only in the
constructor and should be declared readonly to prevent reassignment; update the
class field declarations for ftpClient (FTP.Client) and queue (PQueue) in
FTPClient to use the readonly modifier (e.g., change "private ftpClient:
FTP.Client" and "private queue: PQueue" to "private readonly ftpClient:
FTP.Client" and "private readonly queue: PQueue") and ensure the constructor
continues to initialize them as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 61eef297-c93f-4c91-82d5-b80d93fb5536

📥 Commits

Reviewing files that changed from the base of the PR and between 9468b7f and c7f32d3.

📒 Files selected for processing (3)
  • shared/packages/worker/src/worker/accessorHandlers/ftp.ts
  • shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts
  • shared/packages/worker/src/worker/accessorHandlers/lib/json-write-file.ts

Comment on lines +496 to +502
// It the options don't match, something is wrong with the cacheKey

this.worker.logger.error(
`Something is wrong with the FTP client cacheKey. The options do not match the cacheKey. Deleting cached clients for this key. cacheKey: ${cacheKey}, options: ${JSON.stringify(
options
)}, cachedOptions: ${JSON.stringify(cachedClients.options)}`
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging FTP credentials in cache-mismatch error.

cacheKey, options, and cachedClients.options all serialize the FTPOptions object that includes username and password (constructed at lines 476‑484). Emitting these via logger.error writes the plaintext password to logs/log shipping. The same problem exists at the new info log on line 542 (Creating new FTP client for purpose=...). The file already establishes a redaction convention via the safeUrl getter (line 456), so logs should use a sanitized view of the options.

🔒 Proposed fix — redact password before logging (apply to both call sites)
+		const safeOptions = { ...options, password: options.password ? '***' : options.password }
+		const safeCacheKey = JSON.stringify([this.accessorId, safeOptions, this.accessor.basePath ?? '/'])
+
 		let cachedClients = accessorCache[cacheKey]
 		if (cachedClients) {
 			// Check that options matches:
 			if (!isEqual(cachedClients.options, options)) {
-				// It the options don't match, something is wrong with the cacheKey
-
-				this.worker.logger.error(
-					`Something is wrong with the FTP client cacheKey. The options do not match the cacheKey. Deleting cached clients for this key. cacheKey: ${cacheKey}, options: ${JSON.stringify(
-						options
-					)}, cachedOptions: ${JSON.stringify(cachedClients.options)}`
-				)
+				// If the options don't match, something is wrong with the cacheKey
+				const safeCachedOptions = {
+					...cachedClients.options,
+					password: cachedClients.options.password ? '***' : cachedClients.options.password,
+				}
+				this.worker.logger.error(
+					`Something is wrong with the FTP client cacheKey. The options do not match the cacheKey. Deleting cached clients for this key. cacheKey: ${safeCacheKey}, options: ${JSON.stringify(
+						safeOptions
+					)}, cachedOptions: ${JSON.stringify(safeCachedOptions)}`
+				)

And at line 542:

-			this.worker.logger.info(`Creating new FTP client for purpose="${purpose}", ${JSON.stringify(options)}`)
+			this.worker.logger.info(`Creating new FTP client for purpose="${purpose}", ${JSON.stringify(safeOptions)}`)

Also consider fixing the typo "It the options" → "If the options" on line 496.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/packages/worker/src/worker/accessorHandlers/ftp.ts` around lines 496 -
502, Log messages leak FTP credentials by serializing FTPOptions (cacheKey,
options, cachedClients.options) and in the creation info log; instead, before
calling this.worker.logger.error or .info, build a sanitized representation that
omits or masks sensitive fields (use the existing safeUrl getter or create a
small sanitizer that copies options and replaces password/username with masked
values) and log that sanitized object/URL; update both the cache-mismatch log
(currently referencing cacheKey/options/cachedClients.options) and the "Creating
new FTP client..." info log to use the sanitized view, and fix the typo "It the
options" → "If the options".

Comment on lines +165 to +166
// Remove the file if it already exists:
await ftpClient.remove(fullPath, true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

basic-ftp Client uploadFrom overwrite existing file behavior

💡 Result:

The basic-ftp Client's uploadFrom method overwrites existing files on the remote FTP server by default. If a file with the specified remotePath already exists, it will be overwritten with the data from the provided readable stream or local file.[1][2][3]

Citations:


🏁 Script executed:

cat -n shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts | sed -n '155,200p'

Repository: Sofie-Automation/sofie-package-manager

Length of output: 1998


Inconsistency: upload removes target before uploadFrom, uploadContent does not.

The upload() method explicitly calls remove(fullPath, true) before uploadFrom, while uploadContent() omits this step. Since uploadFrom overwrites existing files by default (per basic-ftp documentation), the explicit removal in upload() is redundant. Remove the unnecessary remove() call from upload() to align both methods and avoid defensive programming that masks the intended overwrite semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts`
around lines 165 - 166, Remove the redundant explicit removal in the upload()
method: the code currently calls ftpClient.remove(fullPath, true) before calling
ftpClient.uploadFrom, but uploadFrom already overwrites existing files; delete
the remove(...) call so upload() behaves consistently with uploadContent(),
keeping uploadFrom(uploadFromPath, fullPath) as the sole operation for writing
the file and preserving expected overwrite semantics in the upload and
uploadContent functions.

Comment on lines +329 to +331
async access(...args: Parameters<FTPInstance['access']>): ReturnType<FTPInstance['access']> {
return this.putInQueue('access', args, async () => this.ftpClient.access(...args))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid serializing arbitrary args into error messages — risk of leaking credentials.

putInQueue builds orgError with JSON.stringify(args) and the wrapper exposes access(...) (Line 329), whose argument is an FTP.AccessOptions object containing user and password. Today access is only invoked inside batch via the raw client, so the password is not stringified — but the public wrapper makes it easy for a future caller to invoke safeFtp.access(options) directly, at which point a thrown error will embed the password in the error stack (which typically lands in logs).

Consider either dropping args from the error string, redacting them, or only including the method name:

🛡️ Proposed hardening
-			const orgError = new Error(`Error executing ${methodName}: ${JSON.stringify(args)}`) // Used later
+			const orgError = new Error(`Error executing ${methodName}`) // Used later (args omitted to avoid leaking credentials/secrets)

Also applies to: 360-377

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts`
around lines 329 - 331, The error construction in putInQueue currently includes
JSON.stringify(args) which can leak credentials when wrappers like
access(...args) (wrapper for FTPInstance.access) are called; update the
implementation so that sensitive data is not serialized — either remove args
from the orgError message, only include the method name, or redact known
sensitive keys (e.g., user, password) before any stringify; you can implement
this by adding a small sanitizer used by putInQueue (or by adding a flag on
calls from access and other wrappers at lines ~360-377) so errors log safe text
only and avoid embedding raw args.

@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@shared/packages/worker/src/worker/accessorHandlers/ftp.ts`:
- Around line 201-204: The FTP handler conditionally skips calling
fileHandler.clearPackageRemoval when this.workOptions.removeDelay is negative,
which leaves stale entries in packagesToRemove; remove the guard so
clearPackageRemoval(this.filePath) is called unconditionally (matching other
handlers like s3/localFolder/httpProxy/fileShare), or if you intentionally want
conditional behavior apply the same guard across all handlers; update the FTP
methods that currently wrap clearPackageRemoval with the
(this.workOptions.removeDelay ?? -1) >= 0 check to instead always call
fileHandler.clearPackageRemoval(this.filePath) (or make the same guard change in
the other handler implementations) so scheduled removals are correctly cancelled
when a package is rewritten.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3de3f86-0f87-4f31-8090-396462ad8a5e

📥 Commits

Reviewing files that changed from the base of the PR and between c7f32d3 and 39aede2.

📒 Files selected for processing (1)
  • shared/packages/worker/src/worker/accessorHandlers/ftp.ts

Comment on lines +201 to +204
if ((this.workOptions.removeDelay ?? -1) >= 0) {
// Only handle this if there is a removeDelay set, otherwise we can skip it to save time:
await this.fileHandler.clearPackageRemoval(this.filePath)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -i 'GenericFileOperations*' shared/packages/worker/src/worker/accessorHandlers | while read -r f; do
  echo "== $f =="
  rg -n -C4 '\b(clearPackageRemoval|handleRemovePackage|removeDuePackages)\b' "$f"
done

echo "== call sites =="
rg -n -C3 '\bclearPackageRemoval\s*\(' shared/packages/worker/src/worker/accessorHandlers
rg -n -C3 '\bhandleRemovePackage\s*\(' shared/packages/worker/src/worker/accessorHandlers

Repository: Sofie-Automation/sofie-package-manager

Length of output: 16656


Remove the guard or apply consistently across all file handlers.

The clearPackageRemoval() guard at lines 201-204 and 272-275 creates a behavioral inconsistency. All other handlers (s3, localFolder, httpProxy, fileShare) call clearPackageRemoval() unconditionally in equivalent methods. When FTP skips this due to removeDelay < 0, any stale delayed-removal entry persists in the packagesToRemove list (see GenericFileOperations.ts:41-45) and can still execute later via removeDuePackages(). If a package is scheduled for removal, then re-written with removeDelay = undefined, the stale removal entry will not be canceled. Either remove the guard to match other handlers or apply it to all handlers consistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/packages/worker/src/worker/accessorHandlers/ftp.ts` around lines 201 -
204, The FTP handler conditionally skips calling fileHandler.clearPackageRemoval
when this.workOptions.removeDelay is negative, which leaves stale entries in
packagesToRemove; remove the guard so clearPackageRemoval(this.filePath) is
called unconditionally (matching other handlers like
s3/localFolder/httpProxy/fileShare), or if you intentionally want conditional
behavior apply the same guard across all handlers; update the FTP methods that
currently wrap clearPackageRemoval with the (this.workOptions.removeDelay ?? -1)
>= 0 check to instead always call fileHandler.clearPackageRemoval(this.filePath)
(or make the same guard change in the other handler implementations) so
scheduled removals are correctly cancelled when a package is rewritten.

@Saftret Saftret added the Contribution from NRK Contributions sponsored by NRK (nrk.no) label May 28, 2026
@nytamin nytamin merged commit 0c6215d into main Jun 1, 2026
11 checks passed
@nytamin nytamin deleted the fix/ftp-client branch June 1, 2026 05:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Contribution from NRK Contributions sponsored by NRK (nrk.no)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants