feat(data): store ssh workspaces on app_settings

This commit is contained in:
mika kuns
2026-10-02 14:21:21 +02:00
parent f7625baa24
commit 0314319872
8 changed files with 1345 additions and 0 deletions
@@ -43,6 +43,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.RepoImportFolders)
.HasColumnName("repo_import_folders");
builder.Property(s => s.SshWorkspaces)
.HasColumnName("ssh_workspaces");
builder.Property(s => s.ReportExcludedPaths).HasColumnName("report_excluded_paths");
builder.Property(s => s.StandupWeekday).HasColumnName("standup_weekday")
.IsRequired().HasDefaultValue((int)DayOfWeek.Wednesday);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddSshWorkspaces : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ssh_workspaces",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "ssh_workspaces",
value: null);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ssh_workspaces",
table: "app_settings");
}
}
}
@@ -126,6 +126,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SshWorkspaces")
.HasColumnType("TEXT")
.HasColumnName("ssh_workspaces");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -29,6 +29,8 @@ public sealed class AppSettingsEntity
// JSON array of parent folders remembered by the repo-import modal.
public string? RepoImportFolders { get; set; }
// JSON array of SshWorkspace rows offered as remote quick sessions in Mission Control.
public string? SshWorkspaces { get; set; }
// JSON array of path prefixes whose sessions are excluded from the weekly report.
public string? ReportExcludedPaths { get; set; }
// DayOfWeek the standup happens on; default Wednesday. Drives the report's default range.
+29
View File
@@ -0,0 +1,29 @@
using System.Text.RegularExpressions;
namespace ClaudeDo.Data.Models;
/// <summary>A server an interactive claude session can be started on via ssh; connection details live in the user's ssh config.</summary>
public sealed record SshWorkspace(string Id, string Name, string Host, string RemotePath)
{
// A leading '-' would make ssh parse the alias as an option (-oProxyCommand=... runs local code).
private static readonly Regex HostPattern = new(@"^[A-Za-z0-9._@][A-Za-z0-9._@-]*$", RegexOptions.Compiled);
/// <summary>Returns the first invalid field ("name", "host", "path") or null when valid.</summary>
public static string? Validate(SshWorkspace w)
{
if (string.IsNullOrWhiteSpace(w.Name)) return "name";
if (string.IsNullOrEmpty(w.Host) || !HostPattern.IsMatch(w.Host)) return "host";
var p = w.RemotePath ?? "";
if (!(p.StartsWith('/') || p.StartsWith('~')) || p.Contains('\'') || p.Contains('\n') || p.Contains('\r'))
return "path";
return null;
}
/// <summary>Single-quotes a validated remote path for a POSIX shell, leaving a leading <c>~</c> unquoted so it still expands.</summary>
public static string QuoteRemotePath(string path)
{
if (path == "~") return "~";
if (path.StartsWith("~/")) return "~/'" + path[2..] + "'";
return "'" + path + "'";
}
}
@@ -128,6 +128,28 @@ public sealed class AppSettingsRepository
await _context.SaveChangesAsync(ct);
}
/// <summary>Reads the configured SSH workspaces; missing or corrupt JSON yields an empty list.</summary>
public async Task<List<SshWorkspace>> GetSshWorkspacesAsync(CancellationToken ct = default)
{
var json = await _context.AppSettings.AsNoTracking()
.Where(s => s.Id == AppSettingsEntity.SingletonId)
.Select(s => s.SshWorkspaces)
.FirstOrDefaultAsync(ct);
if (string.IsNullOrWhiteSpace(json)) return new List<SshWorkspace>();
try { return JsonSerializer.Deserialize<List<SshWorkspace>>(json) ?? new List<SshWorkspace>(); }
catch (JsonException) { return new List<SshWorkspace>(); }
}
/// <summary>Writes only the SSH workspaces column; an empty list clears it.</summary>
public async Task SetSshWorkspacesAsync(IEnumerable<SshWorkspace> workspaces, CancellationToken ct = default)
{
var list = workspaces.ToList();
var row = await GetOrCreateTrackedRowAsync(ct);
row.SshWorkspaces = list.Count == 0 ? null : JsonSerializer.Serialize(list);
await _context.SaveChangesAsync(ct);
}
/// <summary>Writes only the ticket fallback list; blank clears it.</summary>
public async Task SetTicketFallbackListIdAsync(string? listId, CancellationToken ct = default)
{