feat(data): store ssh workspaces on app_settings
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user