diff --git a/Dockerfile b/Dockerfile index e1452b5..f7325b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,13 @@ -FROM golang:1.26-alpine AS build +FROM golang:1.23-alpine AS build WORKDIR /src -COPY go.* ./ +COPY go.mod ./ RUN go mod download -RUN go mod tidy -COPY . . +COPY cmd ./cmd COPY internal ./internal -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt ./cmd/server -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt-client ./cmd/client +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt ./cmd/server \ + && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt-client ./cmd/client -FROM alpine:3.24 +FROM alpine:3.21 RUN adduser -D -H app && mkdir -p /data /app && chown -R app:app /data /app WORKDIR /app COPY --from=build /out/neuralhunt /app/neuralhunt diff --git a/README.md b/README.md index 813bbb6..43319ad 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ -# Neural Hunt — V2.8 RIFT Task-Style Collection +# Neural Hunt — V3.1 RIFT Task-Style Collection + Profile Cleanup +> **V3.1 Admin Profile Cleanup:** Im Admin-Tab **RUNTIME** gibt es ein manuelles Bereinigungstool für alte Identitäten. Die Inaktivitätsdauer ist in Stunden/Tagen/Wochen einstellbar. Vor dem Löschen zeigt **PRÜFEN** die Anzahl löschbarer Profile. Gelöscht werden ausschließlich Profile, deren letzte Aktivität älter als die gewählte Grenze ist, die aktuell nicht verbunden sind und die niemals Gewinner eines Tasks waren. Gewinner werden immer geschützt; aktuell verbundene Clients ebenfalls. Beim Löschen werden die per Foreign Key abhängigen `task_points`, `client_unlocks` und `client_task_selection` mit entfernt. WebSocket-Verbindungsaufbau und -ende aktualisieren `clients.last_seen`, damit die Inaktivitätsgrenze tatsächliche Nutzung besser abbildet. + > **V2.8 RIFT Task-Styles:** RIFT-Identität und Rendering-Stil sind jetzt sauber getrennt. `/data/artifacts/_collection/character_anchor.png` ist ein globaler, neutraler Identity-Lock für den Waschbären RIFT und kann im Admin-Tab **ARTIFACT** einmalig manuell erzeugt und geprüft werden. Jeder Task kann im Admin-Tab **TASK ACTIONS** ein eigenes JPEG-/PNG-Style-Referenzbild erhalten; Nutzer sehen dieses Stylebild bereits in der Task-Auswahl und wählen damit indirekt die gewünschte NFT-Art. Bei jeder RIFT-Karte sendet Neural Hunt **Image 1 = Character Anchor** und **Image 2 = Task Style Reference** an die Images Edit API. Ohne eigenen Task-Style bleibt `internal/artifact/assets/style_reference.jpg` nur noch der Default-Fallback. Folge-Tasks erben ihren Style. `medium` bleibt Standard und die OpenAI-Usage-/Kosten-KPIs aus V2.7 bleiben erhalten. diff --git a/TESTING.md b/TESTING.md index 28cbeb7..6fabe60 100644 --- a/TESTING.md +++ b/TESTING.md @@ -319,3 +319,16 @@ go test ./internal/webui ./internal/core ./internal/auth ./internal/artifact ./i - Complete a task and verify its successor inherits `nft_style_reference`. - Generate a RIFT winner card and inspect the multipart OpenAI edit request: `image[]` must contain two files in this order: `character_anchor.png`, then the task style reference. Provider metadata should contain `reference_mode=character-plus-task-style`, the character-anchor hash and the style-reference hash. - Use **AUF DEFAULT ZURÜCK** and verify the task DB reference is empty and generation falls back to `internal/artifact/assets/style_reference.jpg` without deleting shared content-addressed style files. + + +## V3.1 — Admin cleanup for stale non-winner profiles + +- In Admin → RUNTIME set e.g. `30 Tage` and click **PRÜFEN**. Verify the preview reports only clients whose `clients.last_seen` is older than the cutoff, that are not currently connected, and that have never appeared as `tasks.winner_client_id`. +- Keep an old client connected via WebSocket: it must be reported as **aktuell verbunden geschützt** and never be deleted. +- Create an old winner identity: it must be reported as **Gewinner geschützt** and never be deleted, regardless of age. +- Confirm deletion and verify the client row is removed together with cascading `task_points`, `client_unlocks`, and `client_task_selection` rows. +- Verify a recent non-winner remains untouched. +- Verify WebSocket connect and disconnect update `clients.last_seen`, so a long-running session starts its inactivity window at disconnect rather than at its original login. +- The API rejects cleanup windows shorter than one hour. + +Relevant automated tests: `internal/data/profile_cleanup_test.go` and `internal/server/profile_cleanup_test.go`. diff --git a/internal/runtime/state.go b/internal/runtime/state.go index c3fbbb3..7bc3dc1 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -100,6 +100,21 @@ func (s *State) ConnectedCount() int64 { return int64(len(s.presence)) } +// ForgetClient removes non-durable runtime state for an identity that was +// deleted by the admin profile cleanup tool. The caller must only pass clients +// that are currently disconnected. +func (s *State) ForgetClient(clientID string) { + s.mu.Lock() + delete(s.presence, clientID) + delete(s.selected, clientID) + for k := range s.guesses { + if k.ClientID == clientID { + delete(s.guesses, k) + } + } + s.mu.Unlock() +} + func (s *State) SetTaskSelection(clientID, taskID string) { s.mu.Lock() if taskID == "" { diff --git a/internal/server/profile_cleanup_test.go b/internal/server/profile_cleanup_test.go new file mode 100644 index 0000000..a59d293 --- /dev/null +++ b/internal/server/profile_cleanup_test.go @@ -0,0 +1,55 @@ +package server + +import ( + "context" + "testing" + "time" + + "neuralhunt/internal/auth" + "neuralhunt/internal/data" + rtx "neuralhunt/internal/runtime" +) + +func TestProfileCleanupPreviewProtectsConnectedClient(t *testing.T) { + ctx := context.Background() + db, err := data.OpenSQLite(ctx, t.TempDir()+"/cleanup.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := data.New(db) + jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"} + for _, id := range []string{"offline_old", "online_old"} { + if err := store.UpsertClient(ctx, id, jwk); err != nil { + t.Fatal(err) + } + } + old := time.Now().UTC().Add(-10 * 24 * time.Hour).UnixMilli() + if _, err := db.ExecContext(ctx, `UPDATE clients SET last_seen=?`, old); err != nil { + t.Fatal(err) + } + runtimeState := rtx.New() + if _, err := runtimeState.AcquirePresence("online_old", "session"); err != nil { + t.Fatal(err) + } + s := &Server{store: store, runtime: runtimeState} + preview, ids, err := s.profileCleanupPreview(ctx, 7*24*60*60) + if err != nil { + t.Fatal(err) + } + if preview.Eligible != 1 || preview.ProtectedConnected != 1 { + t.Fatalf("unexpected preview: %+v", preview) + } + if len(ids) != 1 || ids[0] != "offline_old" { + t.Fatalf("unexpected ids: %#v", ids) + } +} + +func TestProfileCleanupDurationGuard(t *testing.T) { + if _, err := profileCleanupDurationSeconds("3599"); err == nil { + t.Fatal("sub-hour cleanup window should be rejected") + } + if got, err := profileCleanupDurationSeconds("86400"); err != nil || got != 86400 { + t.Fatalf("got=%d err=%v", got, err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9581fae..83d2de8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -178,6 +178,8 @@ func (s *Server) Routes() http.Handler { r.Use(func(n http.Handler) http.Handler { return s.require("admin", n) }) r.Get("/api/admin/overview", s.adminOverview) r.Get("/api/admin/performance", s.adminPerformance) + r.Get("/api/admin/profiles/cleanup-preview", s.adminProfileCleanupPreview) + r.Post("/api/admin/profiles/cleanup", s.adminProfileCleanup) r.Get("/api/admin/settings", s.adminSettingsGet) r.Put("/api/admin/settings", s.adminSettingsPut) r.Get("/api/admin/tasks", s.adminTasks) @@ -736,6 +738,108 @@ func (s *Server) serveAdminArtifactPart(w http.ResponseWriter, r *http.Request, http.ServeFile(w, r, path) } +type profileCleanupPreview struct { + CutoffMS int64 `json:"cutoff_ms"` + InactiveForSeconds int64 `json:"inactive_for_seconds"` + Eligible int `json:"eligible"` + ProtectedWinners int64 `json:"protected_winners"` + ProtectedConnected int `json:"protected_connected"` + OldestEligibleMS int64 `json:"oldest_eligible_ms,omitempty"` + NewestEligibleMS int64 `json:"newest_eligible_ms,omitempty"` +} + +func profileCleanupDurationSeconds(raw string) (int64, error) { + seconds, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return 0, errors.New("inactive_for_seconds must be an integer") + } + // A one-hour minimum prevents an accidental near-live purge while still + // allowing short-lived development/test deployments to clean up quickly. + if seconds < 3600 || seconds > 10*365*24*60*60 { + return 0, errors.New("inactive_for_seconds must be between 3600 seconds and 10 years") + } + return seconds, nil +} + +func (s *Server) profileCleanupPreview(ctx context.Context, inactiveForSeconds int64) (profileCleanupPreview, []string, error) { + cutoff := time.Now().UTC().Add(-time.Duration(inactiveForSeconds) * time.Second).UnixMilli() + candidates, err := s.store.InactiveNonWinnerClients(ctx, cutoff) + if err != nil { + return profileCleanupPreview{}, nil, err + } + protectedWinners, err := s.store.OldWinnerCount(ctx, cutoff) + if err != nil { + return profileCleanupPreview{}, nil, err + } + ids := make([]string, 0, len(candidates)) + out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners} + for _, c := range candidates { + if s.runtime.IsConnected(c.ClientID) { + out.ProtectedConnected++ + continue + } + ids = append(ids, c.ClientID) + if out.OldestEligibleMS == 0 || c.LastSeen < out.OldestEligibleMS { + out.OldestEligibleMS = c.LastSeen + } + if c.LastSeen > out.NewestEligibleMS { + out.NewestEligibleMS = c.LastSeen + } + } + out.Eligible = len(ids) + return out, ids, nil +} + +func (s *Server) adminProfileCleanupPreview(w http.ResponseWriter, r *http.Request) { + seconds, err := profileCleanupDurationSeconds(r.URL.Query().Get("inactive_for_seconds")) + if err != nil { + jsonOut(w, 400, map[string]string{"error": err.Error()}) + return + } + preview, _, err := s.profileCleanupPreview(r.Context(), seconds) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "profile cleanup preview failed: " + err.Error()}) + return + } + jsonOut(w, 200, preview) +} + +func (s *Server) adminProfileCleanup(w http.ResponseWriter, r *http.Request) { + var in struct { + InactiveForSeconds int64 `json:"inactive_for_seconds"` + } + if err := decode(r, &in); err != nil { + jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()}) + return + } + seconds, err := profileCleanupDurationSeconds(strconv.FormatInt(in.InactiveForSeconds, 10)) + if err != nil { + jsonOut(w, 400, map[string]string{"error": err.Error()}) + return + } + preview, ids, err := s.profileCleanupPreview(r.Context(), seconds) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "profile cleanup check failed: " + err.Error()}) + return + } + deleted, err := s.store.DeleteInactiveNonWinnerClients(r.Context(), preview.CutoffMS, ids) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "profile cleanup failed: " + err.Error()}) + return + } + for _, id := range deleted { + s.runtime.ForgetClient(id) + } + jsonOut(w, 200, map[string]any{ + "deleted": len(deleted), + "eligible_before": preview.Eligible, + "protected_winners": preview.ProtectedWinners, + "protected_connected": preview.ProtectedConnected, + "cutoff_ms": preview.CutoffMS, + "inactive_for_seconds": seconds, + }) +} + func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { var clients, activeTasks, completedTasks, guesses, artifacts int64 _ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*) FROM clients`).Scan(&clients) @@ -1062,6 +1166,10 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) { http.Error(w, "identity not registered", http.StatusUnauthorized) return } + // WebSocket use counts as recent profile activity. This also makes a race + // with the admin cleanup safe: a newly connecting identity no longer + // matches an old last_seen cutoff. + s.store.TouchClient(r.Context(), c.ClientID) t, err := s.store.TaskForClient(r.Context(), c.ClientID) if err != nil { http.Error(w, "no task", 503) @@ -1080,7 +1188,13 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) { } cl := wsx.NewClient(conn, t.ID, c.ClientID, false) s.hub.Add(cl) - defer func() { s.hub.Remove(cl); s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID) }() + defer func() { + s.hub.Remove(cl) + s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID) + // Mark the disconnect time as last activity. A client that stayed online + // for days therefore starts its inactivity window only after disconnect. + s.store.TouchClient(context.Background(), c.ClientID) + }() // A map point is durable only once per client/task. Subsequent losing guesses // stay in memory; improvements are checkpointed by the guess handler. diff --git a/internal/webui/dist/app.js b/internal/webui/dist/app.js index aee99bc..c6399ab 100644 --- a/internal/webui/dist/app.js +++ b/internal/webui/dist/app.js @@ -432,7 +432,16 @@ async function runAdmin(){ async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const token=localStorage.getItem(adminTokenKey),r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{headers:{Authorization:'Bearer '+token}});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}} function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>``).join(''):'
Keine Tasks
';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})} function renderRuntime(){ - $('settingfields').innerHTML=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block'; + $('settingfields').innerHTML=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

+
ALTE PROFILE BEREINIGEN

Löscht ausschließlich Accounts, die seit mindestens X Zeit inaktiv, aktuell nicht verbunden und niemals Gewinner eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.

+
+
Noch nicht geprüft.
+
`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block'; + const cleanupSeconds=()=>{const v=Math.max(1,Number($('profileCleanupValue')?.value||0)),unit=$('profileCleanupUnit')?.value||'days',factor=unit==='hours'?3600:unit==='weeks'?7*86400:86400;return Math.round(v*factor)}; + const showCleanup=p=>{const box=$('profileCleanupResult');if(!box)return;const eligible=Number(p?.eligible||0),wins=Number(p?.protected_winners||0),online=Number(p?.protected_connected||0),cutoff=p?.cutoff_ms?fmtDate(p.cutoff_ms):'—';box.innerHTML=`${eligible.toLocaleString('de-DE')} löschbar· ${wins.toLocaleString('de-DE')} alte Gewinner geschützt · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschütztGrenze: letzte Aktivität vor ${esc(cutoff)}`;}; + const previewCleanup=async()=>{try{captureDraft();const seconds=cleanupSeconds(),p=await api(`/api/admin/profiles/cleanup-preview?inactive_for_seconds=${seconds}`,{},true);showCleanup(p);return p}catch(e){msg(e.message);throw e}}; + $('previewProfileCleanup').onclick=()=>previewCleanup().catch(()=>{}); + $('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline und niemals Gewinner.\nGewinner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}}; } function renderArtifact(){ const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[]; diff --git a/internal/webui/dist/styles.css b/internal/webui/dist/styles.css index 0f79864..a52b38b 100644 --- a/internal/webui/dist/styles.css +++ b/internal/webui/dist/styles.css @@ -98,3 +98,6 @@ html.mobile-mode .task-landing-inner{width:calc(100% - 16px);padding:82px 0 22px html.mobile-mode .cost-grid{grid-template-columns:1fr}.mobile-mode .cost-card{padding:8px}.mobile-mode .usage-table-wrap{max-width:100%} .reference-admin-box,.task-style-admin{display:grid;grid-template-columns:minmax(0,1fr) 128px;gap:10px;align-items:stretch;border:1px solid rgba(82,231,255,.13);border-radius:12px;padding:10px;margin:10px 0;background:rgba(82,231,255,.025)}.reference-preview{min-height:128px;border:1px dashed rgba(133,200,255,.18);border-radius:10px;display:grid;place-items:center;overflow:hidden;background:rgba(2,8,17,.72);color:#607f92;font-size:7px;letter-spacing:.12em}.reference-preview.has-image{border-style:solid}.reference-preview img{width:100%;height:100%;min-height:128px;display:block;object-fit:cover}.ready-copy{color:#8edbb8}.anchor-actions{margin:0 0 12px}.task-style-admin{grid-template-columns:128px minmax(0,1fr);margin:10px 0 12px}.task-style-controls{min-width:0}.task-style-controls .section-title{margin-top:0}.task-style-controls input[type=file]{width:100%;font-size:7px;color:#7898aa;border:1px solid rgba(133,200,255,.12);border-radius:8px;padding:6px;background:rgba(2,8,17,.55)}.style-actions{justify-content:flex-start;flex-wrap:wrap}.danger-button{color:#ff9bad!important;border-color:rgba(255,95,136,.22)!important;background:rgba(255,95,136,.04)!important}.control-section button:disabled{opacity:.45;cursor:not-allowed} html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-template-columns:1fr}.mobile-mode .reference-preview{min-height:160px}.mobile-mode .reference-preview img{min-height:160px;max-height:240px} + +/* Admin-only cleanup for stale non-winner identities. */ +.profile-cleanup{margin-top:12px;border-top:1px solid rgba(133,200,255,.12);padding-top:14px}.cleanup-controls{display:grid;grid-template-columns:minmax(0,1fr) minmax(110px,.7fr);gap:8px;margin:9px 0}.cleanup-actions{display:flex;gap:7px;flex-wrap:wrap}.cleanup-result{margin-top:9px;border:1px solid rgba(133,200,255,.11);background:rgba(2,8,17,.55);border-radius:9px;padding:9px;color:#88a6b8;font-size:8px;line-height:1.5}.cleanup-result b{color:#dffaff;font-size:11px}.cleanup-result span{margin-left:5px}.cleanup-result small{display:block;color:#64879a;margin-top:3px}.profile-cleanup .danger-button{margin-left:auto}@media(max-width:520px){.cleanup-controls{grid-template-columns:1fr}.profile-cleanup .danger-button{margin-left:0}.cleanup-actions button{flex:1}} diff --git a/run-load.ps1 b/run-load.ps1 index 10938a1..5167247 100644 --- a/run-load.ps1 +++ b/run-load.ps1 @@ -1,45 +1,45 @@ -param( - [switch]$NoEnv -) - -$ErrorActionPreference = "Stop" -Set-StrictMode -Version Latest - -$ProjectRoot = $PSScriptRoot -Set-Location $ProjectRoot - -function Import-DotEnv { - param([Parameter(Mandatory = $true)][string]$Path) - - Get-Content -LiteralPath $Path | ForEach-Object { - $line = $_.Trim() - if (-not $line -or $line.StartsWith("#")) { return } - - $parts = $line.Split("=", 2) - if ($parts.Count -ne 2) { return } - - $name = $parts[0].Trim() - $value = $parts[1].Trim() - if (-not $name) { return } - - if (($value.StartsWith('"') -and $value.EndsWith('"')) -or - ($value.StartsWith("'") -and $value.EndsWith("'"))) { - $value = $value.Substring(1, $value.Length - 2) - } - - [Environment]::SetEnvironmentVariable($name, $value, "Process") - } -} - -if (-not $NoEnv) { - $envFile = Join-Path $ProjectRoot ".env" - if (Test-Path -LiteralPath $envFile) { - Import-DotEnv -Path $envFile - } - else { - Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet." - } -} - -go run ./cmd/loadtest -exit $LASTEXITCODE +param( + [switch]$NoEnv +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$ProjectRoot = $PSScriptRoot +Set-Location $ProjectRoot + +function Import-DotEnv { + param([Parameter(Mandatory = $true)][string]$Path) + + Get-Content -LiteralPath $Path | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + + $parts = $line.Split("=", 2) + if ($parts.Count -ne 2) { return } + + $name = $parts[0].Trim() + $value = $parts[1].Trim() + if (-not $name) { return } + + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or + ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + + [Environment]::SetEnvironmentVariable($name, $value, "Process") + } +} + +if (-not $NoEnv) { + $envFile = Join-Path $ProjectRoot ".env" + if (Test-Path -LiteralPath $envFile) { + Import-DotEnv -Path $envFile + } + else { + Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet." + } +} + +go run ./cmd/loadtest +exit $LASTEXITCODE