From 751030dcef0046c36a8d2ca0d205a30f0a98ffb8 Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Thu, 11 Jun 2026 19:03:33 +0000 Subject: [PATCH] Add InstallScriptBuilder + GET /install.sh + GET /client.tar.gz --- backend.tests/InstallEndpointsTests.cs | 55 +++++++++++++++++ backend.tests/InstallScriptBuilderTests.cs | 18 ++++++ backend/Install/InstallEndpoints.cs | 28 +++++++++ backend/Install/InstallScriptBuilder.cs | 70 ++++++++++++++++++++++ backend/Program.cs | 3 + 5 files changed, 174 insertions(+) create mode 100644 backend.tests/InstallEndpointsTests.cs create mode 100644 backend.tests/InstallScriptBuilderTests.cs create mode 100644 backend/Install/InstallEndpoints.cs create mode 100644 backend/Install/InstallScriptBuilder.cs diff --git a/backend.tests/InstallEndpointsTests.cs b/backend.tests/InstallEndpointsTests.cs new file mode 100644 index 0000000..4d30fcf --- /dev/null +++ b/backend.tests/InstallEndpointsTests.cs @@ -0,0 +1,55 @@ +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace backend.tests; + +public class InstallEndpointsTests +{ + [Fact] + public async Task Install_sh_returns_shell_script_with_request_host() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + var res = await client.GetAsync("/install.sh"); + res.StatusCode.Should().Be(HttpStatusCode.OK); + res.Content.Headers.ContentType!.MediaType.Should().Be("text/x-shellscript"); + + var body = await res.Content.ReadAsStringAsync(); + body.Should().StartWith("#!/usr/bin/env bash"); + body.Should().Contain("BACKEND_URL=\"http://localhost\""); + } + + [Fact] + public async Task Client_tarball_returns_octet_stream() + { + var tmp = Path.Combine(Path.GetTempPath(), $"client-test-{Guid.NewGuid():N}.tar.gz"); + File.WriteAllBytes(tmp, new byte[] { 0x1f, 0x8b }); // tiny gzip-ish marker + + try + { + using var factory = new ApiFactory(); + var client = factory.WithWebHostBuilder(b => + { + b.ConfigureAppConfiguration((_, cfg) => + { + cfg.AddInMemoryCollection(new Dictionary + { + ["Install:ClientTarballPath"] = tmp, + }); + }); + }).CreateClient(); + + var res = await client.GetAsync("/client.tar.gz"); + res.StatusCode.Should().Be(HttpStatusCode.OK); + res.Content.Headers.ContentType!.MediaType.Should().Be("application/gzip"); + (await res.Content.ReadAsByteArrayAsync()).Length.Should().Be(2); + } + finally + { + if (File.Exists(tmp)) File.Delete(tmp); + } + } +} diff --git a/backend.tests/InstallScriptBuilderTests.cs b/backend.tests/InstallScriptBuilderTests.cs new file mode 100644 index 0000000..95eb803 --- /dev/null +++ b/backend.tests/InstallScriptBuilderTests.cs @@ -0,0 +1,18 @@ +using backend.Install; +using FluentAssertions; +using Xunit; + +namespace backend.tests; + +public class InstallScriptBuilderTests +{ + [Fact] + public void Build_inlines_the_backend_url_into_the_script() + { + var script = new InstallScriptBuilder().Build("https://example.test"); + script.Should().Contain("BACKEND_URL=\"https://example.test\""); + script.Should().Contain("#!/usr/bin/env bash"); + script.Should().Contain("$BACKEND_URL/client.tar.gz"); + script.Should().Contain("client.pair"); + } +} diff --git a/backend/Install/InstallEndpoints.cs b/backend/Install/InstallEndpoints.cs new file mode 100644 index 0000000..065f2fc --- /dev/null +++ b/backend/Install/InstallEndpoints.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Configuration; + +namespace backend.Install; + +public static class InstallEndpoints +{ + public static IEndpointRouteBuilder MapInstallEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/install.sh", (HttpContext http, InstallScriptBuilder builder) => + { + var scheme = http.Request.Scheme; + var host = http.Request.Host.ToString(); + var script = builder.Build($"{scheme}://{host}"); + return Results.Text(script, "text/x-shellscript"); + }); + + app.MapGet("/client.tar.gz", (IConfiguration cfg, IWebHostEnvironment env) => + { + var path = cfg["Install:ClientTarballPath"] + ?? Path.Combine(env.WebRootPath ?? "wwwroot", "client.tar.gz"); + if (!File.Exists(path)) + return Results.NotFound(new { error = "client.tar.gz not found at " + path }); + return Results.File(path, "application/gzip", "client.tar.gz"); + }); + + return app; + } +} diff --git a/backend/Install/InstallScriptBuilder.cs b/backend/Install/InstallScriptBuilder.cs new file mode 100644 index 0000000..341ab45 --- /dev/null +++ b/backend/Install/InstallScriptBuilder.cs @@ -0,0 +1,70 @@ +namespace backend.Install; + +public class InstallScriptBuilder +{ + public string Build(string backendUrl) + { + return $$""" +#!/usr/bin/env bash +# Smart Assistant installer (idempotent). Re-run to upgrade. +set -euo pipefail + +BACKEND_URL="{{backendUrl}}" +ASSISTANT_HOME="$HOME/assistant" +RESET=0 +for arg in "$@"; do + case "$arg" in + --reset) RESET=1 ;; + esac +done + +echo "Installing system deps (sudo password may be prompted)..." +sudo apt-get update -qq +sudo apt-get install -y --no-install-recommends python3-venv libportaudio2 alsa-utils + +mkdir -p "$ASSISTANT_HOME/code" "$ASSISTANT_HOME/state" + +if [ ! -d "$ASSISTANT_HOME/.venv" ]; then + python3 -m venv "$ASSISTANT_HOME/.venv" +fi + +echo "Downloading client bundle from $BACKEND_URL ..." +curl -fsSL "$BACKEND_URL/client.tar.gz" | tar -xz -C "$ASSISTANT_HOME/code" + +"$ASSISTANT_HOME/.venv/bin/pip" install --quiet -r "$ASSISTANT_HOME/code/requirements.txt" +"$ASSISTANT_HOME/.venv/bin/pip" install --quiet --no-deps -r "$ASSISTANT_HOME/code/requirements-nodeps.txt" + +if [ "$RESET" -eq 1 ]; then + rm -f "$ASSISTANT_HOME/state/config.json" +fi + +if [ ! -f "$ASSISTANT_HOME/state/config.json" ]; then + echo + read -r -p "Pairing code: " CODE + "$ASSISTANT_HOME/.venv/bin/python" -m client.pair --backend "$BACKEND_URL" --code "$CODE" +fi + +mkdir -p "$HOME/.config/systemd/user" +cat > "$HOME/.config/systemd/user/assistant.service" </dev/null || true +systemctl --user daemon-reload +systemctl --user enable --now assistant +echo "Done. systemctl --user status assistant" +"""; + } +} diff --git a/backend/Program.cs b/backend/Program.cs index a8b2553..af3ad12 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,6 +1,7 @@ using backend.Auth; using backend.Data; using backend.Devices; +using backend.Install; using backend.Pairing; using Microsoft.EntityFrameworkCore; @@ -14,6 +15,7 @@ builder.Services.AddAuthorization(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -37,6 +39,7 @@ app.UseAuthorization(); app.MapAuthEndpoints(); app.MapPairingEndpoints(); app.MapDevicesEndpoints(); +app.MapInstallEndpoints(); app.MapGet("/health", () => Results.Ok(new { ok = true }));