SFTP Host Files Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Extend the SFTP server to expose node host filesystems via virtual /hosts/<nodeID>/ paths, add SFTP credentials to the NodeDetail web page, and add a NodeFilesScreen to the Android terminal app.
Architecture: The existing SFTP server (port 2022) already handles /instances/<uuid>/... by tunneling to the agent's SFTP subsystem. We add parallel support for /hosts/<nodeID>/... using the same tunnel mechanism with direct path passthrough (no volume translation). The Android app mirrors the existing InstanceFilesScreen pattern using the REST API (/v1/nodes/{id}/files* which already exists).
Tech Stack: Go (pkg/sftp, golang.org/x/crypto/ssh, tunnel.Manager), React/TypeScript (lucide-react, i18next), Kotlin/Jetpack Compose (Retrofit, Hilt, Navigation Compose)
Task 1: SFTP Server — resolveRemoteHost function
Files:
- Modify:
internal/controlplane/sftpserver/server.go
Step 1: Add resolveRemoteHost to fileSystem
Add after resolveRemote (around line 627). This handles paths like /hosts/<nodeID>/etc/nginx:
// resolveRemoteHost checks if a virtual path refers to a remote node host filesystem.
// Virtual path: /hosts/<nodeID>/path/on/host
// Remote path: /path/on/host (direct passthrough, no volume translation)
// Returns nil if path is not a /hosts/ path or tunnel is unavailable.
func (fs *fileSystem) resolveRemoteHost(virtualPath string) *remoteContext {
if fs.server == nil || fs.server.tunnelMgr == nil || fs.server.nodeRepo == nil {
return nil
}
// Parse /hosts/<nodeID>/...
parts := strings.Split(strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+virtualPath)), "/"), "/")
if len(parts) < 2 || parts[0] != "hosts" {
return nil
}
nodeID, err := uuid.Parse(parts[1])
if err != nil {
return nil
}
// Reconstruct the remote path
remotePath := "/"
if len(parts) > 2 {
remotePath = "/" + strings.Join(parts[2:], "/")
}
sftpClient, session := fs.getOrCreateRemoteClient(nodeID)
if sftpClient == nil {
return nil
}
return &remoteContext{
client: sftpClient,
session: session,
remotePath: remotePath,
instance: nil, // host path, not an instance
}
}Step 2: Run existing tests to confirm no regression
cd /home/caiolombello/shardlyn
go test ./internal/controlplane/sftpserver/... -v 2>&1 | tail -20Expected: all tests pass (or same as before).
Step 3: Commit
cd /home/caiolombello/shardlyn
git add internal/controlplane/sftpserver/server.go
git commit -m "feat(sftp): add resolveRemoteHost for /hosts/<nodeID>/ virtual paths"Task 2: SFTP Server — wire resolveRemoteHost into file operations
Files:
- Modify:
internal/controlplane/sftpserver/server.go
Step 1: Update Fileread to check hosts
Find Fileread (around line 800) and add host check right after the instance check:
func (fs *fileSystem) Fileread(r *sftp.Request) (io.ReaderAt, error) {
flags := r.Pflags()
if !flags.Read {
return nil, os.ErrInvalid
}
if rc := fs.resolveRemote(r.Filepath); rc != nil {
return rc.client.Open(rc.remotePath)
}
// NEW: host check
if rc := fs.resolveRemoteHost(r.Filepath); rc != nil {
return rc.client.Open(rc.remotePath)
}
path, err := fs.resolvePath(r.Filepath)
// ... rest unchangedStep 2: Update Filewrite to check hosts
In Filewrite (around line 818), add after the existing resolveRemote block:
if rc := fs.resolveRemote(r.Filepath); rc != nil {
// ... existing block unchanged
}
// NEW: host check
if rc := fs.resolveRemoteHost(r.Filepath); rc != nil {
openFlags := os.O_WRONLY
if flags.Read { openFlags = os.O_RDWR }
if flags.Append { openFlags |= os.O_APPEND }
if flags.Creat { openFlags |= os.O_CREATE }
if flags.Trunc { openFlags |= os.O_TRUNC }
if flags.Excl { openFlags |= os.O_EXCL }
return rc.client.OpenFile(rc.remotePath, openFlags)
}Step 3: Update Filecmd to check hosts
In Filecmd (around line 898), add after the resolveRemote block:
if rc := fs.resolveRemote(r.Filepath); rc != nil {
return fs.remoteFilecmd(rc, r)
}
// NEW: host check
if rc := fs.resolveRemoteHost(r.Filepath); rc != nil {
return fs.remoteFilecmd(rc, r)
}Step 4: Update Filelist to check hosts
In Filelist (around line 974), add after the resolveRemote block:
if rc := fs.resolveRemote(r.Filepath); rc != nil {
return fs.remoteFilelist(rc, r)
}
// NEW: host check
if rc := fs.resolveRemoteHost(r.Filepath); rc != nil {
return fs.remoteFilelist(rc, r)
}Step 5: Build to verify no compile errors
cd /home/caiolombello/shardlyn
go build ./... 2>&1Expected: no output (clean build).
Step 6: Run tests
go test ./internal/controlplane/sftpserver/... -v 2>&1 | tail -20Step 7: Commit
git add internal/controlplane/sftpserver/server.go
git commit -m "feat(sftp): wire host path resolution into Fileread/Filewrite/Filecmd/Filelist"Task 3: SFTP Server — add test for resolveRemoteHost
Files:
- Modify:
internal/controlplane/sftpserver/server.go(or a_test.goif one exists) - Check if
internal/controlplane/sftpserver/server_test.goexists first
Step 1: Check for existing test file
ls /home/caiolombello/shardlyn/internal/controlplane/sftpserver/Step 2: Add unit test for resolveRemoteHost path parsing
If no test file exists, create internal/controlplane/sftpserver/host_resolve_test.go:
package sftpserver
import (
"testing"
)
func TestResolveRemoteHost_NilWhenNoTunnel(t *testing.T) {
fs := &fileSystem{server: nil}
if rc := fs.resolveRemoteHost("/hosts/some-id/etc/hosts"); rc != nil {
t.Fatal("expected nil when server is nil")
}
}
func TestResolveRemoteHost_NilForNonHostPath(t *testing.T) {
fs := &fileSystem{server: &Server{}}
if rc := fs.resolveRemoteHost("/instances/some-id/data"); rc != nil {
t.Fatal("expected nil for non-hosts path")
}
}
func TestResolveRemoteHost_NilForInvalidUUID(t *testing.T) {
fs := &fileSystem{server: &Server{}}
if rc := fs.resolveRemoteHost("/hosts/not-a-uuid/etc/hosts"); rc != nil {
t.Fatal("expected nil for invalid UUID")
}
}Step 3: Run tests
cd /home/caiolombello/shardlyn
go test ./internal/controlplane/sftpserver/... -run TestResolveRemoteHost -vExpected: all 3 tests PASS.
Step 4: Commit
git add internal/controlplane/sftpserver/
git commit -m "test(sftp): add unit tests for resolveRemoteHost path parsing"Task 4: Web — SFTP credentials panel in NodeDetail
Files:
- Modify:
web/src/pages/NodeDetail.tsx
Context: InstanceDetail.tsx has an SFTP panel starting around line 1630. The NodeDetail.tsx already has a files tab with <FileBrowser nodeId={id!} />. We add an SFTP credentials block below the FileBrowser.
Step 1: Add SFTP state/vars near the top of the NodeDetail component
Find where const sftpHost would go — look for where other constants are defined near the component top (after the id param). Add:
const sftpPort = 2022
const sftpUser = user?.email ?? ''
const sftpPathPrefix = `/hosts/${id}/`For sftpHost, look at how InstanceDetail builds it (uses window.location.hostname). Add the same logic:
const sftpHost = (() => {
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return hostname
}
return hostname
})()Step 2: Add SFTP panel in the files tab
Find the files tab render (around line 449):
{activeTab === 'files' && (
<FileBrowser nodeId={id!} />
)}Replace with:
{activeTab === 'files' && (
<div className="space-y-6">
<FileBrowser nodeId={id!} />
<div className="card p-4 space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<KeyRound className="w-4 h-4" />
{t('nodeDetail.files.sftp.title', { defaultValue: 'SFTP Access' })}
</h3>
<p className="text-muted text-sm">
{t('nodeDetail.files.sftp.description', {
defaultValue: 'Connect with any SFTP client to browse this host\'s filesystem directly.',
})}
</p>
<div className="grid grid-cols-2 gap-2 text-sm font-mono">
<div className="text-muted">{t('nodeDetail.files.sftp.host', { defaultValue: 'Host' })}</div>
<div className="font-mono text-[color:var(--text)]">{sftpHost}</div>
<div className="text-muted">{t('nodeDetail.files.sftp.port', { defaultValue: 'Port' })}</div>
<div className="font-mono text-[color:var(--text)]">{sftpPort}</div>
<div className="text-muted">{t('nodeDetail.files.sftp.user', { defaultValue: 'User' })}</div>
<div className="font-mono text-[color:var(--text)]">{sftpUser}</div>
<div className="text-muted">{t('nodeDetail.files.sftp.path', { defaultValue: 'Path prefix' })}</div>
<div className="font-mono text-[color:var(--text)]">{sftpPathPrefix}</div>
</div>
<code className="block text-xs bg-surface-2 rounded p-2 text-muted">
{sftpUser.includes('@')
? `sftp -P ${sftpPort} -o User="${sftpUser}" ${sftpHost}`
: `sftp -P ${sftpPort} ${sftpUser}@${sftpHost}`}
</code>
</div>
</div>
)}Step 3: Add KeyRound to the lucide-react import in NodeDetail.tsx
Find the lucide import block and add KeyRound if not already present.
Step 4: Typecheck
cd /home/caiolombello/shardlyn/web
npm run typecheck 2>&1Expected: no errors.
Step 5: Commit
cd /home/caiolombello/shardlyn
git add web/src/pages/NodeDetail.tsx
git commit -m "feat(web): add SFTP credentials panel to node files tab"Task 5: Android — Node file API endpoints in ShardlynApiService
Files:
- Modify:
/mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/data/api/ShardlynApiService.kt
Step 1: Add node file endpoints
After the instance file endpoints (after downloadInstanceFile), add:
// Node (host) files — mirrors instance file endpoints at /v1/nodes/{id}/files*
@GET("v1/nodes/{id}/files")
suspend fun getNodeFiles(
@Path("id") id: String,
@Query("path") path: String,
): Response<List<InstanceFileEntryDto>>
@GET("v1/nodes/{id}/files/content")
suspend fun getNodeFileContent(
@Path("id") id: String,
@Query("path") path: String,
): Response<InstanceFileContentDto>
@PUT("v1/nodes/{id}/files/content")
suspend fun saveNodeFileContent(
@Path("id") id: String,
@Body request: SaveInstanceFileContentRequestDto,
): Response<Map<String, String>>
@DELETE("v1/nodes/{id}/files")
suspend fun deleteNodeFile(
@Path("id") id: String,
@Query("path") path: String,
): Response<Unit>
@POST("v1/nodes/{id}/files/mkdir")
suspend fun createNodeDirectory(
@Path("id") id: String,
@Body request: CreateInstanceDirectoryRequestDto,
): Response<StatusPathResponseDto>
@POST("v1/nodes/{id}/files/move")
suspend fun moveNodeFile(
@Path("id") id: String,
@Body request: MoveInstanceFileRequestDto,
): Response<Map<String, String>>
@Multipart
@POST("v1/nodes/{id}/files/upload")
suspend fun uploadNodeFile(
@Path("id") id: String,
@Part file: MultipartBody.Part,
@Part("path") path: RequestBody,
): Response<StatusPathResponseDto>
@Streaming
@GET("v1/nodes/{id}/files/download")
suspend fun downloadNodeFile(
@Path("id") id: String,
@Query("path") path: String,
): Response<ResponseBody>Step 2: Build to verify no compile errors
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30Expected: BUILD SUCCESSFUL or only pre-existing warnings.
Step 3: Commit
cd /mnt/c/Users/caio/shardlyn-terminal
git add app/src/main/java/com/shardlyn/terminal/data/api/ShardlynApiService.kt
git commit -m "feat(terminal): add node host file API endpoints to ShardlynApiService"Task 6: Android — Node file methods in ShardlynRepository
Files:
- Modify:
/mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/data/repository/ShardlynRepository.kt
Step 1: Add interface methods
In ShardlynRepository interface, after downloadInstanceFile, add:
// Node (host) file operations
suspend fun getNodeFiles(nodeId: String, path: String): Result<List<InstanceFileEntryDto>>
suspend fun getNodeFileContent(nodeId: String, path: String): Result<InstanceFileContentDto>
suspend fun saveNodeFileContent(nodeId: String, path: String, content: String): Result<Unit>
suspend fun deleteNodeFile(nodeId: String, path: String): Result<Unit>
suspend fun createNodeDirectory(nodeId: String, path: String): Result<Unit>
suspend fun moveNodeFile(nodeId: String, source: String, destination: String): Result<Unit>
suspend fun uploadNodeFile(nodeId: String, destinationPath: String, file: File, mimeType: String?): Result<Unit>
suspend fun downloadNodeFile(nodeId: String, path: String): Result<ByteArray>Step 2: Implement in ShardlynRepositoryImpl
After downloadInstanceFile implementation, add:
override suspend fun getNodeFiles(nodeId: String, path: String): Result<List<InstanceFileEntryDto>> = runCatching {
val response = api.getNodeFiles(nodeId, path)
if (response.isSuccessful) response.body().orEmpty() else error("HTTP ${response.code()}")
}
override suspend fun getNodeFileContent(nodeId: String, path: String): Result<InstanceFileContentDto> = runCatching {
val response = api.getNodeFileContent(nodeId, path)
if (response.isSuccessful) response.body()!! else error("HTTP ${response.code()}")
}
override suspend fun saveNodeFileContent(nodeId: String, path: String, content: String): Result<Unit> = runCatching {
val response = api.saveNodeFileContent(nodeId, SaveInstanceFileContentRequestDto(path = path, content = content))
if (!response.isSuccessful) error("HTTP ${response.code()}")
}
override suspend fun deleteNodeFile(nodeId: String, path: String): Result<Unit> = runCatching {
val response = api.deleteNodeFile(nodeId, path)
if (!response.isSuccessful) error("HTTP ${response.code()}")
}
override suspend fun createNodeDirectory(nodeId: String, path: String): Result<Unit> = runCatching {
val response = api.createNodeDirectory(nodeId, CreateInstanceDirectoryRequestDto(path = path))
if (!response.isSuccessful) error("HTTP ${response.code()}")
}
override suspend fun moveNodeFile(nodeId: String, source: String, destination: String): Result<Unit> = runCatching {
val response = api.moveNodeFile(nodeId, MoveInstanceFileRequestDto(source = source, destination = destination))
if (!response.isSuccessful) error("HTTP ${response.code()}")
}
override suspend fun uploadNodeFile(nodeId: String, destinationPath: String, file: File, mimeType: String?): Result<Unit> = runCatching {
val mediaType = mimeType?.toMediaTypeOrNull() ?: "application/octet-stream".toMediaTypeOrNull()
val requestBody = file.asRequestBody(mediaType)
val filePart = MultipartBody.Part.createFormData("file", file.name, requestBody)
val pathPart = destinationPath.toRequestBody("text/plain".toMediaTypeOrNull())
val response = api.uploadNodeFile(nodeId, filePart, pathPart)
if (!response.isSuccessful) error("HTTP ${response.code()}")
}
override suspend fun downloadNodeFile(nodeId: String, path: String): Result<ByteArray> = runCatching {
val response = api.downloadNodeFile(nodeId, path)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()!!.bytes()
}Step 3: Build
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30Expected: BUILD SUCCESSFUL.
Step 4: Commit
git add app/src/main/java/com/shardlyn/terminal/data/repository/ShardlynRepository.kt
git commit -m "feat(terminal): add node file operations to ShardlynRepository"Task 7: Android — NodeFilesViewModel
Files:
- Create:
/mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/shardlyn/NodeFilesViewModel.kt
Step 1: Create the ViewModel
This is a simplified version of InstanceFilesViewModel — no volume roots, starts at /:
package com.shardlyn.terminal.ui.shardlyn
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shardlyn.terminal.data.api.dto.InstanceFileEntryDto
import com.shardlyn.terminal.data.repository.ShardlynRepository
import com.shardlyn.terminal.ui.sftp.RemoteFileEditorState
import com.shardlyn.terminal.ui.sftp.RemoteFileLanguage
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class NodeFilesUiState(
val title: String = "Host Files",
val currentPath: String? = null,
val entries: List<InstanceFileEntryDto> = emptyList(),
val isLoading: Boolean = true,
val activeOperationLabel: String? = null,
val error: String? = null,
val message: String? = null,
val editorState: RemoteFileEditorState? = null,
)
@HiltViewModel
class NodeFilesViewModel @Inject constructor(
private val repository: ShardlynRepository,
@ApplicationContext private val context: Context,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val nodeId: String = savedStateHandle["nodeId"] ?: ""
private val nodeNameArg: String = savedStateHandle["nodeName"] ?: "Host"
private val _state = MutableStateFlow(NodeFilesUiState(title = nodeNameArg))
val state: StateFlow<NodeFilesUiState> = _state.asStateFlow()
init {
refresh("/")
}
fun retry() = refresh(_state.value.currentPath ?: "/")
fun consumeMessage() {
_state.value = _state.value.copy(message = null)
}
fun refresh(targetPath: String? = _state.value.currentPath ?: "/") {
val path = targetPath ?: "/"
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
repository.getNodeFiles(nodeId, path)
.onSuccess { entries ->
_state.value = _state.value.copy(
currentPath = path,
entries = sortEntries(entries),
isLoading = false,
)
}
.onFailure { error ->
_state.value = _state.value.copy(
isLoading = false,
error = error.message ?: "Failed to load files",
entries = emptyList(),
)
}
}
}
fun openParentDirectory() {
val currentPath = _state.value.currentPath ?: return
if (currentPath == "/") return
val parent = parentPath(currentPath) ?: "/"
refresh(parent)
}
fun openEntry(entry: InstanceFileEntryDto) {
if (entry.isDir) refresh(entry.path) else openRemoteFile(entry)
}
fun createDirectory(name: String) {
val currentPath = _state.value.currentPath ?: "/"
val normalizedName = name.trim()
if (normalizedName.isEmpty()) {
_state.value = _state.value.copy(message = "Folder name cannot be empty")
return
}
runMutation("Creating folder") {
repository.createNodeDirectory(nodeId, joinPath(currentPath, normalizedName)).getOrThrow()
refresh(currentPath)
_state.value = _state.value.copy(message = "Created $normalizedName")
}
}
fun renameEntry(entry: InstanceFileEntryDto, newName: String) {
val normalizedName = newName.trim()
if (normalizedName.isEmpty()) {
_state.value = _state.value.copy(message = "Name cannot be empty")
return
}
val targetPath = joinPath(parentPath(entry.path) ?: "/", normalizedName)
runMutation("Renaming") {
repository.moveNodeFile(nodeId, entry.path, targetPath).getOrThrow()
refresh(_state.value.currentPath)
_state.value = _state.value.copy(message = "Renamed to $normalizedName")
}
}
fun deleteEntry(entry: InstanceFileEntryDto) {
runMutation("Deleting") {
repository.deleteNodeFile(nodeId, entry.path).getOrThrow()
refresh(_state.value.currentPath)
_state.value = _state.value.copy(message = "Deleted ${entry.name}")
}
}
fun uploadFromUri(uri: Uri) {
val currentPath = _state.value.currentPath ?: "/"
runMutation("Uploading") {
val displayName = queryDisplayName(uri) ?: "upload-${System.currentTimeMillis()}"
val mimeType = context.contentResolver.getType(uri)
val tempFile = copyUriToTempFile(uri, displayName)
try {
repository.uploadNodeFile(nodeId, joinPath(currentPath, displayName), tempFile, mimeType).getOrThrow()
} finally {
tempFile.delete()
}
refresh(currentPath)
_state.value = _state.value.copy(message = "Uploaded $displayName")
}
}
fun downloadToUri(entry: InstanceFileEntryDto, destinationUri: Uri) {
if (entry.isDir) {
_state.value = _state.value.copy(message = "Directory download is not supported")
return
}
runMutation("Downloading") {
val bytes = repository.downloadNodeFile(nodeId, entry.path).getOrThrow()
writeBytesToUri(bytes, destinationUri)
_state.value = _state.value.copy(message = "Downloaded ${entry.name}")
}
}
fun updateEditorValue(value: TextFieldValue) {
val current = _state.value.editorState ?: return
_state.value = _state.value.copy(editorState = recalculateSearchState(current.copy(value = value)))
}
fun updateEditorSearchQuery(query: String) {
val current = _state.value.editorState ?: return
_state.value = _state.value.copy(editorState = recalculateSearchState(current.copy(searchQuery = query)))
}
fun jumpToNextSearchMatch() = selectSearchMatch(1)
fun jumpToPreviousSearchMatch() = selectSearchMatch(-1)
fun closeEditor(force: Boolean = false) {
val current = _state.value.editorState ?: return
if (!force && current.hasUnsavedChanges) return
_state.value = _state.value.copy(editorState = null)
}
fun saveEditor() {
val current = _state.value.editorState ?: return
viewModelScope.launch {
_state.value = _state.value.copy(editorState = current.copy(isSaving = true))
repository.saveNodeFileContent(nodeId, current.path, current.value.text)
.onSuccess {
val updated = recalculateSearchState(current.copy(originalText = current.value.text, isSaving = false))
_state.value = _state.value.copy(editorState = updated, message = "Saved ${current.name}")
refresh(_state.value.currentPath)
}
.onFailure { error ->
_state.value = _state.value.copy(
editorState = current.copy(isSaving = false),
message = error.message ?: "Failed to save file",
)
}
}
}
private fun openRemoteFile(entry: InstanceFileEntryDto) {
viewModelScope.launch {
_state.value = _state.value.copy(
editorState = RemoteFileEditorState(
path = entry.path, name = entry.name,
language = detectLanguage(entry.name),
value = TextFieldValue(""), originalText = "",
permissions = null, isLoading = true,
),
)
repository.getNodeFileContent(nodeId, entry.path)
.onSuccess { content ->
_state.value = _state.value.copy(
editorState = recalculateSearchState(
RemoteFileEditorState(
path = entry.path, name = entry.name,
language = detectLanguage(entry.name),
value = TextFieldValue(content.content),
originalText = content.content,
permissions = null,
),
),
)
}
.onFailure { error ->
_state.value = _state.value.copy(editorState = null, message = error.message ?: "Failed to open file")
}
}
}
private fun runMutation(label: String, block: suspend () -> Unit) {
viewModelScope.launch {
_state.value = _state.value.copy(activeOperationLabel = label)
try {
block()
} catch (error: Throwable) {
_state.value = _state.value.copy(message = error.message ?: "$label failed")
} finally {
_state.value = _state.value.copy(activeOperationLabel = null)
}
}
}
private fun sortEntries(entries: List<InstanceFileEntryDto>): List<InstanceFileEntryDto> =
entries.sortedWith(compareBy<InstanceFileEntryDto>({ !it.isDir }, { it.name.lowercase() }))
private fun parentPath(path: String): String? {
val normalized = if (path.startsWith("/")) path else "/$path"
if (normalized == "/") return null
val last = normalized.lastIndexOf('/')
return if (last <= 0) "/" else normalized.substring(0, last)
}
private fun joinPath(dir: String, child: String): String {
val d = dir.trimEnd('/')
val c = child.trim().trim('/')
return if (d.isEmpty()) "/$c" else "$d/$c"
}
private suspend fun queryDisplayName(uri: Uri): String? = withContext(Dispatchers.IO) {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
val col = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (col >= 0 && cursor.moveToFirst()) cursor.getString(col) else null
}
}
private suspend fun copyUriToTempFile(uri: Uri, displayName: String): File = withContext(Dispatchers.IO) {
val safeName = displayName.ifBlank { "file" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
val tempFile = File.createTempFile("node-files-upload-", "-$safeName", context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output -> input.copyTo(output) }
} ?: throw IOException("Unable to open selected file")
tempFile
}
private suspend fun writeBytesToUri(bytes: ByteArray, uri: Uri) = withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
output.write(bytes)
output.flush()
} ?: throw IOException("Unable to write to destination")
}
private fun selectSearchMatch(step: Int) {
val current = _state.value.editorState ?: return
if (current.searchMatches.isEmpty()) return
val nextIndex = when {
current.currentMatchIndex < 0 -> if (step >= 0) 0 else current.searchMatches.lastIndex
else -> (current.currentMatchIndex + step).mod(current.searchMatches.size)
}
_state.value = _state.value.copy(editorState = current.withSelectedMatch(nextIndex))
}
private fun recalculateSearchState(state: RemoteFileEditorState): RemoteFileEditorState {
val query = state.searchQuery.trim()
if (query.isEmpty()) return state.copy(searchMatches = emptyList(), currentMatchIndex = -1)
val matches = findMatches(state.value.text, query)
if (matches.isEmpty()) return state.copy(searchMatches = emptyList(), currentMatchIndex = -1)
val preferredIndex = state.currentMatchIndex.takeIf { it in matches.indices }
?: matches.indexOfFirst { it.first >= state.value.selection.min }.takeIf { it >= 0 } ?: 0
return state.withSelectedMatch(preferredIndex, matches)
}
private fun RemoteFileEditorState.withSelectedMatch(index: Int, matches: List<IntRange> = searchMatches): RemoteFileEditorState {
val match = matches.getOrNull(index) ?: return copy(searchMatches = matches, currentMatchIndex = -1)
return copy(value = value.copy(selection = TextRange(match.first, match.last + 1)), searchMatches = matches, currentMatchIndex = index)
}
private fun findMatches(text: String, query: String): List<IntRange> {
if (query.isEmpty()) return emptyList()
val matches = mutableListOf<IntRange>()
var startIndex = 0
while (startIndex < text.length) {
val foundAt = text.indexOf(query, startIndex, ignoreCase = true)
if (foundAt < 0) break
matches += foundAt until (foundAt + query.length)
startIndex = foundAt + query.length
}
return matches
}
private fun detectLanguage(fileName: String): RemoteFileLanguage {
val normalized = fileName.lowercase()
return when {
normalized.endsWith(".yaml") || normalized.endsWith(".yml") -> RemoteFileLanguage.Yaml
normalized.endsWith(".json") -> RemoteFileLanguage.Json
normalized.endsWith(".conf") || normalized.endsWith(".cfg") || normalized.endsWith(".ini") -> RemoteFileLanguage.Conf
normalized.endsWith(".sh") || normalized.endsWith(".bash") || normalized.endsWith(".zsh") ||
normalized == ".bashrc" || normalized == ".zshrc" || normalized == ".profile" -> RemoteFileLanguage.Shell
else -> RemoteFileLanguage.PlainText
}
}
}Step 2: Build
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30Expected: BUILD SUCCESSFUL.
Step 3: Commit
git add app/src/main/java/com/shardlyn/terminal/ui/shardlyn/NodeFilesViewModel.kt
git commit -m "feat(terminal): add NodeFilesViewModel for host file browsing"Task 8: Android — NodeFilesScreen
Files:
- Create:
/mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/screen/NodeFilesScreen.kt
Step 1: Create the screen
This is InstanceFilesScreen with the RootSelector removed (hosts start at /, no volume concept):
package com.shardlyn.terminal.ui.screen
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.shardlyn.terminal.data.api.dto.InstanceFileEntryDto
import com.shardlyn.terminal.ui.sftp.RemoteFileEditor
import com.shardlyn.terminal.ui.shardlyn.NodeFilesViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeFilesScreen(
onBack: () -> Unit,
viewModel: NodeFilesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var createFolderDialogOpen by rememberSaveable { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<InstanceFileEntryDto?>(null) }
var deleteTarget by remember { mutableStateOf<InstanceFileEntryDto?>(null) }
var downloadTarget by remember { mutableStateOf<InstanceFileEntryDto?>(null) }
val uploadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) viewModel.uploadFromUri(uri)
}
val downloadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri: Uri? ->
val target = downloadTarget
if (uri != null && target != null) viewModel.downloadToUri(target, uri)
downloadTarget = null
}
LaunchedEffect(state.message) {
val message = state.message ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
viewModel.consumeMessage()
}
state.editorState?.let { editorState ->
RemoteFileEditor(
editorState = editorState,
snackbarHostState = snackbarHostState,
onValueChange = viewModel::updateEditorValue,
onSearchQueryChange = viewModel::updateEditorSearchQuery,
onPreviousMatch = viewModel::jumpToPreviousSearchMatch,
onNextMatch = viewModel::jumpToNextSearchMatch,
onSave = viewModel::saveEditor,
onCloseConfirmed = { viewModel.closeEditor(force = true) },
)
return
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("Host Files")
Text(
state.title,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(
onClick = { uploadLauncher.launch(arrayOf("*/*")) },
enabled = state.currentPath != null && state.activeOperationLabel == null,
) {
Icon(Icons.Default.Upload, contentDescription = "Upload")
}
IconButton(
onClick = { createFolderDialogOpen = true },
enabled = state.currentPath != null && state.activeOperationLabel == null,
) {
Icon(Icons.Default.CreateNewFolder, contentDescription = "Create folder")
}
IconButton(
onClick = { viewModel.refresh() },
enabled = state.activeOperationLabel == null,
) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
)
},
floatingActionButton = {
if (state.currentPath != null && state.activeOperationLabel == null) {
FloatingActionButton(onClick = { uploadLauncher.launch(arrayOf("*/*")) }) {
Icon(Icons.Default.Upload, contentDescription = "Upload")
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
NodePathBar(
currentPath = state.currentPath,
canGoBack = state.currentPath != null && state.currentPath != "/",
onNavigate = viewModel::refresh,
onGoBack = viewModel::openParentDirectory,
)
state.activeOperationLabel?.let { label ->
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
when {
state.isLoading -> NodeFullscreenState("Loading files", "Reading host filesystem...", true)
state.error != null -> NodeErrorState(title = "Files unavailable", body = state.error ?: "Unknown error", onRetry = viewModel::retry)
state.entries.isEmpty() -> NodeFullscreenState(title = state.currentPath ?: "Empty directory", body = "No files found.")
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.entries, key = { it.path }) { entry ->
NodeFileEntryCard(
entry = entry,
onClick = { viewModel.openEntry(entry) },
onRename = { renameTarget = entry },
onDelete = { deleteTarget = entry },
onDownload = {
downloadTarget = entry
downloadLauncher.launch(entry.name)
},
)
}
}
}
}
}
}
if (createFolderDialogOpen) {
NodeNameInputDialog(
title = "New folder",
label = "Folder name",
onDismiss = { createFolderDialogOpen = false },
onConfirm = { name ->
createFolderDialogOpen = false
viewModel.createDirectory(name)
},
)
}
renameTarget?.let { entry ->
NodeNameInputDialog(
title = "Rename",
label = "New name",
initialValue = entry.name,
onDismiss = { renameTarget = null },
onConfirm = { newName ->
renameTarget = null
viewModel.renameEntry(entry, newName)
},
)
}
deleteTarget?.let { entry ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text("Delete ${entry.name}?") },
text = { Text("This will delete the file directly from the host filesystem.") },
confirmButton = {
TextButton(onClick = { deleteTarget = null; viewModel.deleteEntry(entry) }) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) { Text("Cancel") }
},
)
}
}
@Composable
private fun NodePathBar(currentPath: String?, canGoBack: Boolean, onNavigate: (String) -> Unit, onGoBack: () -> Unit) {
val breadcrumbs = remember(currentPath) { buildNodeBreadcrumbs(currentPath) }
LazyRow(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (canGoBack) {
item {
AssistChip(
onClick = onGoBack,
label = { Text("..") },
leadingIcon = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, modifier = Modifier.size(16.dp)) },
)
}
}
items(breadcrumbs, key = { it.first }) { (path, label) ->
AssistChip(onClick = { onNavigate(path) }, label = { Text(label) })
}
}
}
@Composable
private fun NodeFileEntryCard(entry: InstanceFileEntryDto, onClick: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, onDownload: () -> Unit) {
var menuExpanded by rememberSaveable(entry.path) { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = if (entry.isDir) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = if (entry.isDir) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = entry.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
text = buildNodeSubtitle(entry),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Box {
IconButton(onClick = { menuExpanded = true }) { Icon(Icons.Default.MoreVert, contentDescription = "More") }
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
if (!entry.isDir) {
DropdownMenuItem(
text = { Text("Download") },
onClick = { menuExpanded = false; onDownload() },
leadingIcon = { Icon(Icons.Default.Download, contentDescription = null) },
)
}
DropdownMenuItem(text = { Text("Rename") }, onClick = { menuExpanded = false; onRename() }, leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) })
DropdownMenuItem(text = { Text("Delete") }, onClick = { menuExpanded = false; onDelete() }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) })
}
}
}
}
}
@Composable
private fun NodeFullscreenState(title: String, body: String, loading: Boolean = false) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (loading) CircularProgressIndicator()
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun NodeErrorState(title: String, body: String, onRetry: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onRetry) { Text("Retry") }
}
}
}
@Composable
private fun NodeNameInputDialog(title: String, label: String, initialValue: String = "", onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var value by rememberSaveable(title, initialValue) { mutableStateOf(initialValue) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(label) }, singleLine = true) },
confirmButton = { TextButton(onClick = { onConfirm(value) }) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun buildNodeBreadcrumbs(currentPath: String?): List<Pair<String, String>> {
if (currentPath.isNullOrBlank() || currentPath == "/") return listOf("/" to "/")
val segments = currentPath.trim('/').split('/').filter { it.isNotBlank() }
val breadcrumbs = mutableListOf("/" to "/")
var running = ""
segments.forEach { segment ->
running += "/$segment"
breadcrumbs += running to segment
}
return breadcrumbs
}
private fun buildNodeSubtitle(entry: InstanceFileEntryDto): String {
val details = mutableListOf<String>()
if (!entry.isDir) details += formatNodeBytes(entry.size)
if (entry.modifiedAt.isNotBlank()) details += entry.modifiedAt
return details.joinToString(" | ").ifBlank { entry.path }
}
private fun formatNodeBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = listOf("KB", "MB", "GB", "TB")
var value = bytes.toDouble()
var index = -1
while (value >= 1024 && index < units.lastIndex) { value /= 1024; index++ }
return String.format("%.1f %s", value, units[index])
}Step 2: Build
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30Expected: BUILD SUCCESSFUL.
Step 3: Commit
git add app/src/main/java/com/shardlyn/terminal/ui/screen/NodeFilesScreen.kt
git commit -m "feat(terminal): add NodeFilesScreen for host filesystem browsing"Task 9: Android — Navigation wiring for NodeFilesScreen
Files:
- Modify:
/mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/navigation/AppDestination.kt - Modify:
/mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/navigation/AppNavigation.kt - Modify:
/mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/screen/ShardlynScreen.kt
Step 1: Add NodeFiles destination in AppDestination.kt
After the InstanceFiles object, add:
/** Node host files browser backed by Shardlyn node file APIs. */
data object NodeFiles : AppDestination("node_files/{nodeId}/{nodeName}") {
const val ROUTE_PATTERN = "node_files/{nodeId}/{nodeName}"
fun route(nodeId: String, nodeName: String): String =
"node_files/${Uri.encode(nodeId)}/${Uri.encode(nodeName)}"
}Step 2: Add composable in AppNavigation.kt
Add import at top:
import com.shardlyn.terminal.ui.screen.NodeFilesScreenAdd the composable after the InstanceFiles composable (around line 202):
composable(
route = AppDestination.NodeFiles.ROUTE_PATTERN,
arguments = listOf(
navArgument("nodeId") { type = NavType.StringType },
navArgument("nodeName") { type = NavType.StringType },
),
) {
NodeFilesScreen(onBack = { navController.popBackStack() })
}Add onOpenNodeFiles callback in the ShardlynScreen composable call (around line 111):
onOpenNodeFiles = { nodeId, nodeName ->
navController.navigate(
AppDestination.NodeFiles.route(nodeId, nodeName),
) { launchSingleTop = true }
},Step 3: Add onOpenNodeFiles callback to ShardlynScreen
In ShardlynScreen.kt, find the ShardlynScreen composable parameters (around line 79) and add:
onOpenNodeFiles: (nodeId: String, nodeName: String) -> Unit = { _, _ -> },Pass it through to the inner composable that renders NodeCard. Find where NodeCard is called (around line 216) and the inner composable signature. Look for where onConnect is passed and add:
onOpenNodeFiles = { onOpenNodeFiles(nodeWithInstances.node.id, nodeWithInstances.node.name) },Update NodeCard signature to add:
onOpenNodeFiles: () -> Unit = {},Add a "Files" button in NodeCard next to the terminal connect button — find where onConnect is called (around line 358, near "Open node terminals" text) and add a files button:
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// ... existing connect button ...
OutlinedButton(onClick = onOpenNodeFiles) {
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Files")
}
}Step 4: Build
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30Expected: BUILD SUCCESSFUL.
Step 5: Full build
./gradlew :app:assembleDebug 2>&1 | tail -20Expected: BUILD SUCCESSFUL with APK output path shown.
Step 6: Commit
git add app/src/main/java/com/shardlyn/terminal/ui/navigation/AppDestination.kt \
app/src/main/java/com/shardlyn/terminal/ui/navigation/AppNavigation.kt \
app/src/main/java/com/shardlyn/terminal/ui/screen/ShardlynScreen.kt
git commit -m "feat(terminal): wire NodeFilesScreen into navigation and ShardlynScreen"Task 10: Final validation
Step 1: Backend — full Go build + tests
cd /home/caiolombello/shardlyn
go build ./...
go test ./internal/controlplane/sftpserver/... -vStep 2: Web — typecheck + lint
cd /home/caiolombello/shardlyn/web
npm run typecheck && npm run lintExpected: no errors, no warnings.
Step 3: Android — full debug build
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:assembleDebug 2>&1 | tail -10Expected: BUILD SUCCESSFUL.
Step 4: Commit plan doc
cd /home/caiolombello/shardlyn
git add docs/plans/2026-03-13-sftp-host-files.md
git commit -m "docs: add SFTP host files implementation plan"