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) builder.Property(s => s.RepoImportFolders)
.HasColumnName("repo_import_folders"); .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.ReportExcludedPaths).HasColumnName("report_excluded_paths");
builder.Property(s => s.StandupWeekday).HasColumnName("standup_weekday") builder.Property(s => s.StandupWeekday).HasColumnName("standup_weekday")
.IsRequired().HasDefaultValue((int)DayOfWeek.Wednesday); .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") .HasColumnType("TEXT")
.HasColumnName("session_skills"); .HasColumnName("session_skills");
b.Property<string>("SshWorkspaces")
.HasColumnType("TEXT")
.HasColumnName("ssh_workspaces");
b.Property<int>("StandupWeekday") b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -29,6 +29,8 @@ public sealed class AppSettingsEntity
// JSON array of parent folders remembered by the repo-import modal. // JSON array of parent folders remembered by the repo-import modal.
public string? RepoImportFolders { get; set; } 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. // JSON array of path prefixes whose sessions are excluded from the weekly report.
public string? ReportExcludedPaths { get; set; } public string? ReportExcludedPaths { get; set; }
// DayOfWeek the standup happens on; default Wednesday. Drives the report's default range. // 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); 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> /// <summary>Writes only the ticket fallback list; blank clears it.</summary>
public async Task SetTicketFallbackListIdAsync(string? listId, CancellationToken ct = default) public async Task SetTicketFallbackListIdAsync(string? listId, CancellationToken ct = default)
{ {
@@ -0,0 +1,74 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Tests;
public sealed class SshWorkspaceTests : IDisposable
{
private readonly string _dbPath;
private readonly DbContextOptions<ClaudeDoDbContext> _options;
public SshWorkspaceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ssh_{Guid.NewGuid():N}.db");
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>().UseSqlite($"Data Source={_dbPath}").Options;
using var ctx = new ClaudeDoDbContext(_options);
ctx.Database.EnsureCreated();
}
public void Dispose()
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
try { File.Delete(_dbPath + suffix); } catch { }
}
[Theory]
[InlineData("VPS", "vps", "/home/mika/app", null)]
[InlineData("VPS", "mika@vps.example.com", "~/app", null)]
[InlineData("VPS", "vps", "~", null)]
[InlineData("", "vps", "/x", "name")]
[InlineData("VPS", "", "/x", "host")]
[InlineData("VPS", "-oProxyCommand=calc", "/x", "host")]
[InlineData("VPS", "vps host", "/x", "host")]
[InlineData("VPS", "vps", "relative/path", "path")]
[InlineData("VPS", "vps", "/it's", "path")]
[InlineData("VPS", "vps", "/a\nb", "path")]
public void Validate_ReturnsFieldOfFirstProblem(string name, string host, string path, string? expected)
=> Assert.Equal(expected, SshWorkspace.Validate(new SshWorkspace("id", name, host, path)));
[Theory]
[InlineData("/home/mika/app", "'/home/mika/app'")]
[InlineData("~", "~")]
[InlineData("~/app dir", "~/'app dir'")]
public void QuoteRemotePath_KeepsTildeExpandable(string path, string expected)
=> Assert.Equal(expected, SshWorkspace.QuoteRemotePath(path));
[Fact]
public async Task SetThenGet_RoundTrips_AndSurvivesGeneralSettingsUpdate()
{
var ws = new SshWorkspace("w1", "VPS", "vps", "/srv/app");
await using (var ctx = new ClaudeDoDbContext(_options))
await new AppSettingsRepository(ctx).SetSshWorkspacesAsync([ws]);
// A general settings save copies named fields only; it must not clobber the workspaces.
await using (var ctx = new ClaudeDoDbContext(_options))
await new AppSettingsRepository(ctx).UpdateAsync(new AppSettingsEntity());
await using (var ctx = new ClaudeDoDbContext(_options))
Assert.Equal([ws], await new AppSettingsRepository(ctx).GetSshWorkspacesAsync());
}
[Fact]
public async Task Get_WithCorruptJson_ReturnsEmpty()
{
await using (var ctx = new ClaudeDoDbContext(_options))
{
await new AppSettingsRepository(ctx).GetAsync();
await ctx.Database.ExecuteSqlRawAsync("UPDATE app_settings SET ssh_workspaces = 'not json'");
}
await using (var ctx = new ClaudeDoDbContext(_options))
Assert.Empty(await new AppSettingsRepository(ctx).GetSshWorkspacesAsync());
}
}