RC-2
Some checks failed
release-tag / release-image (push) Failing after 1m14s

This commit is contained in:
2026-08-10 06:30:40 +02:00
parent bab68df514
commit 7fb7960608
9 changed files with 265 additions and 55 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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 == "" {

View File

@@ -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)
}
}

View File

@@ -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.

View File

@@ -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=>`<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span>${esc(t.artifact_status||'—')}<small>${t.parent_task_id?`${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small><small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`).join(''):'<div class="empty">Keine Tasks</div>';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=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
</div>`;$('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=`<b>${eligible.toLocaleString('de-DE')} löschbar</b><span>· ${wins.toLocaleString('de-DE')} alte Gewinner geschützt · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschützt</span><small>Grenze: letzte Aktivität vor ${esc(cutoff)}</small>`;};
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:[];

View File

@@ -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}}

View File

@@ -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