diff --git a/.gitignore b/.gitignore index d97cdc2..1da5c87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .DS_Store .claude/settings.local.json .wisp/ -wisp \ No newline at end of file +wisp + +# Web client +web/node_modules/ \ No newline at end of file diff --git a/README.md b/README.md index 980260b..bdbe3a5 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,36 @@ wisp start --repo org/repo --spec docs/my-rfc.md --sibling-repos org/shared-lib When the agent needs input, the TUI prompts for a response. Type and press Enter. +### Remote access + +Monitor and interact with sessions from any device using the built-in web server: + +```bash +wisp start --repo org/repo --spec docs/rfc.md --server +``` + +On first use, you'll be prompted to set a password. The server URL is printed +(default: `http://localhost:8374`). Expose via ngrok for mobile access: + +```bash +ngrok http 8374 +``` + +The web interface provides: +- Dashboard with active sessions +- Task list with completion status +- Live Claude output stream +- Input prompt for NEEDS_INPUT responses +- Browser notifications when input is needed + +Additional server options: + +```bash +wisp start ... --server --port 9000 # custom port +wisp start ... --server --password # change password +wisp resume wisp/my-feature --server # resume with server +``` + ### Complete and create PR ```bash diff --git a/cmd/debug-server/main.go b/cmd/debug-server/main.go new file mode 100644 index 0000000..be94858 --- /dev/null +++ b/cmd/debug-server/main.go @@ -0,0 +1,69 @@ +// Simple standalone server for debugging auth flow. +// Run with: go run ./cmd/debug-server +// Make sure to rebuild web assets first: cd web && npm run build +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/thruflo/wisp/internal/auth" + "github.com/thruflo/wisp/internal/server" + "github.com/thruflo/wisp/web" +) + +func main() { + password := "test123" + if len(os.Args) > 1 { + password = os.Args[1] + } + + hash, err := auth.HashPassword(password) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to hash password: %v\n", err) + os.Exit(1) + } + + // Check if web/dist exists (fresh build) + if stat, err := os.Stat("./web/dist"); err != nil || !stat.IsDir() { + fmt.Fprintln(os.Stderr, "Warning: ./web/dist not found. Run 'cd web && npm run build' first.") + fmt.Fprintln(os.Stderr, "Using embedded assets (may be stale).") + } else { + fmt.Println("Using live assets from ./web/dist") + } + + srv, err := server.NewServer(&server.Config{ + Port: 8375, + PasswordHash: hash, + Assets: web.GetAssets("./web/dist"), + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create server: %v\n", err) + os.Exit(1) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle Ctrl+C + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Println("\nShutting down...") + cancel() + }() + + go srv.Start(ctx) + + fmt.Printf("Server running on http://localhost:%d\n", srv.Port()) + fmt.Printf("Password: %s\n", password) + fmt.Println("\nTest with:") + fmt.Printf(" curl -X POST http://localhost:%d/auth -H 'Content-Type: application/json' -d '{\"password\":\"%s\"}'\n", srv.Port(), password) + + <-ctx.Done() + srv.Stop() +} diff --git a/go.mod b/go.mod index 80032b5..a451729 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,138 @@ module github.com/thruflo/wisp -go 1.24.0 +go 1.25 require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7 + golang.org/x/crypto v0.47.0 + golang.org/x/term v0.39.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/Masterminds/semver/v3 v3.2.1 // indirect + cel.dev/expr v0.24.0 // indirect + cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect + dario.cat/mergo v1.0.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect + github.com/KimMachineGun/automemlimit v0.7.4 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Microsoft/go-winio v0.6.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/caddyserver/caddy/v2 v2.10.2 // indirect + github.com/caddyserver/certmagic v0.24.0 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/ccoveille/go-safecast v1.6.1 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chzyer/readline v1.5.1 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/coreos/go-oidc/v3 v3.14.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgraph-io/badger v1.6.2 // indirect + github.com/dgraph-io/badger/v2 v2.2007.4 // indirect + github.com/dgraph-io/ristretto v0.2.0 // indirect + github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect + github.com/durable-streams/durable-streams/packages/caddy-plugin v0.0.0-20260116005712-42bf4d3b3e45 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/francoispqt/gojay v1.2.13 // indirect + github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/libdns v1.1.0 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mholt/acmez/v3 v3.1.2 // indirect + github.com/miekg/dns v1.1.63 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/slackhq/nebula v1.9.5 // indirect + github.com/smallstep/certificates v0.28.4 // indirect + github.com/smallstep/cli-utils v0.12.1 // indirect + github.com/smallstep/linkedca v0.23.0 // indirect + github.com/smallstep/nosql v0.7.0 // indirect + github.com/smallstep/pkcs7 v0.2.1 // indirect + github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 // indirect + github.com/smallstep/truststore v0.13.0 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7 // indirect - golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect - golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 // indirect + github.com/urfave/cli v1.22.17 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.etcd.io/bbolt v1.4.3 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.step.sm/crypto v0.67.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/mock v0.5.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/api v0.240.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect + howett.net/plist v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index dc1d9d9..847af2b 100644 --- a/go.sum +++ b/go.sum @@ -1,30 +1,552 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= +dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= +dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= +dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/KimMachineGun/automemlimit v0.7.4 h1:UY7QYOIfrr3wjjOAqahFmC3IaQCLWvur9nmfIn6LnWk= +github.com/KimMachineGun/automemlimit v0.7.4/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= +github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/caddyserver/caddy/v2 v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8= +github.com/caddyserver/caddy/v2 v2.10.2/go.mod h1:TXLQHx+ev4HDpkO6PnVVHUbL6OXt6Dfe7VcIBdQnPL0= +github.com/caddyserver/certmagic v0.24.0 h1:EfXTWpxHAUKgDfOj6MHImJN8Jm4AMFfMT6ITuKhrDF0= +github.com/caddyserver/certmagic v0.24.0/go.mod h1:xPT7dC1DuHHnS2yuEQCEyks+b89sUkMENh8dJF+InLE= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/ccoveille/go-safecast v1.6.1 h1:Nb9WMDR8PqhnKCVs2sCB+OqhohwO5qaXtCviZkIff5Q= +github.com/ccoveille/go-safecast v1.6.1/go.mod h1:QqwNjxQ7DAqY0C721OIO9InMk9zCwcsO7tnRuHytad8= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger v1.6.2 h1:mNw0qs90GVgGGWylh0umH5iag1j6n/PeJtNvL6KY/x8= +github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE= +github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o= +github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk= +github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= +github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/durable-streams/durable-streams/packages/caddy-plugin v0.0.0-20260116005712-42bf4d3b3e45 h1:mWYXH+vP9jZJ7FJKQpAu7Jj8L/tSM+grkGBW7mA1D34= +github.com/durable-streams/durable-streams/packages/caddy-plugin v0.0.0-20260116005712-42bf4d3b3e45/go.mod h1:mX4HnrJ9vDQhrE5pVp/gvF75wDpmqFzlOQpG3BC8XkA= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= +github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= +github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= +github.com/libdns/libdns v1.1.0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= +github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= +github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= +github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= +github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= +github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= +github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= +github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= +github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= +github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= +github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= +github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= +github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/slackhq/nebula v1.9.5 h1:ZrxcvP/lxwFglaijmiwXLuCSkybZMJnqSYI1S8DtGnY= +github.com/slackhq/nebula v1.9.5/go.mod h1:1+4q4wd3dDAjO8rKCttSb9JIVbklQhuJiBp5I0lbIsQ= +github.com/smallstep/certificates v0.28.4 h1:JTU6/A5Xes6m+OsR6fw1RACSA362vJc9SOFVG7poBEw= +github.com/smallstep/certificates v0.28.4/go.mod h1:LUqo+7mKZE7FZldlTb0zhU4A0bq4G4+akieFMcTaWvA= +github.com/smallstep/cli-utils v0.12.1 h1:D9QvfbFqiKq3snGZ2xDcXEFrdFJ1mQfPHZMq/leerpE= +github.com/smallstep/cli-utils v0.12.1/go.mod h1:skV2Neg8qjiKPu2fphM89H9bIxNpKiiRTnX9Q6Lc+20= +github.com/smallstep/linkedca v0.23.0 h1:5W/7EudlK1HcCIdZM68dJlZ7orqCCCyv6bm2l/0JmLU= +github.com/smallstep/linkedca v0.23.0/go.mod h1:7cyRM9soAYySg9ag65QwytcgGOM+4gOlkJ/YA58A9E8= +github.com/smallstep/nosql v0.7.0 h1:YiWC9ZAHcrLCrayfaF+QJUv16I2bZ7KdLC3RpJcnAnE= +github.com/smallstep/nosql v0.7.0/go.mod h1:H5VnKMCbeq9QA6SRY5iqPylfxLfYcLwvUff3onQ8+HU= +github.com/smallstep/pkcs7 v0.0.0-20240911091500-b1cae6277023/go.mod h1:CM5KrX7rxWgwDdMj9yef/pJB2OPgy/56z4IEx2UIbpc= +github.com/smallstep/pkcs7 v0.2.1 h1:6Kfzr/QizdIuB6LSv8y1LJdZ3aPSfTNhTLqAx9CTLfA= +github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0= +github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 h1:LyZqn24/ZiVg8v9Hq07K6mx6RqPtpDeK+De5vf4QEY4= +github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101/go.mod h1:EuKQjYGQwhUa1mgD21zxIgOgUYLsqikJmvxNscxpS/Y= +github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4= +github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7 h1:RZLYb8iaESjntBxWXLeKbB5de9T4HKd0pod+KQ5NNdU= github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7/go.mod h1:4zltGIGJa3HV+XumRyNn4BmhlavbUZH3Uh5xJNaDwsY= +github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 h1:uxMgm0C+EjytfAqyfBG55ZONKQ7mvd7x4YYCWsf8QHQ= +github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53/go.mod h1:kNGUQ3VESx3VZwRwA9MSCUegIl6+saPL8Noq82ozCaU= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= +github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= +github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= +github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.step.sm/crypto v0.67.0 h1:1km9LmxMKG/p+mKa1R4luPN04vlJYnRLlLQrWv7egGU= +go.step.sm/crypto v0.67.0/go.mod h1:+AoDpB0mZxbW/PmOXuwkPSpXRgaUaoIK+/Wx/HGgtAU= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= +golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 h1:V5+zy0jmgNYmK1uW/sPpBw8ioFvalrhaUrYWmu1Fpe4= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= +google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= +google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..b1affb2 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,151 @@ +// Package auth provides password hashing and verification using argon2id. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/crypto/argon2" + "golang.org/x/term" +) + +// Argon2id parameters (recommended for password hashing) +const ( + argonTime = 3 // iterations + argonMemory = 65536 // 64 MB + argonThreads = 4 // parallelism + argonKeyLen = 32 // output length + saltLength = 16 // salt length +) + +// HashPassword creates an argon2id hash of the given password. +// Returns a string in the format: $argon2id$v=19$m=65536,t=3,p=4$$ +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) + } + + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + saltB64 := base64.RawStdEncoding.EncodeToString(salt) + hashB64 := base64.RawStdEncoding.EncodeToString(hash) + + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, saltB64, hashB64), nil +} + +// VerifyPassword checks if the provided password matches the hash. +// The hash must be in the format: $argon2id$v=19$m=65536,t=3,p=4$$ +func VerifyPassword(password, encodedHash string) (bool, error) { + // Parse the hash + params, salt, hash, err := decodeHash(encodedHash) + if err != nil { + return false, err + } + + // Compute hash with the same parameters + computed := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.threads, params.keyLen) + + // Constant-time comparison + if subtle.ConstantTimeCompare(hash, computed) == 1 { + return true, nil + } + return false, nil +} + +type argonParams struct { + memory uint32 + time uint32 + threads uint8 + keyLen uint32 +} + +// decodeHash parses an encoded argon2id hash string. +func decodeHash(encodedHash string) (*argonParams, []byte, []byte, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return nil, nil, nil, fmt.Errorf("invalid hash format: expected 6 parts, got %d", len(parts)) + } + + if parts[1] != "argon2id" { + return nil, nil, nil, fmt.Errorf("invalid hash algorithm: expected argon2id, got %s", parts[1]) + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return nil, nil, nil, fmt.Errorf("invalid version format: %w", err) + } + if version != argon2.Version { + return nil, nil, nil, fmt.Errorf("unsupported argon2 version: %d", version) + } + + var memory, time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return nil, nil, nil, fmt.Errorf("invalid params format: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid salt encoding: %w", err) + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid hash encoding: %w", err) + } + + return &argonParams{ + memory: memory, + time: time, + threads: threads, + keyLen: uint32(len(hash)), + }, salt, hash, nil +} + +// ErrEmptyPassword is returned when the user enters an empty password. +var ErrEmptyPassword = errors.New("password cannot be empty") + +// ErrPasswordMismatch is returned when password confirmation doesn't match. +var ErrPasswordMismatch = errors.New("passwords do not match") + +// PromptPassword prompts the user for a password (hidden input). +// Returns the entered password. +func PromptPassword(prompt string) (string, error) { + fmt.Print(prompt) + password, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() // Add newline after hidden input + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + return string(password), nil +} + +// PromptAndConfirmPassword prompts for a password with confirmation. +// Returns the password if both entries match. +func PromptAndConfirmPassword() (string, error) { + password, err := PromptPassword("Enter password for web server: ") + if err != nil { + return "", err + } + if password == "" { + return "", ErrEmptyPassword + } + + confirm, err := PromptPassword("Confirm password: ") + if err != nil { + return "", err + } + + if password != confirm { + return "", ErrPasswordMismatch + } + + return password, nil +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..a88e654 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,138 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHashPassword(t *testing.T) { + t.Parallel() + + password := "test-password-123" + hash, err := HashPassword(password) + require.NoError(t, err) + + // Check format: $argon2id$v=19$m=65536,t=3,p=4$$ + assert.Contains(t, hash, "$argon2id$") + assert.Contains(t, hash, "v=19") + assert.Contains(t, hash, "m=65536,t=3,p=4") +} + +func TestHashPassword_UniquePerCall(t *testing.T) { + t.Parallel() + + password := "same-password" + hash1, err := HashPassword(password) + require.NoError(t, err) + + hash2, err := HashPassword(password) + require.NoError(t, err) + + // Hashes should be different due to random salt + assert.NotEqual(t, hash1, hash2) +} + +func TestVerifyPassword_Correct(t *testing.T) { + t.Parallel() + + password := "correct-horse-battery-staple" + hash, err := HashPassword(password) + require.NoError(t, err) + + match, err := VerifyPassword(password, hash) + require.NoError(t, err) + assert.True(t, match) +} + +func TestVerifyPassword_Incorrect(t *testing.T) { + t.Parallel() + + password := "correct-password" + wrongPassword := "wrong-password" + hash, err := HashPassword(password) + require.NoError(t, err) + + match, err := VerifyPassword(wrongPassword, hash) + require.NoError(t, err) + assert.False(t, match) +} + +func TestVerifyPassword_EmptyPassword(t *testing.T) { + t.Parallel() + + password := "" + hash, err := HashPassword(password) + require.NoError(t, err) + + // Empty password should still verify correctly + match, err := VerifyPassword("", hash) + require.NoError(t, err) + assert.True(t, match) + + // Non-empty should not match + match, err = VerifyPassword("not-empty", hash) + require.NoError(t, err) + assert.False(t, match) +} + +func TestVerifyPassword_InvalidHashFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hash string + }{ + {"empty", ""}, + {"not enough parts", "$argon2id$v=19"}, + {"wrong algorithm", "$bcrypt$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA"}, + {"invalid version format", "$argon2id$version=19$m=65536,t=3,p=4$c2FsdA$aGFzaA"}, + {"invalid params format", "$argon2id$v=19$memory=65536$c2FsdA$aGFzaA"}, + {"invalid salt encoding", "$argon2id$v=19$m=65536,t=3,p=4$!!!invalid!!!$aGFzaA"}, + {"invalid hash encoding", "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$!!!invalid!!!"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := VerifyPassword("password", tt.hash) + assert.Error(t, err) + }) + } +} + +func TestVerifyPassword_DifferentParams(t *testing.T) { + t.Parallel() + + // Test that we can verify a hash with different parameters + // This uses the format that our HashPassword produces + password := "test123" + hash, err := HashPassword(password) + require.NoError(t, err) + + // Should still verify correctly + match, err := VerifyPassword(password, hash) + require.NoError(t, err) + assert.True(t, match) +} + +func TestDecodeHash_ValidFormats(t *testing.T) { + t.Parallel() + + // Valid hash from HashPassword + password := "test" + hash, err := HashPassword(password) + require.NoError(t, err) + + params, salt, hashBytes, err := decodeHash(hash) + require.NoError(t, err) + + assert.Equal(t, uint32(65536), params.memory) + assert.Equal(t, uint32(3), params.time) + assert.Equal(t, uint8(4), params.threads) + assert.Equal(t, uint32(32), params.keyLen) + assert.Len(t, salt, 16) + assert.Len(t, hashBytes, 32) +} diff --git a/internal/cli/resume.go b/internal/cli/resume.go index 1cfb236..8595383 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -6,15 +6,24 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/spf13/cobra" + "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" "github.com/thruflo/wisp/internal/loop" + "github.com/thruflo/wisp/internal/server" "github.com/thruflo/wisp/internal/sprite" "github.com/thruflo/wisp/internal/state" "github.com/thruflo/wisp/internal/tui" ) +var ( + resumeServer bool + resumeServerPort int + resumeSetPassword bool +) + var resumeCmd = &cobra.Command{ Use: "resume ", Short: "Resume an existing wisp session", @@ -23,14 +32,26 @@ restoring state from local storage, and continuing the iteration loop. The branch argument is required and must match an existing session. +Remote Access: + Use --server to start a web server alongside the TUI for monitoring and + interacting with the session from any device (phone, tablet, another computer). + On first use, you'll be prompted to set a password. Use --port to customize + the server port (default: 8374). Use --password to change the password. + Example: wisp resume wisp/my-feature - wisp resume feature/auth-implementation`, + wisp resume feature/auth-implementation + wisp resume wisp/my-feature --server + wisp resume wisp/my-feature --server --port 9000`, Args: cobra.ExactArgs(1), RunE: runResume, } func init() { + resumeCmd.Flags().BoolVar(&resumeServer, "server", false, "start web server alongside TUI for remote access") + resumeCmd.Flags().IntVar(&resumeServerPort, "port", config.DefaultServerPort, "web server port (requires --server)") + resumeCmd.Flags().BoolVar(&resumeSetPassword, "password", false, "prompt to set/change web server password") + rootCmd.AddCommand(resumeCmd) } @@ -62,6 +83,13 @@ func runResume(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } + // Handle server mode and password setup + if resumeServer || resumeSetPassword { + if err := handleResumeServerPassword(cwd, cfg, resumeServer, resumeSetPassword, resumeServerPort); err != nil { + return err + } + } + // Load settings settings, err := config.LoadSettings(cwd) if err != nil { @@ -140,8 +168,51 @@ func runResume(cmd *cobra.Command, args []string) error { // Get template directory templateDir := filepath.Join(cwd, ".wisp", "templates", templateName) + // Create web server if enabled + var srv *server.Server + if resumeServer { + var err error + srv, err = server.NewServerFromConfig(cfg.Server) + if err != nil { + return fmt.Errorf("failed to create web server: %w", err) + } + + // Start server in background + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.Start(ctx) + }() + + // Give the server a moment to start and check for errors + select { + case err := <-serverErrCh: + return fmt.Errorf("web server failed to start: %w", err) + case <-time.After(100 * time.Millisecond): + // Server started successfully + } + + fmt.Printf("Web server running at http://localhost:%d\n", cfg.Server.Port) + + // Ensure server is stopped when we exit + defer func() { + if err := srv.Stop(); err != nil { + fmt.Printf("Warning: failed to stop web server: %v\n", err) + } + }() + } + // Create and run loop - l := loop.NewLoop(client, syncMgr, store, cfg, session, t, repoPath, templateDir) + l := loop.NewLoopWithOptions(loop.LoopOptions{ + Client: client, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: t, + Server: srv, + RepoPath: repoPath, + TemplateDir: templateDir, + }) fmt.Printf("Resuming iteration loop...\n") @@ -356,3 +427,49 @@ func checkoutBranch(ctx context.Context, client sprite.Client, spriteName, repoP return nil } + +// handleResumeServerPassword handles password setup for the web server on resume. +// It prompts for a password if needed and saves the hash to config. +func handleResumeServerPassword(basePath string, cfg *config.Config, serverEnabled, setPassword bool, port int) error { + // Initialize server config if not present + if cfg.Server == nil { + cfg.Server = config.DefaultServerConfig() + } + + // Update port from flag + cfg.Server.Port = port + + // Check if we need to prompt for password + needsPassword := false + + if setPassword { + // User explicitly wants to set/change password + needsPassword = true + } else if serverEnabled && cfg.Server.PasswordHash == "" { + // Server mode enabled but no password configured + needsPassword = true + } + + if needsPassword { + password, err := auth.PromptAndConfirmPassword() + if err != nil { + return fmt.Errorf("password setup failed: %w", err) + } + + hash, err := auth.HashPassword(password) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + cfg.Server.PasswordHash = hash + + // Save the updated config + if err := config.SaveConfig(basePath, cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("Password saved to config.") + } + + return nil +} diff --git a/internal/cli/resume_test.go b/internal/cli/resume_test.go index 8624cd2..f5fb617 100644 --- a/internal/cli/resume_test.go +++ b/internal/cli/resume_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" "github.com/thruflo/wisp/internal/state" ) @@ -281,3 +282,94 @@ func TestResumeCommand_ContextCancellation(t *testing.T) { // Verify context is cancelled assert.Error(t, ctx.Err()) } + +func TestResumeServerFlagsRegistered(t *testing.T) { + // Verify the --server flag is registered on the resume command + serverFlag := resumeCmd.Flags().Lookup("server") + require.NotNil(t, serverFlag, "--server flag should be registered") + assert.Equal(t, "bool", serverFlag.Value.Type()) + assert.Equal(t, "false", serverFlag.DefValue) + assert.Contains(t, serverFlag.Usage, "web server") + + // Verify the --port flag is registered on the resume command + portFlag := resumeCmd.Flags().Lookup("port") + require.NotNil(t, portFlag, "--port flag should be registered") + assert.Equal(t, "int", portFlag.Value.Type()) + assert.Equal(t, "8374", portFlag.DefValue) + assert.Contains(t, portFlag.Usage, "port") + + // Verify the --password flag is registered on the resume command + passwordFlag := resumeCmd.Flags().Lookup("password") + require.NotNil(t, passwordFlag, "--password flag should be registered") + assert.Equal(t, "bool", passwordFlag.Value.Type()) + assert.Equal(t, "false", passwordFlag.DefValue) + assert.Contains(t, passwordFlag.Usage, "password") +} + +func TestHandleResumeServerPassword_NoServerConfig(t *testing.T) { + // Test that handleResumeServerPassword creates server config if missing + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + cfg := &config.Config{ + Limits: config.DefaultLimits(), + } + + // Not enabling server, not setting password - should be no-op + err := handleResumeServerPassword(tmpDir, cfg, false, false, 9000) + require.NoError(t, err) + // Server config should still be initialized since function was called + assert.NotNil(t, cfg.Server) + assert.Equal(t, 9000, cfg.Server.Port) + assert.Empty(t, cfg.Server.PasswordHash) +} + +func TestHandleResumeServerPassword_WithExistingPassword(t *testing.T) { + // Test that handleResumeServerPassword doesn't prompt when password exists + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + // Create config with existing password hash + existingHash, err := auth.HashPassword("existingpassword") + require.NoError(t, err) + + cfg := &config.Config{ + Limits: config.DefaultLimits(), + Server: &config.ServerConfig{ + Port: 8374, + PasswordHash: existingHash, + }, + } + + // Server enabled but password already set - should not prompt (no error) + err = handleResumeServerPassword(tmpDir, cfg, true, false, 8080) + require.NoError(t, err) + // Port should be updated, but password hash should be unchanged + assert.Equal(t, 8080, cfg.Server.Port) + assert.Equal(t, existingHash, cfg.Server.PasswordHash) +} + +func TestHandleResumeServerPassword_PortUpdated(t *testing.T) { + // Test that port is updated even when no password change needed + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + existingHash, err := auth.HashPassword("testpassword") + require.NoError(t, err) + + cfg := &config.Config{ + Limits: config.DefaultLimits(), + Server: &config.ServerConfig{ + Port: 8374, + PasswordHash: existingHash, + }, + } + + // Enable server with different port, password already set + err = handleResumeServerPassword(tmpDir, cfg, true, false, 9999) + require.NoError(t, err) + assert.Equal(t, 9999, cfg.Server.Port) +} diff --git a/internal/cli/start.go b/internal/cli/start.go index c3740ef..b5bf874 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -11,22 +11,27 @@ import ( "time" "github.com/spf13/cobra" + "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" "github.com/thruflo/wisp/internal/loop" + "github.com/thruflo/wisp/internal/server" "github.com/thruflo/wisp/internal/sprite" "github.com/thruflo/wisp/internal/state" "github.com/thruflo/wisp/internal/tui" ) var ( - startRepo string - startSpec string - startSiblingRepo []string - startBranch string - startTemplate string - startCheckpoint string - startHeadless bool - startContinue bool + startRepo string + startSpec string + startSiblingRepo []string + startBranch string + startTemplate string + startCheckpoint string + startHeadless bool + startContinue bool + startServer bool + startServerPort int + startSetPassword bool ) // HeadlessResult is the JSON output format for headless mode. @@ -50,10 +55,18 @@ and begins the iteration loop. The --repo and --spec flags are required. The spec path should be relative to the repository root and point to the RFC/specification document. +Remote Access: + Use --server to start a web server alongside the TUI for monitoring and + interacting with the session from any device (phone, tablet, another computer). + On first use, you'll be prompted to set a password. Use --port to customize + the server port (default: 8374). Use --password to change the password. + Example: wisp start --repo org/repo --spec docs/rfc.md wisp start --repo org/repo --spec docs/rfc.md --branch feature/my-feature - wisp start --repo org/repo --spec docs/rfc.md --sibling-repos org/other-repo`, + wisp start --repo org/repo --spec docs/rfc.md --sibling-repos org/other-repo + wisp start --repo org/repo --spec docs/rfc.md --server + wisp start --repo org/repo --spec docs/rfc.md --server --port 9000`, RunE: runStart, } @@ -66,6 +79,9 @@ func init() { startCmd.Flags().StringVarP(&startCheckpoint, "checkpoint", "c", "", "checkpoint ID to restore from") startCmd.Flags().BoolVar(&startHeadless, "headless", false, "run without TUI, print JSON result to stdout (for testing/CI)") startCmd.Flags().BoolVar(&startContinue, "continue", false, "continue on existing branch instead of creating new") + startCmd.Flags().BoolVar(&startServer, "server", false, "start web server alongside TUI for remote access") + startCmd.Flags().IntVar(&startServerPort, "port", config.DefaultServerPort, "web server port (requires --server)") + startCmd.Flags().BoolVar(&startSetPassword, "password", false, "prompt to set/change web server password") startCmd.MarkFlagRequired("repo") startCmd.MarkFlagRequired("spec") @@ -101,6 +117,13 @@ func runStart(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } + // Handle server mode and password setup + if startServer || startSetPassword { + if err := handleServerPassword(cwd, cfg, startServer, startSetPassword, startServerPort); err != nil { + return err + } + } + // Load settings settings, err := config.LoadSettings(cwd) if err != nil { @@ -210,8 +233,51 @@ func runStart(cmd *cobra.Command, args []string) error { // Create TUI t := tui.NewTUI(os.Stdout) + // Create web server if enabled + var srv *server.Server + if startServer { + var err error + srv, err = server.NewServerFromConfig(cfg.Server) + if err != nil { + return fmt.Errorf("failed to create web server: %w", err) + } + + // Start server in background + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.Start(ctx) + }() + + // Give the server a moment to start and check for errors + select { + case err := <-serverErrCh: + return fmt.Errorf("web server failed to start: %w", err) + case <-time.After(100 * time.Millisecond): + // Server started successfully + } + + fmt.Printf("Web server running at http://localhost:%d\n", cfg.Server.Port) + + // Ensure server is stopped when we exit + defer func() { + if err := srv.Stop(); err != nil { + fmt.Printf("Warning: failed to stop web server: %v\n", err) + } + }() + } + // Create and run loop - l := loop.NewLoop(client, syncMgr, store, cfg, session, t, repoPath, templateDir) + l := loop.NewLoopWithOptions(loop.LoopOptions{ + Client: client, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: t, + Server: srv, + RepoPath: repoPath, + TemplateDir: templateDir, + }) fmt.Printf("Starting iteration loop...\n") @@ -676,3 +742,49 @@ func RunCreateTasksPrompt(ctx context.Context, client sprite.Client, session *co return sprite.RunTasksPrompt(ctx, client, session.SpriteName, repoPath, createTasksPath, "RFC path: "+RemoteSpecPath, contextPath, 50) } + +// handleServerPassword handles password setup for the web server. +// It prompts for a password if needed and saves the hash to config. +func handleServerPassword(basePath string, cfg *config.Config, serverEnabled, setPassword bool, port int) error { + // Initialize server config if not present + if cfg.Server == nil { + cfg.Server = config.DefaultServerConfig() + } + + // Update port from flag + cfg.Server.Port = port + + // Check if we need to prompt for password + needsPassword := false + + if setPassword { + // User explicitly wants to set/change password + needsPassword = true + } else if serverEnabled && cfg.Server.PasswordHash == "" { + // Server mode enabled but no password configured + needsPassword = true + } + + if needsPassword { + password, err := auth.PromptAndConfirmPassword() + if err != nil { + return fmt.Errorf("password setup failed: %w", err) + } + + hash, err := auth.HashPassword(password) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + cfg.Server.PasswordHash = hash + + // Save the updated config + if err := config.SaveConfig(basePath, cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("Password saved to config.") + } + + return nil +} diff --git a/internal/config/loader.go b/internal/config/loader.go index e061418..ad736f0 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -18,6 +18,7 @@ const ( DefaultMaxBudgetUSD = 20.0 DefaultMaxDurationHours = 4.0 DefaultNoProgressThreshold = 3 + DefaultServerPort = 8374 ) // DefaultLimits returns limits with sensible default values. @@ -30,6 +31,13 @@ func DefaultLimits() Limits { } } +// DefaultServerConfig returns a ServerConfig with sensible default values. +func DefaultServerConfig() *ServerConfig { + return &ServerConfig{ + Port: DefaultServerPort, + } +} + // DefaultConfig returns a Config with sensible default values. func DefaultConfig() Config { return Config{ @@ -88,6 +96,22 @@ func ValidateConfig(cfg *Config) error { if cfg.Limits.NoProgressThreshold <= 0 { return ValidationError{Field: "limits.no_progress_threshold", Message: "must be positive"} } + + // Validate server config if present + if cfg.Server != nil { + if err := ValidateServerConfig(cfg.Server); err != nil { + return err + } + } + + return nil +} + +// ValidateServerConfig checks that server config values are valid. +func ValidateServerConfig(cfg *ServerConfig) error { + if cfg.Port < 0 || cfg.Port > 65535 { + return ValidationError{Field: "server.port", Message: "must be between 0 and 65535"} + } return nil } @@ -219,3 +243,24 @@ func IsValidationError(err error) bool { var ve ValidationError return errors.As(err, &ve) } + +// SaveConfig writes the config to .wisp/config.yaml at the given base path. +// Creates the .wisp directory if it doesn't exist. +func SaveConfig(basePath string, cfg *Config) error { + wispDir := filepath.Join(basePath, ".wisp") + if err := os.MkdirAll(wispDir, 0o755); err != nil { + return fmt.Errorf("failed to create .wisp directory: %w", err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + configPath := filepath.Join(wispDir, "config.yaml") + if err := os.WriteFile(configPath, data, 0o644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 8223512..46faa27 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -492,3 +492,301 @@ func TestIsValidationError(t *testing.T) { assert.True(t, IsValidationError(ve)) assert.False(t, IsValidationError(os.ErrNotExist)) } + +func TestLoadConfig_WithServerConfig(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + configContent := `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +server: + port: 9000 + password_hash: "$argon2id$v=19$m=65536,t=3,p=4$testsalt$testhash" +` + require.NoError(t, os.WriteFile(filepath.Join(wispDir, "config.yaml"), []byte(configContent), 0o644)) + + cfg, err := LoadConfig(tmpDir) + require.NoError(t, err) + + require.NotNil(t, cfg.Server) + assert.Equal(t, 9000, cfg.Server.Port) + assert.Equal(t, "$argon2id$v=19$m=65536,t=3,p=4$testsalt$testhash", cfg.Server.PasswordHash) +} + +func TestLoadConfig_ServerConfigOptional(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + // Config without server section + configContent := `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +` + require.NoError(t, os.WriteFile(filepath.Join(wispDir, "config.yaml"), []byte(configContent), 0o644)) + + cfg, err := LoadConfig(tmpDir) + require.NoError(t, err) + + // Server should be nil when not configured + assert.Nil(t, cfg.Server) +} + +func TestLoadConfig_ServerConfigPartial(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + // Config with only port set + configContent := `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +server: + port: 8080 +` + require.NoError(t, os.WriteFile(filepath.Join(wispDir, "config.yaml"), []byte(configContent), 0o644)) + + cfg, err := LoadConfig(tmpDir) + require.NoError(t, err) + + require.NotNil(t, cfg.Server) + assert.Equal(t, 8080, cfg.Server.Port) + assert.Empty(t, cfg.Server.PasswordHash) +} + +func TestLoadConfig_ServerConfigValidationError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + field string + }{ + { + name: "invalid port negative", + content: `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +server: + port: -1 +`, + field: "server.port", + }, + { + name: "invalid port too high", + content: `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +server: + port: 65536 +`, + field: "server.port", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(wispDir, "config.yaml"), []byte(tt.content), 0o644)) + + _, err := LoadConfig(tmpDir) + require.Error(t, err) + assert.True(t, IsValidationError(err)) + + var ve ValidationError + require.ErrorAs(t, err, &ve) + assert.Equal(t, tt.field, ve.Field) + }) + } +} + +func TestDefaultServerConfig(t *testing.T) { + t.Parallel() + + cfg := DefaultServerConfig() + assert.Equal(t, DefaultServerPort, cfg.Port) + assert.Empty(t, cfg.PasswordHash) +} + +func TestValidateServerConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *ServerConfig + wantErr bool + field string + }{ + { + name: "valid default port", + config: &ServerConfig{Port: 8374}, + wantErr: false, + }, + { + name: "valid port 0 (dynamic)", + config: &ServerConfig{Port: 0}, + wantErr: false, + }, + { + name: "valid max port", + config: &ServerConfig{Port: 65535}, + wantErr: false, + }, + { + name: "invalid negative port", + config: &ServerConfig{Port: -1}, + wantErr: true, + field: "server.port", + }, + { + name: "invalid port too high", + config: &ServerConfig{Port: 65536}, + wantErr: true, + field: "server.port", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateServerConfig(tt.config) + if tt.wantErr { + require.Error(t, err) + var ve ValidationError + require.ErrorAs(t, err, &ve) + assert.Equal(t, tt.field, ve.Field) + return + } + assert.NoError(t, err) + }) + } +} + +func TestSaveConfig(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + cfg := &Config{ + Limits: Limits{ + MaxIterations: 100, + MaxBudgetUSD: 50.0, + MaxDurationHours: 8.0, + NoProgressThreshold: 5, + }, + } + + err := SaveConfig(tmpDir, cfg) + require.NoError(t, err) + + // Load it back + loaded, err := LoadConfig(tmpDir) + require.NoError(t, err) + + assert.Equal(t, cfg.Limits.MaxIterations, loaded.Limits.MaxIterations) + assert.Equal(t, cfg.Limits.MaxBudgetUSD, loaded.Limits.MaxBudgetUSD) +} + +func TestSaveConfig_WithServer(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + cfg := &Config{ + Limits: Limits{ + MaxIterations: 50, + MaxBudgetUSD: 20.0, + MaxDurationHours: 4.0, + NoProgressThreshold: 3, + }, + Server: &ServerConfig{ + Port: 9000, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$testsalt$testhash", + }, + } + + err := SaveConfig(tmpDir, cfg) + require.NoError(t, err) + + // Load it back + loaded, err := LoadConfig(tmpDir) + require.NoError(t, err) + + require.NotNil(t, loaded.Server) + assert.Equal(t, 9000, loaded.Server.Port) + assert.Equal(t, "$argon2id$v=19$m=65536,t=3,p=4$testsalt$testhash", loaded.Server.PasswordHash) +} + +func TestSaveConfig_CreatesWispDir(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + cfg := DefaultConfig() + err := SaveConfig(tmpDir, &cfg) + require.NoError(t, err) + + // Verify .wisp directory was created + wispDir := filepath.Join(tmpDir, ".wisp") + info, err := os.Stat(wispDir) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestSaveConfig_OverwritesExisting(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Save initial config + cfg1 := &Config{ + Limits: Limits{ + MaxIterations: 10, + MaxBudgetUSD: 5.0, + MaxDurationHours: 1.0, + NoProgressThreshold: 1, + }, + } + err := SaveConfig(tmpDir, cfg1) + require.NoError(t, err) + + // Save updated config + cfg2 := &Config{ + Limits: Limits{ + MaxIterations: 200, + MaxBudgetUSD: 100.0, + MaxDurationHours: 24.0, + NoProgressThreshold: 10, + }, + } + err = SaveConfig(tmpDir, cfg2) + require.NoError(t, err) + + // Load and verify it's the second config + loaded, err := LoadConfig(tmpDir) + require.NoError(t, err) + assert.Equal(t, 200, loaded.Limits.MaxIterations) +} diff --git a/internal/config/types.go b/internal/config/types.go index 6db3877..7a60962 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -13,9 +13,16 @@ type Limits struct { NoProgressThreshold int `yaml:"no_progress_threshold"` } +// ServerConfig defines optional web server configuration. +type ServerConfig struct { + Port int `yaml:"port"` + PasswordHash string `yaml:"password_hash"` +} + // Config represents the .wisp/config.yaml file. type Config struct { - Limits Limits `yaml:"limits"` + Limits Limits `yaml:"limits"` + Server *ServerConfig `yaml:"server,omitempty"` } // Permissions defines Claude Code permission rules. diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 20bef03..914ee71 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -448,6 +448,122 @@ func TestConfig_YAMLRoundTrip(t *testing.T) { assert.Equal(t, config, got) } +func TestConfig_WithServer_YAMLRoundTrip(t *testing.T) { + t.Parallel() + + config := Config{ + Limits: Limits{ + MaxIterations: 50, + MaxBudgetUSD: 20.00, + MaxDurationHours: 4, + NoProgressThreshold: 3, + }, + Server: &ServerConfig{ + Port: 9000, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$salt$hash", + }, + } + + data, err := yaml.Marshal(config) + require.NoError(t, err) + + var got Config + err = yaml.Unmarshal(data, &got) + require.NoError(t, err) + assert.Equal(t, config, got) +} + +func TestServerConfig_YAMLMarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config ServerConfig + want string + }{ + { + name: "full config", + config: ServerConfig{ + Port: 8374, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$salt$hash", + }, + want: `port: 8374 +password_hash: $argon2id$v=19$m=65536,t=3,p=4$salt$hash +`, + }, + { + name: "port only", + config: ServerConfig{ + Port: 9000, + }, + want: `port: 9000 +password_hash: "" +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := yaml.Marshal(tt.config) + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func TestServerConfig_YAMLUnmarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want ServerConfig + wantErr bool + }{ + { + name: "full config", + input: `port: 8374 +password_hash: "$argon2id$v=19$m=65536,t=3,p=4$salt$hash" +`, + want: ServerConfig{ + Port: 8374, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$salt$hash", + }, + wantErr: false, + }, + { + name: "port only", + input: `port: 9000 +`, + want: ServerConfig{ + Port: 9000, + }, + wantErr: false, + }, + { + name: "invalid yaml", + input: `port: [`, + want: ServerConfig{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got ServerConfig + err := yaml.Unmarshal([]byte(tt.input), &got) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + func TestSettings_JSONRoundTrip(t *testing.T) { t.Parallel() diff --git a/internal/loop/loop.go b/internal/loop/loop.go index 9fe0f12..63f494e 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -12,6 +12,7 @@ import ( "time" "github.com/thruflo/wisp/internal/config" + "github.com/thruflo/wisp/internal/server" "github.com/thruflo/wisp/internal/sprite" "github.com/thruflo/wisp/internal/state" "github.com/thruflo/wisp/internal/tui" @@ -98,12 +99,14 @@ type Loop struct { cfg *config.Config session *config.Session tui *tui.TUI - repoPath string // Path on Sprite: /var/local/wisp/repos// - wispPath string // Path on Sprite: /.wisp + server *server.Server // Optional web server for remote access + repoPath string // Path on Sprite: /var/local/wisp/repos// + wispPath string // Path on Sprite: /.wisp iteration int startTime time.Time templateDir string // Local path to templates claudeCfg ClaudeConfig // Claude command configuration + eventSeq int // Sequence counter for Claude events } // LoopOptions holds configuration for creating a Loop instance. @@ -115,6 +118,7 @@ type LoopOptions struct { Config *config.Config Session *config.Session TUI *tui.TUI + Server *server.Server // Optional: web server for remote access RepoPath string TemplateDir string StartTime time.Time // Optional: for deterministic time-based testing @@ -160,6 +164,7 @@ func NewLoopWithOptions(opts LoopOptions) *Loop { cfg: opts.Config, session: opts.Session, tui: opts.TUI, + server: opts.Server, repoPath: opts.RepoPath, wispPath: filepath.Join(opts.RepoPath, ".wisp"), templateDir: opts.TemplateDir, @@ -225,6 +230,9 @@ func (l *Loop) Run(ctx context.Context) Result { } } + // Broadcast state to web clients if server is running + l.broadcastState(iterResult) + // Record history if err := l.recordHistory(ctx, iterResult); err != nil { // Non-fatal, continue @@ -398,6 +406,9 @@ func (l *Loop) streamOutput(ctx context.Context, r io.ReadCloser) error { l.tui.AppendTailLine(displayLine) l.tui.Update() } + + // Broadcast Claude event to web clients if server is running + l.broadcastClaudeEvent(line) } return scanner.Err() @@ -634,8 +645,28 @@ func (l *Loop) handleNeedsInput(ctx context.Context, st *state.State) Result { l.tui.Bell() l.tui.Update() - // Wait for user input + // Broadcast input request to web clients and get request ID + requestID := l.broadcastInputRequest(st.Question) + + // Wait for user input from TUI or web client for { + // Check for web client input + if l.server != nil && requestID != "" { + if response, ok := l.server.GetPendingInput(requestID); ok { + // Web client provided input + if err := l.sync.WriteResponseToSprite(ctx, l.session.SpriteName, response); err != nil { + return Result{ + Reason: ExitReasonCrash, + Iterations: l.iteration, + Error: fmt.Errorf("failed to write response: %w", err), + } + } + // Broadcast that input request was responded + l.broadcastInputResponded(requestID, response) + return Result{Reason: ExitReasonUnknown} + } + } + select { case <-ctx.Done(): return Result{Reason: ExitReasonBackground, Iterations: l.iteration} @@ -643,6 +674,10 @@ func (l *Loop) handleNeedsInput(ctx context.Context, st *state.State) Result { case action := <-l.tui.Actions(): switch action.Action { case tui.ActionSubmitInput: + // Mark as responded in server first (for first-response-wins) + if l.server != nil && requestID != "" { + l.server.MarkInputResponded(requestID) + } // Write response to Sprite if err := l.sync.WriteResponseToSprite(ctx, l.session.SpriteName, action.Input); err != nil { return Result{ @@ -651,7 +686,8 @@ func (l *Loop) handleNeedsInput(ctx context.Context, st *state.State) Result { Error: fmt.Errorf("failed to write response: %w", err), } } - // Continue loop + // Broadcast that input request was responded + l.broadcastInputResponded(requestID, action.Input) return Result{Reason: ExitReasonUnknown} case tui.ActionCancelInput: @@ -666,6 +702,10 @@ func (l *Loop) handleNeedsInput(ctx context.Context, st *state.State) Result { case tui.ActionBackground, tui.ActionQuit: return Result{Reason: ExitReasonBackground, Iterations: l.iteration} } + + case <-time.After(100 * time.Millisecond): + // Poll for web client input periodically + continue } } } @@ -757,3 +797,166 @@ var ( errUserKill = errors.New("user killed session") errUserBackground = errors.New("user backgrounded session") ) + +// broadcastState broadcasts session and task state to web clients. +// This is called after each state sync to keep web clients up to date. +func (l *Loop) broadcastState(st *state.State) { + if l.server == nil { + return + } + + streams := l.server.Streams() + if streams == nil { + return + } + + // Map state.State status to server.SessionStatus + var status server.SessionStatus + switch st.Status { + case state.StatusDone: + status = server.SessionStatusDone + case state.StatusNeedsInput: + status = server.SessionStatusNeedsInput + case state.StatusBlocked: + status = server.SessionStatusBlocked + default: + status = server.SessionStatusRunning + } + + // Broadcast session state + session := &server.Session{ + ID: l.session.Branch, + Repo: l.session.Repo, + Branch: l.session.Branch, + Spec: l.session.Spec, + Status: status, + Iteration: l.iteration, + StartedAt: l.session.StartedAt.Format(time.RFC3339), + } + streams.BroadcastSession(session) + + // Broadcast tasks + tasks, err := l.store.LoadTasks(l.session.Branch) + if err != nil { + return + } + + for i, t := range tasks { + var taskStatus server.TaskStatus + if t.Passes { + taskStatus = server.TaskStatusCompleted + } else { + // The first incomplete task is considered in progress + foundIncomplete := false + for j := 0; j < i; j++ { + if !tasks[j].Passes { + foundIncomplete = true + break + } + } + if !foundIncomplete && !t.Passes { + taskStatus = server.TaskStatusInProgress + } else { + taskStatus = server.TaskStatusPending + } + } + + task := &server.Task{ + ID: fmt.Sprintf("%s-task-%d", l.session.Branch, i), + SessionID: l.session.Branch, + Order: i, + Content: t.Description, + Status: taskStatus, + } + streams.BroadcastTask(task) + } +} + +// broadcastClaudeEvent broadcasts a Claude output line to web clients. +func (l *Loop) broadcastClaudeEvent(line string) { + if l.server == nil { + return + } + + streams := l.server.Streams() + if streams == nil { + return + } + + // Skip empty lines + line = strings.TrimSpace(line) + if line == "" { + return + } + + // Try to parse as JSON to pass through raw SDK message + var sdkMessage any + if err := json.Unmarshal([]byte(line), &sdkMessage); err != nil { + // Not valid JSON, skip + return + } + + // Increment sequence for this iteration + l.eventSeq++ + + event := &server.ClaudeEvent{ + ID: fmt.Sprintf("%s-%d-%d", l.session.Branch, l.iteration, l.eventSeq), + SessionID: l.session.Branch, + Iteration: l.iteration, + Sequence: l.eventSeq, + Message: sdkMessage, + Timestamp: time.Now().Format(time.RFC3339), + } + + streams.BroadcastClaudeEvent(event) +} + +// broadcastInputRequest broadcasts an input request to web clients. +// Returns the request ID for tracking responses. +func (l *Loop) broadcastInputRequest(question string) string { + if l.server == nil { + return "" + } + + streams := l.server.Streams() + if streams == nil { + return "" + } + + requestID := fmt.Sprintf("%s-%d-input", l.session.Branch, l.iteration) + + req := &server.InputRequest{ + ID: requestID, + SessionID: l.session.Branch, + Iteration: l.iteration, + Question: question, + Responded: false, + Response: nil, + } + + streams.BroadcastInputRequest(req) + return requestID +} + +// broadcastInputResponded broadcasts that an input request has been responded to. +func (l *Loop) broadcastInputResponded(requestID, response string) { + if l.server == nil || requestID == "" { + return + } + + streams := l.server.Streams() + if streams == nil { + return + } + + req := &server.InputRequest{ + ID: requestID, + SessionID: l.session.Branch, + Iteration: l.iteration, + Question: "", // Question is not needed for update + Responded: true, + Response: &response, + } + + streams.BroadcastInputRequest(req) +} diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go index 3a58b77..67068c2 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -12,7 +12,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" + "github.com/thruflo/wisp/internal/server" "github.com/thruflo/wisp/internal/sprite" "github.com/thruflo/wisp/internal/state" "github.com/thruflo/wisp/internal/tui" @@ -1291,3 +1293,578 @@ func TestLoopRunWithInjectedStartTime(t *testing.T) { assert.Equal(t, ExitReasonBackground, result.Reason) }) } + +// TestBroadcastState tests that session and task state is broadcast to the server. +func TestBroadcastState(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-broadcast" + + // Create session + startTime := time.Now() + session := &config.Session{ + Repo: "org/repo", + Branch: branch, + Spec: "docs/spec.md", + SpriteName: "wisp-test", + StartedAt: startTime, + } + require.NoError(t, store.CreateSession(session)) + + // Create tasks + tasks := []state.Task{ + {Description: "Task 1", Passes: true}, + {Description: "Task 2", Passes: false}, + {Description: "Task 3", Passes: false}, + } + require.NoError(t, store.SaveTasks(branch, tasks)) + + // Create server + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, // Auto-assign port + PasswordHash: hash, + }) + require.NoError(t, err) + + // Create loop with server + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{ + Limits: config.Limits{ + MaxIterations: 10, + }, + } + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + StartTime: startTime, + }) + loop.iteration = 5 + + // Test broadcastState + st := &state.State{ + Status: state.StatusContinue, + Summary: "Working on task 2", + } + + loop.broadcastState(st) + + // Verify session was broadcast + sessions, broadcastTasks, _ := srv.Streams().GetCurrentState() + require.Len(t, sessions, 1, "expected 1 session") + assert.Equal(t, branch, sessions[0].ID) + assert.Equal(t, "org/repo", sessions[0].Repo) + assert.Equal(t, branch, sessions[0].Branch) + assert.Equal(t, "docs/spec.md", sessions[0].Spec) + assert.Equal(t, server.SessionStatusRunning, sessions[0].Status) + assert.Equal(t, 5, sessions[0].Iteration) + + // Verify tasks were broadcast + require.Len(t, broadcastTasks, 3, "expected 3 tasks") + + // Find tasks by order + tasksByOrder := make(map[int]*server.Task) + for _, task := range broadcastTasks { + tasksByOrder[task.Order] = task + } + + // Task 0 (completed) + assert.Equal(t, server.TaskStatusCompleted, tasksByOrder[0].Status) + assert.Equal(t, "Task 1", tasksByOrder[0].Content) + + // Task 1 (in progress - first incomplete) + assert.Equal(t, server.TaskStatusInProgress, tasksByOrder[1].Status) + assert.Equal(t, "Task 2", tasksByOrder[1].Content) + + // Task 2 (pending) + assert.Equal(t, server.TaskStatusPending, tasksByOrder[2].Status) + assert.Equal(t, "Task 3", tasksByOrder[2].Content) +} + +// TestBroadcastStateNeedsInput tests that NEEDS_INPUT status is correctly broadcast. +func TestBroadcastStateNeedsInput(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-needs-input-broadcast" + + session := &config.Session{ + Repo: "org/repo", + Branch: branch, + SpriteName: "wisp-test", + StartedAt: time.Now(), + } + require.NoError(t, store.CreateSession(session)) + + // Create server + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + // Test with NEEDS_INPUT state + st := &state.State{ + Status: state.StatusNeedsInput, + Summary: "Awaiting input", + Question: "What database?", + } + + loop.broadcastState(st) + + sessions, _, _ := srv.Streams().GetCurrentState() + require.Len(t, sessions, 1) + assert.Equal(t, server.SessionStatusNeedsInput, sessions[0].Status) +} + +// TestBroadcastStateBlocked tests that BLOCKED status is correctly broadcast. +func TestBroadcastStateBlocked(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-blocked-broadcast" + + session := &config.Session{ + Repo: "org/repo", + Branch: branch, + SpriteName: "wisp-test", + StartedAt: time.Now(), + } + require.NoError(t, store.CreateSession(session)) + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + st := &state.State{ + Status: state.StatusBlocked, + Error: "Missing dependency", + } + + loop.broadcastState(st) + + sessions, _, _ := srv.Streams().GetCurrentState() + require.Len(t, sessions, 1) + assert.Equal(t, server.SessionStatusBlocked, sessions[0].Status) +} + +// TestBroadcastStateDone tests that DONE status is correctly broadcast. +func TestBroadcastStateDone(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-done-broadcast" + + session := &config.Session{ + Repo: "org/repo", + Branch: branch, + SpriteName: "wisp-test", + StartedAt: time.Now(), + } + require.NoError(t, store.CreateSession(session)) + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + st := &state.State{ + Status: state.StatusDone, + Summary: "All tasks completed", + } + + loop.broadcastState(st) + + sessions, _, _ := srv.Streams().GetCurrentState() + require.Len(t, sessions, 1) + assert.Equal(t, server.SessionStatusDone, sessions[0].Status) +} + +// TestBroadcastStateNoServer tests that broadcastState is a no-op without server. +func TestBroadcastStateNoServer(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-no-server" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + // No server + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: nil, // No server + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + // Should not panic + st := &state.State{Status: state.StatusContinue} + loop.broadcastState(st) +} + +// TestBroadcastClaudeEvent tests that Claude events are broadcast to the server. +func TestBroadcastClaudeEvent(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-claude-event" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + loop.iteration = 3 + + // Broadcast a Claude event (stream-json format) + jsonLine := `{"type":"assistant","message":{"content":[{"type":"text","text":"Hello, world!"}]}}` + loop.broadcastClaudeEvent(jsonLine) + + // Sequence should increment + assert.Equal(t, 1, loop.eventSeq) + + // Broadcast another event + jsonLine2 := `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}` + loop.broadcastClaudeEvent(jsonLine2) + + assert.Equal(t, 2, loop.eventSeq) +} + +// TestBroadcastClaudeEventSkipsInvalidJSON tests that non-JSON lines are skipped. +func TestBroadcastClaudeEventSkipsInvalidJSON(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-invalid-json" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + // Broadcast invalid JSON - should not increment sequence + loop.broadcastClaudeEvent("not valid json") + assert.Equal(t, 0, loop.eventSeq) + + // Empty line + loop.broadcastClaudeEvent("") + assert.Equal(t, 0, loop.eventSeq) + + // Whitespace only + loop.broadcastClaudeEvent(" ") + assert.Equal(t, 0, loop.eventSeq) +} + +// TestBroadcastInputRequest tests that input requests are broadcast. +func TestBroadcastInputRequest(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-input-request" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + loop.iteration = 7 + + // Broadcast input request + requestID := loop.broadcastInputRequest("What database should we use?") + + assert.Equal(t, "test-input-request-7-input", requestID) + + // Verify input request was broadcast + _, _, inputRequests := srv.Streams().GetCurrentState() + require.Len(t, inputRequests, 1) + assert.Equal(t, requestID, inputRequests[0].ID) + assert.Equal(t, branch, inputRequests[0].SessionID) + assert.Equal(t, 7, inputRequests[0].Iteration) + assert.Equal(t, "What database should we use?", inputRequests[0].Question) + assert.False(t, inputRequests[0].Responded) + assert.Nil(t, inputRequests[0].Response) +} + +// TestBroadcastInputResponded tests that input responses are broadcast. +func TestBroadcastInputResponded(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-input-responded" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + loop.iteration = 4 + + // First broadcast the request + requestID := loop.broadcastInputRequest("Question?") + + // Then broadcast the response + loop.broadcastInputResponded(requestID, "Answer!") + + // Verify input request was updated + _, _, inputRequests := srv.Streams().GetCurrentState() + require.Len(t, inputRequests, 1) + assert.True(t, inputRequests[0].Responded) + require.NotNil(t, inputRequests[0].Response) + assert.Equal(t, "Answer!", *inputRequests[0].Response) +} + +// TestBroadcastInputRequestNoServer tests that broadcastInputRequest handles no server. +func TestBroadcastInputRequestNoServer(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-no-server-input" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + // No server + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: nil, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + // Should return empty string + requestID := loop.broadcastInputRequest("Question?") + assert.Equal(t, "", requestID) + + // broadcastInputResponded should be a no-op + loop.broadcastInputResponded("some-id", "response") // Should not panic +} + +// TestLoopWithServerOption tests that NewLoopWithOptions correctly stores server. +func TestLoopWithServerOption(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + session := &config.Session{Branch: "test-branch"} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + assert.NotNil(t, loop.server) + assert.Equal(t, srv, loop.server) +} diff --git a/internal/server/doc.go b/internal/server/doc.go new file mode 100644 index 0000000..8806a5e --- /dev/null +++ b/internal/server/doc.go @@ -0,0 +1,21 @@ +// Package server provides a web server for remote monitoring and interaction +// with wisp sessions. +// +// The server enables developers to monitor and interact with active wisp sessions +// from any device (phone, tablet, another computer) while away from the machine +// running wisp. It serves a React web client and exposes endpoints for +// authentication, state streaming via Durable Streams, and user input. +// +// # Endpoints +// +// - POST /auth - Password authentication, returns session token +// - GET /stream - Durable Streams endpoint for real-time state updates +// - POST /input - Submit user response to NEEDS_INPUT prompts +// - GET / - Serve embedded web assets (React client) +// +// # Authentication +// +// The server uses password-based authentication with argon2id hashing. +// Clients POST their password to /auth and receive a session token that +// must be included in subsequent requests via the Authorization header. +package server diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..68204f0 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,835 @@ +// Package server provides a web server for remote monitoring and interaction +// with wisp sessions. It serves a React web client and exposes endpoints for +// authentication, state streaming via Durable Streams, and user input. +package server + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/durable-streams/durable-streams/packages/caddy-plugin/store" + "github.com/thruflo/wisp/internal/auth" + "github.com/thruflo/wisp/internal/config" + "github.com/thruflo/wisp/web" +) + +// Server represents the web server for remote access to wisp sessions. +type Server struct { + port int + passwordHash string + + // HTTP server + server *http.Server + listener net.Listener + + // Token management + mu sync.RWMutex + tokens map[string]time.Time // token -> expiry time + + // Durable Streams + streams *StreamManager + + // Input handling + inputMu sync.Mutex + pendingInputs map[string]string // request_id -> response + respondedInputs map[string]bool // request_id -> true if already responded + + // Static assets filesystem + assets fs.FS + + // Lifecycle + started bool +} + +// Config holds server configuration options. +type Config struct { + Port int + PasswordHash string + Assets fs.FS // Optional: static assets filesystem. If nil, uses embedded assets. +} + +// NewServer creates a new Server instance. +func NewServer(cfg *Config) (*Server, error) { + if cfg == nil { + return nil, errors.New("config is required") + } + if cfg.PasswordHash == "" { + return nil, errors.New("password hash is required") + } + + streams, err := NewStreamManager() + if err != nil { + return nil, fmt.Errorf("failed to create stream manager: %w", err) + } + + // Use provided assets or default to embedded web assets + assets := cfg.Assets + if assets == nil { + assets = web.GetAssets("") + } + + return &Server{ + port: cfg.Port, + passwordHash: cfg.PasswordHash, + tokens: make(map[string]time.Time), + streams: streams, + assets: assets, + }, nil +} + +// NewServerFromConfig creates a new Server from a config.ServerConfig. +func NewServerFromConfig(cfg *config.ServerConfig) (*Server, error) { + if cfg == nil { + return nil, errors.New("server config is required") + } + return NewServer(&Config{ + Port: cfg.Port, + PasswordHash: cfg.PasswordHash, + }) +} + +// Port returns the configured port. +func (s *Server) Port() int { + return s.port +} + +// Start starts the HTTP server. +// The server runs until ctx is cancelled or Stop is called. +func (s *Server) Start(ctx context.Context) error { + s.mu.Lock() + if s.started { + s.mu.Unlock() + return errors.New("server already started") + } + + // Create listener + addr := fmt.Sprintf(":%d", s.port) + listener, err := net.Listen("tcp", addr) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + s.listener = listener + + // Setup HTTP server with routes + mux := http.NewServeMux() + s.setupRoutes(mux) + + s.server = &http.Server{ + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + s.started = true + s.mu.Unlock() + + // Start cleanup goroutine for expired tokens + go s.cleanupExpiredTokens(ctx) + + // Run server (blocks until error or server closed) + err = s.server.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("server error: %w", err) + } + + return nil +} + +// Stop gracefully shuts down the server. +func (s *Server) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.started || s.server == nil { + return nil + } + + // Shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := s.server.Shutdown(ctx); err != nil { + return fmt.Errorf("shutdown error: %w", err) + } + + // Close the stream manager + if s.streams != nil { + s.streams.Close() + } + + s.started = false + return nil +} + +// Streams returns the StreamManager for broadcasting state. +func (s *Server) Streams() *StreamManager { + return s.streams +} + +// ListenAddr returns the actual address the server is listening on. +// Useful when port 0 is used to get an available port. +// Returns empty string if not started. +func (s *Server) ListenAddr() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.listener == nil { + return "" + } + return s.listener.Addr().String() +} + +// setupRoutes configures the HTTP routes. +func (s *Server) setupRoutes(mux *http.ServeMux) { + // Public endpoint + mux.HandleFunc("/auth", s.handleAuth) + + // Protected endpoints + mux.HandleFunc("/stream", s.withAuth(s.handleStream)) + mux.HandleFunc("/input", s.withAuth(s.handleInput)) + mux.HandleFunc("/", s.handleStatic) // Static assets are public for initial page load +} + +// withAuth wraps a handler with authentication middleware. +func (s *Server) withAuth(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Extract token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "authorization required", http.StatusUnauthorized) + return + } + + // Expect "Bearer " format + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + http.Error(w, "invalid authorization format", http.StatusUnauthorized) + return + } + + token := strings.TrimPrefix(authHeader, bearerPrefix) + if !s.ValidateToken(token) { + http.Error(w, "invalid or expired token", http.StatusUnauthorized) + return + } + + handler(w, r) + } +} + +// VerifyPassword checks if the provided password matches the stored hash. +func (s *Server) VerifyPassword(password string) (bool, error) { + return auth.VerifyPassword(password, s.passwordHash) +} + +// tokenExpiry is how long tokens are valid. +const tokenExpiry = 24 * time.Hour + +// GenerateToken creates a new authentication token. +func (s *Server) GenerateToken() (string, error) { + // Generate 32 bytes of random data (256 bits) + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate token: %w", err) + } + + token := hex.EncodeToString(bytes) + + // Store token with expiry + s.mu.Lock() + s.tokens[token] = time.Now().Add(tokenExpiry) + s.mu.Unlock() + + return token, nil +} + +// ValidateToken checks if a token is valid and not expired. +func (s *Server) ValidateToken(token string) bool { + if token == "" { + return false + } + + s.mu.RLock() + expiry, exists := s.tokens[token] + s.mu.RUnlock() + + if !exists { + return false + } + + return time.Now().Before(expiry) +} + +// RevokeToken removes a token from the valid tokens map. +func (s *Server) RevokeToken(token string) { + s.mu.Lock() + delete(s.tokens, token) + s.mu.Unlock() +} + +// cleanupExpiredTokens periodically removes expired tokens. +func (s *Server) cleanupExpiredTokens(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.mu.Lock() + now := time.Now() + for token, expiry := range s.tokens { + if now.After(expiry) { + delete(s.tokens, token) + } + } + s.mu.Unlock() + } + } +} + +// Protocol header names for Durable Streams +const ( + headerStreamNextOffset = "Stream-Next-Offset" + headerStreamUpToDate = "Stream-Up-To-Date" + headerStreamCursor = "Stream-Cursor" +) + +// handleStream handles GET /stream for Durable Streams. +// It supports catch-up, long-poll, and SSE modes. +func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Set CORS headers + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Expose-Headers", "Stream-Next-Offset, Stream-Up-To-Date, Stream-Cursor") + + // Get stream path + path := s.streams.StreamPath() + + // Parse offset from query + query := r.URL.Query() + offsetStr := query.Get("offset") + + var offset store.Offset + var err error + if offsetStr == "" { + offset = store.Offset{} // Start from beginning + } else { + offset, err = store.ParseOffset(offsetStr) + if err != nil { + http.Error(w, "invalid offset", http.StatusBadRequest) + return + } + } + + // Check for live mode + liveMode := query.Get("live") + + // Get current offset to check if we're at the tail + currentOffset, err := s.streams.GetCurrentOffset() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Handle offset=now + if offset.IsNow() { + offset = currentOffset + } + + // Handle SSE mode + if liveMode == "sse" { + s.handleSSE(w, r, path, offset) + return + } + + // Read messages + messages, upToDate, err := s.streams.Read(offset) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Handle long-poll mode + if liveMode == "long-poll" && len(messages) == 0 { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + messages, upToDate, err = s.streams.WaitForMessages(ctx, offset, 30*time.Second) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Timeout - return 204 with current offset + w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerStreamNextOffset, offset.String()) + w.Header().Set(headerStreamUpToDate, "true") + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + + // Calculate next offset + nextOffset := offset + if len(messages) > 0 { + nextOffset = messages[len(messages)-1].Offset + } else { + nextOffset = currentOffset + } + + // Set response headers + w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerStreamNextOffset, nextOffset.String()) + if upToDate || len(messages) == 0 { + w.Header().Set(headerStreamUpToDate, "true") + } + + // Format response as JSON array + body := formatJSONResponse(messages) + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +// handleSSE handles Server-Sent Events streaming. +func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request, path string, offset store.Offset) { + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + flusher.Flush() + + ctx := r.Context() + currentOffset := offset + sentInitialControl := false + + // Reconnect interval (60 seconds) + reconnectTimer := time.NewTimer(60 * time.Second) + defer reconnectTimer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-reconnectTimer.C: + return + default: + // Read any available messages + messages, upToDate, err := s.streams.Read(currentOffset) + if err != nil { + return + } + + if len(messages) > 0 { + // Send data event + body := formatJSONResponse(messages) + fmt.Fprintf(w, "event: data\n") + // Handle line terminators for SSE safety + for _, line := range strings.Split(string(body), "\n") { + fmt.Fprintf(w, "data:%s\n", line) + } + fmt.Fprintf(w, "\n") + + // Update current offset + currentOffset = messages[len(messages)-1].Offset + + // Send control event + control := map[string]interface{}{ + "streamNextOffset": currentOffset.String(), + } + if upToDate { + control["upToDate"] = true + } + controlJSON, _ := json.Marshal(control) + fmt.Fprintf(w, "event: control\n") + fmt.Fprintf(w, "data:%s\n\n", controlJSON) + + flusher.Flush() + sentInitialControl = true + } else if !sentInitialControl { + // Send initial control event + tailOffset, _ := s.streams.GetCurrentOffset() + control := map[string]interface{}{ + "streamNextOffset": tailOffset.String(), + "upToDate": true, + } + controlJSON, _ := json.Marshal(control) + fmt.Fprintf(w, "event: control\n") + fmt.Fprintf(w, "data:%s\n\n", controlJSON) + + flusher.Flush() + sentInitialControl = true + } + + // Wait for more data (100ms polling) + waitCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + s.streams.WaitForMessages(waitCtx, currentOffset, 100*time.Millisecond) + cancel() + } + } +} + +// formatJSONResponse formats messages as a JSON array. +func formatJSONResponse(messages []store.Message) []byte { + if len(messages) == 0 { + return []byte("[]") + } + return store.FormatJSONResponse(messages) +} + +// handleInput handles POST /input for user responses. +// Implements first-response-wins: if the request has already been responded to +// (either from web or TUI), subsequent responses are rejected. +func (s *Server) handleInput(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse JSON body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var req struct { + RequestID string `json:"request_id"` + Response string `json:"response"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + if req.RequestID == "" { + http.Error(w, "request_id is required", http.StatusBadRequest) + return + } + + // Use inputMu for input-specific operations (first-response-wins) + s.inputMu.Lock() + + // Check if this request has already been responded to + if s.respondedInputs != nil && s.respondedInputs[req.RequestID] { + s.inputMu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + fmt.Fprintf(w, `{"status":"already_responded"}`) + return + } + + // Initialize maps if needed + if s.pendingInputs == nil { + s.pendingInputs = make(map[string]string) + } + if s.respondedInputs == nil { + s.respondedInputs = make(map[string]bool) + } + + // Mark as responded and store the response + s.respondedInputs[req.RequestID] = true + s.pendingInputs[req.RequestID] = req.Response + s.inputMu.Unlock() + + // Broadcast that this input request has been responded to + // This allows web clients to see the updated state immediately + if s.streams != nil { + inputReq := &InputRequest{ + ID: req.RequestID, + Responded: true, + Response: &req.Response, + } + s.streams.BroadcastInputRequest(inputReq) + } + + // Return success + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"status":"received"}`) +} + +// GetPendingInput retrieves and removes a pending input response. +// This is called by the loop when polling for web client input. +func (s *Server) GetPendingInput(requestID string) (string, bool) { + s.inputMu.Lock() + defer s.inputMu.Unlock() + if s.pendingInputs == nil { + return "", false + } + response, ok := s.pendingInputs[requestID] + if ok { + delete(s.pendingInputs, requestID) + } + return response, ok +} + +// MarkInputResponded marks an input request as responded. +// This is called by the loop when the TUI provides input, to prevent +// subsequent web client responses from being accepted. +func (s *Server) MarkInputResponded(requestID string) { + s.inputMu.Lock() + defer s.inputMu.Unlock() + if s.respondedInputs == nil { + s.respondedInputs = make(map[string]bool) + } + s.respondedInputs[requestID] = true +} + +// IsInputResponded checks if an input request has already been responded to. +func (s *Server) IsInputResponded(requestID string) bool { + s.inputMu.Lock() + defer s.inputMu.Unlock() + if s.respondedInputs == nil { + return false + } + return s.respondedInputs[requestID] +} + +// handleStatic handles GET requests for serving static web assets. +// It serves files from the embedded or development assets filesystem. +// For SPA support, requests for non-existent paths return index.html. +func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Clean the path and remove leading slash + path := r.URL.Path + if path == "/" { + path = "index.html" + } else { + path = strings.TrimPrefix(path, "/") + } + + // Try to open the requested file + file, err := s.assets.Open(path) + if err != nil { + // File not found - for SPA support, serve index.html for HTML requests + // This allows client-side routing to work + if strings.Contains(r.Header.Get("Accept"), "text/html") || path == "" { + s.serveFile(w, r, "index.html") + return + } + http.NotFound(w, r) + return + } + file.Close() + + // Serve the file + s.serveFile(w, r, path) +} + +// serveFile serves a file from the assets filesystem. +func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, path string) { + file, err := s.assets.Open(path) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + // Get file info for size and modification time + stat, err := file.Stat() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Don't serve directories + if stat.IsDir() { + // Try index.html in the directory + indexPath := path + "/index.html" + if path == "" || path == "." { + indexPath = "index.html" + } + s.serveFile(w, r, indexPath) + return + } + + // Set content type based on extension + contentType := getContentType(path) + w.Header().Set("Content-Type", contentType) + + // Set cache headers for static assets + if isImmutableAsset(path) { + // Hashed assets can be cached indefinitely + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + // HTML and other files should be revalidated + w.Header().Set("Cache-Control", "no-cache") + } + + // Read and serve the file content + content, err := io.ReadAll(file) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + http.ServeContent(w, r, path, stat.ModTime(), strings.NewReader(string(content))) +} + +// getContentType returns the MIME type for a file based on its extension. +func getContentType(path string) string { + // Common web asset types + switch { + case strings.HasSuffix(path, ".html"): + return "text/html; charset=utf-8" + case strings.HasSuffix(path, ".css"): + return "text/css; charset=utf-8" + case strings.HasSuffix(path, ".js"): + return "application/javascript; charset=utf-8" + case strings.HasSuffix(path, ".json"): + return "application/json; charset=utf-8" + case strings.HasSuffix(path, ".svg"): + return "image/svg+xml" + case strings.HasSuffix(path, ".png"): + return "image/png" + case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"): + return "image/jpeg" + case strings.HasSuffix(path, ".gif"): + return "image/gif" + case strings.HasSuffix(path, ".ico"): + return "image/x-icon" + case strings.HasSuffix(path, ".woff"): + return "font/woff" + case strings.HasSuffix(path, ".woff2"): + return "font/woff2" + case strings.HasSuffix(path, ".ttf"): + return "font/ttf" + case strings.HasSuffix(path, ".webp"): + return "image/webp" + default: + return "application/octet-stream" + } +} + +// isImmutableAsset returns true if the asset path looks like a hashed asset +// that can be cached indefinitely (e.g., main.abc123.js, index-BcD123.js). +func isImmutableAsset(path string) bool { + // Get the filename without extension + // Vite-style: index-BcD123.js (hash after hyphen) + // Webpack-style: index.abc123.js (hash as middle segment) + + // Get just the filename + lastSlash := strings.LastIndex(path, "/") + if lastSlash >= 0 { + path = path[lastSlash+1:] + } + + // Remove extension + dotIdx := strings.LastIndex(path, ".") + if dotIdx <= 0 { + return false + } + nameWithoutExt := path[:dotIdx] + + // Check for Vite-style: name-HASH (hyphen separator) + hyphenIdx := strings.LastIndex(nameWithoutExt, "-") + if hyphenIdx > 0 { + hash := nameWithoutExt[hyphenIdx+1:] + if len(hash) >= 6 && len(hash) <= 16 && isAlphanumeric(hash) { + return true + } + } + + // Check for Webpack-style: name.HASH (dot separator with 3+ parts) + parts := strings.Split(nameWithoutExt, ".") + if len(parts) >= 2 { + hash := parts[len(parts)-1] + if len(hash) >= 6 && len(hash) <= 16 && isAlphanumeric(hash) { + return true + } + } + + return false +} + +// isAlphanumeric returns true if the string contains only alphanumeric characters. +func isAlphanumeric(s string) bool { + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return len(s) > 0 +} + +// handleAuth handles POST /auth for password authentication. +func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse JSON body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var req struct { + Password string `json:"password"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + if req.Password == "" { + http.Error(w, "password required", http.StatusBadRequest) + return + } + + password := req.Password + + // Verify password + valid, err := s.VerifyPassword(password) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !valid { + http.Error(w, "invalid password", http.StatusUnauthorized) + return + } + + // Generate token + token, err := s.GenerateToken() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Return token as JSON + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"token":"%s"}`, token) +} diff --git a/internal/server/server_integration_test.go b/internal/server/server_integration_test.go new file mode 100644 index 0000000..626e920 --- /dev/null +++ b/internal/server/server_integration_test.go @@ -0,0 +1,1065 @@ +//go:build integration + +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thruflo/wisp/internal/auth" +) + +// Integration tests for the web server. +// Run with: go test -tags=integration ./internal/server/... + +// TestIntegrationAuthFlow tests the complete authentication flow including +// correct password, wrong password, and token-based access to protected endpoints. +func TestIntegrationAuthFlow(t *testing.T) { + t.Parallel() + + // Create server with known password + hash, err := auth.HashPassword(testPassword) + require.NoError(t, err) + + server, err := NewServer(&Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Start server + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + require.NotEmpty(t, addr) + + t.Run("correct_password_returns_valid_token", func(t *testing.T) { + // Authenticate with correct password + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var authResp struct { + Token string `json:"token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&authResp)) + assert.NotEmpty(t, authResp.Token) + assert.Len(t, authResp.Token, 64) // 32 bytes hex encoded + + // Token should work for protected endpoints + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+authResp.Token) + streamResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer streamResp.Body.Close() + + assert.Equal(t, http.StatusOK, streamResp.StatusCode) + }) + + t.Run("incorrect_password_returns_401", func(t *testing.T) { + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(`{"password":"wrong-password"}`)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("empty_password_returns_400", func(t *testing.T) { + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(`{"password":""}`)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("invalid_token_returns_401", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer invalid-token-12345") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("no_auth_header_returns_401", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("multiple_tokens_all_valid", func(t *testing.T) { + // Get multiple tokens + var tokens []string + for i := 0; i < 3; i++ { + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) + require.NoError(t, err) + + var authResp struct { + Token string `json:"token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&authResp)) + resp.Body.Close() + tokens = append(tokens, authResp.Token) + } + + // All tokens should be unique + assert.NotEqual(t, tokens[0], tokens[1]) + assert.NotEqual(t, tokens[1], tokens[2]) + + // All tokens should work + for _, token := range tokens { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + } + }) +} + +// TestIntegrationStateSyncOnConnect tests that clients receive current state +// when connecting to the stream endpoint. +func TestIntegrationStateSyncOnConnect(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + // Broadcast state before client connects + session := &Session{ + ID: "test-session-1", + Repo: "owner/repo", + Branch: "wisp/feature-x", + Spec: "docs/rfc.md", + Status: SessionStatusRunning, + Iteration: 5, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, server.Streams().BroadcastSession(session)) + + tasks := []*Task{ + {ID: "task-1", SessionID: "test-session-1", Order: 0, Content: "Setup project", Status: TaskStatusCompleted}, + {ID: "task-2", SessionID: "test-session-1", Order: 1, Content: "Implement feature", Status: TaskStatusInProgress}, + {ID: "task-3", SessionID: "test-session-1", Order: 2, Content: "Write tests", Status: TaskStatusPending}, + } + for _, task := range tasks { + require.NoError(t, server.Streams().BroadcastTask(task)) + } + + inputReq := &InputRequest{ + ID: "input-1", + SessionID: "test-session-1", + Iteration: 5, + Question: "How should I proceed?", + Responded: false, + Response: nil, + } + require.NoError(t, server.Streams().BroadcastInputRequest(inputReq)) + + // Client connects and should receive all state from beginning + t.Run("new_client_receives_all_state", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Stream-Next-Offset")) + // Note: Stream-Up-To-Date may or may not be set depending on whether + // we've caught up with the tail. The important thing is we get all messages. + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + + // Should have 5 messages: 1 session + 3 tasks + 1 input request + assert.Len(t, messages, 5) + + // Verify session + sessionMsg := messages[0] + assert.Equal(t, MessageTypeSession, sessionMsg.Type) + + // Verify tasks + for i := 1; i <= 3; i++ { + assert.Equal(t, MessageTypeTask, messages[i].Type) + } + + // Verify input request + assert.Equal(t, MessageTypeInputRequest, messages[4].Type) + }) + + t.Run("client_with_offset_receives_only_new_messages", func(t *testing.T) { + // First request to get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Broadcast new message + newTask := &Task{ + ID: "task-4", + SessionID: "test-session-1", + Order: 3, + Content: "Deploy", + Status: TaskStatusPending, + } + require.NoError(t, server.Streams().BroadcastTask(newTask)) + + // Request with offset should only get new message + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + + assert.Len(t, messages, 1) + assert.Equal(t, MessageTypeTask, messages[0].Type) + }) +} + +// TestIntegrationRealTimeUpdatesViaStream tests that updates are received +// in real-time through the stream endpoint using long-polling. +func TestIntegrationRealTimeUpdatesViaStream(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("long_poll_receives_updates", func(t *testing.T) { + // Get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Start long-poll in goroutine + resultCh := make(chan []StreamMessage, 1) + errCh := make(chan error, 1) + + go func() { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=long-poll&offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + errCh <- err + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + errCh <- err + return + } + + var messages []StreamMessage + if err := json.Unmarshal(body, &messages); err != nil { + errCh <- err + return + } + resultCh <- messages + }() + + // Wait for long-poll to establish, then broadcast + time.Sleep(200 * time.Millisecond) + + session := &Session{ + ID: "realtime-session", + Repo: "owner/repo", + Branch: "wisp/realtime", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T12:00:00Z", + } + require.NoError(t, server.Streams().BroadcastSession(session)) + + // Wait for result + select { + case messages := <-resultCh: + require.Len(t, messages, 1) + assert.Equal(t, MessageTypeSession, messages[0].Type) + case err := <-errCh: + t.Fatalf("long-poll error: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("long-poll did not receive message in time") + } + }) + + t.Run("multiple_rapid_updates_delivered", func(t *testing.T) { + // Get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Broadcast multiple messages rapidly + for i := 0; i < 10; i++ { + task := &Task{ + ID: fmt.Sprintf("rapid-task-%d", i), + SessionID: "rapid-session", + Order: i, + Content: fmt.Sprintf("Task %d", i), + Status: TaskStatusPending, + } + require.NoError(t, server.Streams().BroadcastTask(task)) + } + + // Request should get all messages + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + assert.Len(t, messages, 10) + }) + + t.Run("claude_events_delivered_in_order", func(t *testing.T) { + // Get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Broadcast Claude events + for i := 0; i < 5; i++ { + event := &ClaudeEvent{ + ID: fmt.Sprintf("sess-1-%d", i), + SessionID: "sess", + Iteration: 1, + Sequence: i, + Message: map[string]any{ + "type": "assistant", + "message": map[string]any{ + "content": []any{ + map[string]any{ + "type": "text", + "text": fmt.Sprintf("Message %d", i), + }, + }, + }, + }, + Timestamp: time.Now().Format(time.RFC3339), + } + require.NoError(t, server.Streams().BroadcastClaudeEvent(event)) + } + + // Request should get all events in order + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + assert.Len(t, messages, 5) + + for i, msg := range messages { + assert.Equal(t, MessageTypeClaudeEvent, msg.Type) + dataBytes, _ := json.Marshal(msg.Data) + var evt ClaudeEvent + json.Unmarshal(dataBytes, &evt) + assert.Equal(t, i, evt.Sequence) + } + }) +} + +// TestIntegrationInputSubmission tests the input submission flow including +// storing responses and broadcasting updates. +func TestIntegrationInputSubmission(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("submit_input_stores_and_broadcasts", func(t *testing.T) { + // Get current offset to track broadcasts + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Submit input + body := `{"request_id": "submit-test-1", "response": "yes, proceed"}` + req, _ = http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result struct { + Status string `json:"status"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, "received", result.Status) + + // Verify response was stored + response, ok := server.GetPendingInput("submit-test-1") + assert.True(t, ok) + assert.Equal(t, "yes, proceed", response) + + // Verify broadcast was sent + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(bodyBytes, &messages)) + require.Len(t, messages, 1) + assert.Equal(t, MessageTypeInputRequest, messages[0].Type) + + dataBytes, _ := json.Marshal(messages[0].Data) + var inputReq InputRequest + json.Unmarshal(dataBytes, &inputReq) + assert.Equal(t, "submit-test-1", inputReq.ID) + assert.True(t, inputReq.Responded) + require.NotNil(t, inputReq.Response) + assert.Equal(t, "yes, proceed", *inputReq.Response) + }) + + t.Run("submit_with_empty_response", func(t *testing.T) { + body := `{"request_id": "empty-response-1", "response": ""}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Empty string response should still be stored + response, ok := server.GetPendingInput("empty-response-1") + assert.True(t, ok) + assert.Equal(t, "", response) + }) + + t.Run("submit_without_request_id_fails", func(t *testing.T) { + body := `{"response": "yes"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("submit_invalid_json_fails", func(t *testing.T) { + body := `{invalid json}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +// TestIntegrationConcurrentInputHandling tests the first-response-wins semantics +// for concurrent input from TUI and web clients. +func TestIntegrationConcurrentInputHandling(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("web_then_web_second_rejected", func(t *testing.T) { + requestID := "concurrent-web-web-1" + + // First web response + body := fmt.Sprintf(`{"request_id": "%s", "response": "first web response"}`, requestID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + + // Second web response should be rejected + body = fmt.Sprintf(`{"request_id": "%s", "response": "second web response"}`, requestID) + req, _ = http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + + var result struct { + Status string `json:"status"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, "already_responded", result.Status) + }) + + t.Run("tui_then_web_rejected", func(t *testing.T) { + requestID := "concurrent-tui-web-1" + + // Simulate TUI responding first + server.MarkInputResponded(requestID) + + // Web response should be rejected + body := fmt.Sprintf(`{"request_id": "%s", "response": "web response after tui"}`, requestID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + + var result struct { + Status string `json:"status"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, "already_responded", result.Status) + }) + + t.Run("concurrent_web_requests_one_wins", func(t *testing.T) { + requestID := "concurrent-race-1" + + // Launch multiple concurrent requests + var wg sync.WaitGroup + var successCount int32 + var conflictCount int32 + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + + body := fmt.Sprintf(`{"request_id": "%s", "response": "response %d"}`, requestID, n) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + atomic.AddInt32(&successCount, 1) + case http.StatusConflict: + atomic.AddInt32(&conflictCount, 1) + } + }(i) + } + + wg.Wait() + + // Exactly one should succeed, rest should get conflict + assert.Equal(t, int32(1), successCount, "exactly one request should succeed") + assert.Equal(t, int32(9), conflictCount, "rest should get conflict") + }) + + t.Run("isInputResponded_reflects_state", func(t *testing.T) { + requestID := "state-check-1" + + // Initially not responded + assert.False(t, server.IsInputResponded(requestID)) + + // After web submission + body := fmt.Sprintf(`{"request_id": "%s", "response": "test"}`, requestID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + + // Now should be responded + assert.True(t, server.IsInputResponded(requestID)) + }) + + t.Run("tui_check_before_response_prevents_conflict", func(t *testing.T) { + requestID := "tui-check-1" + + // TUI checks if already responded (simulating loop behavior) + assert.False(t, server.IsInputResponded(requestID)) + + // Web submits first + body := fmt.Sprintf(`{"request_id": "%s", "response": "web wins"}`, requestID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + + // TUI checks again and sees it's responded + assert.True(t, server.IsInputResponded(requestID)) + + // TUI can get the response + response, ok := server.GetPendingInput(requestID) + assert.True(t, ok) + assert.Equal(t, "web wins", response) + }) +} + +// TestIntegrationSSEStream tests Server-Sent Events streaming mode. +func TestIntegrationSSEStream(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("sse_receives_initial_control_event", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=sse&offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "text/event-stream") + + // Read first event (should be control) + buf := make([]byte, 4096) + n, err := resp.Body.Read(buf) + // err might be timeout or nil, both ok + if err != nil && err != io.EOF { + // timeout is expected + t.Logf("Read completed with: %v", err) + } + + body := string(buf[:n]) + assert.Contains(t, body, "event: control") + assert.Contains(t, body, "streamNextOffset") + }) + + t.Run("sse_receives_data_events", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=sse&offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + + // Use a context with cancel for controlled shutdown + reqCtx, reqCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer reqCancel() + req = req.WithContext(reqCtx) + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Read in goroutine while we broadcast + dataCh := make(chan string, 10) + go func() { + buf := make([]byte, 8192) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + dataCh <- string(buf[:n]) + } + if err != nil { + close(dataCh) + return + } + } + }() + + // Wait for initial control event + time.Sleep(200 * time.Millisecond) + + // Broadcast a message + session := &Session{ + ID: "sse-test-session", + Repo: "test/repo", + Branch: "sse-test", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: time.Now().Format(time.RFC3339), + } + require.NoError(t, server.Streams().BroadcastSession(session)) + + // Collect data + var allData strings.Builder + timeout := time.After(3 * time.Second) + collect: + for { + select { + case data, ok := <-dataCh: + if !ok { + break collect + } + allData.WriteString(data) + if strings.Contains(data, "sse-test-session") { + break collect + } + case <-timeout: + break collect + } + } + + assert.Contains(t, allData.String(), "event: data") + assert.Contains(t, allData.String(), "sse-test-session") + }) +} + +// TestIntegrationServerLifecycle tests server start, stop, and restart behavior. +func TestIntegrationServerLifecycle(t *testing.T) { + t.Parallel() + + hash, err := auth.HashPassword(testPassword) + require.NoError(t, err) + + t.Run("start_stop_restart", func(t *testing.T) { + server, err := NewServer(&Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start + errCh := make(chan error, 1) + go func() { + errCh <- server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + + addr := server.ListenAddr() + require.NotEmpty(t, addr) + + // Verify working + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + + // Stop via server.Stop() for more reliable shutdown + err = server.Stop() + require.NoError(t, err) + + // Wait for shutdown to complete + select { + case err := <-errCh: + // Should exit cleanly + assert.NoError(t, err) + case <-time.After(3 * time.Second): + t.Fatal("server did not stop in time") + } + + // Should be stopped - connection should be refused + client := &http.Client{Timeout: 1 * time.Second} + _, err = client.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) + assert.Error(t, err, "expected connection refused after server stop") + }) + + t.Run("graceful_shutdown_with_active_connections", func(t *testing.T) { + server, err := NewServer(&Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + // Start a long-poll connection + go func() { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=long-poll&offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 30 * time.Second} + client.Do(req) + }() + + time.Sleep(100 * time.Millisecond) + + // Stop should complete without hanging + done := make(chan bool) + go func() { + server.Stop() + done <- true + }() + + select { + case <-done: + // Good + case <-time.After(6 * time.Second): + t.Fatal("graceful shutdown took too long") + } + }) +} + +// TestIntegrationEndToEndFlow tests a complete realistic flow: +// authenticate, sync state, receive updates, submit input. +func TestIntegrationEndToEndFlow(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + + // Step 1: Authenticate + authBody := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(authBody)) + require.NoError(t, err) + var authResp struct { + Token string `json:"token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&authResp)) + resp.Body.Close() + token := authResp.Token + + // Step 2: Initial state sync - should be empty + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + assert.Equal(t, "[]", string(body)) + + // Step 3: Simulate session starting (server-side broadcast) + session := &Session{ + ID: "e2e-session", + Repo: "owner/repo", + Branch: "wisp/e2e-test", + Spec: "docs/rfc.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: time.Now().Format(time.RFC3339), + } + require.NoError(t, server.Streams().BroadcastSession(session)) + + task := &Task{ + ID: "e2e-task-1", + SessionID: "e2e-session", + Order: 0, + Content: "Implement feature", + Status: TaskStatusInProgress, + } + require.NoError(t, server.Streams().BroadcastTask(task)) + + // Step 4: Get updates + req, _ = http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + assert.Len(t, messages, 2) + + // Step 5: NEEDS_INPUT broadcast + session.Status = SessionStatusNeedsInput + session.Iteration = 2 + require.NoError(t, server.Streams().BroadcastSession(session)) + + inputReq := &InputRequest{ + ID: "e2e-input-1", + SessionID: "e2e-session", + Iteration: 2, + Question: "Should I continue with approach A or B?", + Responded: false, + Response: nil, + } + require.NoError(t, server.Streams().BroadcastInputRequest(inputReq)) + + // Step 6: Client receives NEEDS_INPUT update + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + offset = resp.Header.Get("Stream-Next-Offset") + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + require.NoError(t, json.Unmarshal(body, &messages)) + assert.Len(t, messages, 2) // session update + input request + + // Find input request + var foundInput bool + for _, msg := range messages { + if msg.Type == MessageTypeInputRequest { + foundInput = true + dataBytes, _ := json.Marshal(msg.Data) + var ir InputRequest + json.Unmarshal(dataBytes, &ir) + assert.Equal(t, "e2e-input-1", ir.ID) + assert.False(t, ir.Responded) + } + } + assert.True(t, foundInput) + + // Step 7: Submit response + inputBody := `{"request_id": "e2e-input-1", "response": "Go with approach A"}` + req, _ = http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(inputBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + + // Step 8: Verify response was stored + response, ok := server.GetPendingInput("e2e-input-1") + assert.True(t, ok) + assert.Equal(t, "Go with approach A", response) + + // Step 9: Client sees input as responded + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + require.NoError(t, json.Unmarshal(body, &messages)) + require.Len(t, messages, 1) + + dataBytes, _ := json.Marshal(messages[0].Data) + var finalInput InputRequest + json.Unmarshal(dataBytes, &finalInput) + assert.True(t, finalInput.Responded) + require.NotNil(t, finalInput.Response) + assert.Equal(t, "Go with approach A", *finalInput.Response) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..6787e33 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,1245 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/thruflo/wisp/internal/auth" + "github.com/thruflo/wisp/internal/config" +) + +const testPassword = "test-password-123" + +// createTestServer creates a server with a known password hash for testing. +func createTestServer(t *testing.T) *Server { + t.Helper() + + hash, err := auth.HashPassword(testPassword) + if err != nil { + t.Fatalf("failed to hash password: %v", err) + } + + server, err := NewServer(&Config{ + Port: 0, // random available port + PasswordHash: hash, + }) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + return server +} + +func TestNewServer(t *testing.T) { + tests := []struct { + name string + cfg *Config + wantErr string + }{ + { + name: "nil config", + cfg: nil, + wantErr: "config is required", + }, + { + name: "empty password hash", + cfg: &Config{ + Port: 8080, + PasswordHash: "", + }, + wantErr: "password hash is required", + }, + { + name: "valid config", + cfg: &Config{ + Port: 8080, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$test$hash", + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, err := NewServer(tt.cfg) + if tt.wantErr != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErr) + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if server == nil { + t.Error("expected server, got nil") + return + } + if server.Port() != tt.cfg.Port { + t.Errorf("expected port %d, got %d", tt.cfg.Port, server.Port()) + } + }) + } +} + +func TestNewServerFromConfig(t *testing.T) { + t.Run("nil config", func(t *testing.T) { + _, err := NewServerFromConfig(nil) + if err == nil { + t.Error("expected error for nil config") + } + }) + + t.Run("valid config", func(t *testing.T) { + cfg := &config.ServerConfig{ + Port: 8374, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$test$hash", + } + server, err := NewServerFromConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if server.Port() != 8374 { + t.Errorf("expected port 8374, got %d", server.Port()) + } + }) +} + +func TestVerifyPassword(t *testing.T) { + server := createTestServer(t) + + t.Run("correct password", func(t *testing.T) { + valid, err := server.VerifyPassword(testPassword) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !valid { + t.Error("expected password to be valid") + } + }) + + t.Run("wrong password", func(t *testing.T) { + valid, err := server.VerifyPassword("wrong-password") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if valid { + t.Error("expected password to be invalid") + } + }) + + t.Run("empty password", func(t *testing.T) { + valid, err := server.VerifyPassword("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if valid { + t.Error("expected empty password to be invalid") + } + }) +} + +func TestGenerateToken(t *testing.T) { + server := createTestServer(t) + + t.Run("generates token", func(t *testing.T) { + token, err := server.GenerateToken() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token == "" { + t.Error("expected non-empty token") + } + // Token should be 64 hex characters (32 bytes) + if len(token) != 64 { + t.Errorf("expected token length 64, got %d", len(token)) + } + }) + + t.Run("tokens are unique", func(t *testing.T) { + token1, _ := server.GenerateToken() + token2, _ := server.GenerateToken() + if token1 == token2 { + t.Error("expected unique tokens") + } + }) + + t.Run("token is valid after generation", func(t *testing.T) { + token, _ := server.GenerateToken() + if !server.ValidateToken(token) { + t.Error("expected generated token to be valid") + } + }) +} + +func TestValidateToken(t *testing.T) { + server := createTestServer(t) + + t.Run("empty token", func(t *testing.T) { + if server.ValidateToken("") { + t.Error("expected empty token to be invalid") + } + }) + + t.Run("non-existent token", func(t *testing.T) { + if server.ValidateToken("non-existent-token") { + t.Error("expected non-existent token to be invalid") + } + }) + + t.Run("valid token", func(t *testing.T) { + token, _ := server.GenerateToken() + if !server.ValidateToken(token) { + t.Error("expected valid token to pass validation") + } + }) + + t.Run("revoked token", func(t *testing.T) { + token, _ := server.GenerateToken() + server.RevokeToken(token) + if server.ValidateToken(token) { + t.Error("expected revoked token to be invalid") + } + }) +} + +func TestRevokeToken(t *testing.T) { + server := createTestServer(t) + + t.Run("revoke existing token", func(t *testing.T) { + token, _ := server.GenerateToken() + server.RevokeToken(token) + if server.ValidateToken(token) { + t.Error("expected revoked token to be invalid") + } + }) + + t.Run("revoke non-existent token", func(t *testing.T) { + // Should not panic + server.RevokeToken("non-existent") + }) +} + +func TestHandleAuth(t *testing.T) { + server := createTestServer(t) + + mux := http.NewServeMux() + server.setupRoutes(mux) + + t.Run("wrong method", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/auth", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) + } + }) + + t.Run("missing password", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + }) + + t.Run("wrong password", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(`{"password":"wrong-password"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("correct password", func(t *testing.T) { + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + // Parse response + var response struct { + Token string `json:"token"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if response.Token == "" { + t.Error("expected non-empty token in response") + } + + // Verify token is valid + if !server.ValidateToken(response.Token) { + t.Error("expected returned token to be valid") + } + }) +} + +func TestServerStartStop(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start server in goroutine + errCh := make(chan error, 1) + go func() { + errCh <- server.Start(ctx) + }() + + // Give server time to start + time.Sleep(50 * time.Millisecond) + + // Verify server is listening + addr := server.ListenAddr() + if addr == "" { + t.Fatal("expected server to be listening") + } + + // Make a request + resp, err := http.Get("http://" + addr + "/auth") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + resp.Body.Close() + + // Status should be 405 (method not allowed for GET) + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, resp.StatusCode) + } + + // Stop server + if err := server.Stop(); err != nil { + t.Fatalf("failed to stop server: %v", err) + } + + // Server should have exited cleanly + select { + case err := <-errCh: + if err != nil { + t.Errorf("server returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("server did not exit in time") + } +} + +func TestServerDoubleStart(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start server + errCh := make(chan error, 1) + go func() { + errCh <- server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + + // Try to start again + err := server.Start(ctx) + if err == nil { + t.Error("expected error when starting already-started server") + } + if !strings.Contains(err.Error(), "already started") { + t.Errorf("expected 'already started' error, got: %v", err) + } + + // Cleanup + server.Stop() +} + +func TestServerStopNotStarted(t *testing.T) { + server := createTestServer(t) + + // Stop without starting should not error + if err := server.Stop(); err != nil { + t.Errorf("unexpected error stopping non-started server: %v", err) + } +} + +func TestAuthEndToEnd(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start server + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + + // Authenticate with correct password + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("failed to authenticate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + var response struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Token should be valid + if !server.ValidateToken(response.Token) { + t.Error("token should be valid") + } + + // Authenticate with wrong password + resp2, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(`{"password":"wrong-password"}`)) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + resp2.Body.Close() + + if resp2.StatusCode != http.StatusUnauthorized { + t.Errorf("expected status %d for wrong password, got %d", http.StatusUnauthorized, resp2.StatusCode) + } +} + +// Helper to get an authenticated token for tests +func getAuthToken(t *testing.T, addr string) string { + t.Helper() + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("failed to authenticate: %v", err) + } + defer resp.Body.Close() + + var response struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + return response.Token +} + +func TestAuthMiddleware(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + + t.Run("no auth header", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode) + } + }) + + t.Run("invalid auth format", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Basic sometoken") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode) + } + }) + + t.Run("invalid token", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer invalid-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode) + } + }) + + t.Run("valid token", func(t *testing.T) { + token := getAuthToken(t, addr) + + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + // Should get through auth (200 or other non-401 status) + if resp.StatusCode == http.StatusUnauthorized { + t.Errorf("expected authenticated request to succeed") + } + }) +} + +func TestStreamEndpoint(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("wrong method", func(t *testing.T) { + req, _ := http.NewRequest("POST", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, resp.StatusCode) + } + }) + + t.Run("empty stream", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + // Check headers + if resp.Header.Get("Stream-Next-Offset") == "" { + t.Error("expected Stream-Next-Offset header") + } + if resp.Header.Get("Stream-Up-To-Date") != "true" { + t.Error("expected Stream-Up-To-Date header to be true for empty stream") + } + + // Check body is empty JSON array + body, _ := io.ReadAll(resp.Body) + if string(body) != "[]" { + t.Errorf("expected empty array, got %s", string(body)) + } + }) + + t.Run("with messages", func(t *testing.T) { + // Broadcast a session to the stream + session := &Session{ + ID: "test-session", + Repo: "test/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-01T00:00:00Z", + } + if err := server.Streams().BroadcastSession(session); err != nil { + t.Fatalf("failed to broadcast: %v", err) + } + + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "test-session") { + t.Errorf("expected body to contain session, got %s", string(body)) + } + }) + + t.Run("invalid offset", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=invalid", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, resp.StatusCode) + } + }) +} + +func TestInputEndpoint(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("wrong method", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/input", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, resp.StatusCode) + } + }) + + t.Run("invalid JSON", func(t *testing.T) { + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader("not json")) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, resp.StatusCode) + } + }) + + t.Run("missing request_id", func(t *testing.T) { + body := `{"response": "test response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, resp.StatusCode) + } + }) + + t.Run("valid input", func(t *testing.T) { + body := `{"request_id": "req-123", "response": "test response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Errorf("expected status %d, got %d: %s", http.StatusOK, resp.StatusCode, string(bodyBytes)) + } + + // Verify the input was stored + response, ok := server.GetPendingInput("req-123") + if !ok { + t.Error("expected pending input to be stored") + } + if response != "test response" { + t.Errorf("expected response 'test response', got '%s'", response) + } + + // Getting it again should return not found (it's been consumed) + _, ok = server.GetPendingInput("req-123") + if ok { + t.Error("expected pending input to be consumed after first get") + } + }) +} + +func TestStaticEndpoint(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + + t.Run("root path", func(t *testing.T) { + resp, err := http.Get("http://" + addr + "/") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + if !strings.Contains(resp.Header.Get("Content-Type"), "text/html") { + t.Errorf("expected HTML content type, got %s", resp.Header.Get("Content-Type")) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "Wisp") { + t.Error("expected body to contain 'Wisp'") + } + }) + + t.Run("index.html", func(t *testing.T) { + resp, err := http.Get("http://" + addr + "/index.html") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + }) + + t.Run("unknown path", func(t *testing.T) { + resp, err := http.Get("http://" + addr + "/unknown") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, resp.StatusCode) + } + }) +} + +func TestStreamLongPoll(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("long-poll receives new message", func(t *testing.T) { + // Get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Start long-poll in goroutine + resultCh := make(chan int, 1) + go func() { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=long-poll&offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + resultCh <- -1 + return + } + defer resp.Body.Close() + resultCh <- resp.StatusCode + }() + + // Wait a bit and broadcast a message + time.Sleep(100 * time.Millisecond) + task := &Task{ + ID: "task-1", + SessionID: "test-session", + Order: 1, + Content: "Test task", + Status: TaskStatusPending, + } + server.Streams().BroadcastTask(task) + + // Wait for result + select { + case status := <-resultCh: + if status != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, status) + } + case <-time.After(3 * time.Second): + t.Error("long-poll did not return in time") + } + }) +} + +func TestPendingInputConcurrency(t *testing.T) { + server := createTestServer(t) + + // Test concurrent access to pending inputs + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + reqID := fmt.Sprintf("req-%d", id) + + // Store + server.inputMu.Lock() + if server.pendingInputs == nil { + server.pendingInputs = make(map[string]string) + } + server.pendingInputs[reqID] = fmt.Sprintf("response-%d", id) + server.inputMu.Unlock() + + // Retrieve + resp, ok := server.GetPendingInput(reqID) + if !ok { + t.Errorf("expected to find input %s", reqID) + } + if resp != fmt.Sprintf("response-%d", id) { + t.Errorf("wrong response for %s", reqID) + } + }(i) + } + wg.Wait() +} + +func TestInputFirstResponseWins(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("first response succeeds", func(t *testing.T) { + // First response should succeed + body := `{"request_id": "first-wins-123", "response": "first response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + var result struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Status != "received" { + t.Errorf("expected status 'received', got '%s'", result.Status) + } + }) + + t.Run("second response rejected with 409 Conflict", func(t *testing.T) { + // Second response to same request_id should fail with 409 Conflict + body := `{"request_id": "first-wins-123", "response": "second response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusConflict { + t.Errorf("expected status %d (Conflict), got %d", http.StatusConflict, resp.StatusCode) + } + + var result struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Status != "already_responded" { + t.Errorf("expected status 'already_responded', got '%s'", result.Status) + } + }) +} + +func TestInputMarkResponded(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("MarkInputResponded blocks subsequent web input", func(t *testing.T) { + reqID := "tui-first-123" + + // Simulate TUI responding first by calling MarkInputResponded + server.MarkInputResponded(reqID) + + // Now try to respond via web - should fail with 409 + body := fmt.Sprintf(`{"request_id": "%s", "response": "web response"}`, reqID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusConflict { + t.Errorf("expected status %d (Conflict), got %d", http.StatusConflict, resp.StatusCode) + } + }) + + t.Run("IsInputResponded returns correct state", func(t *testing.T) { + // New request_id should not be responded + if server.IsInputResponded("new-request-456") { + t.Error("expected new request to not be responded") + } + + // After marking, it should be responded + server.MarkInputResponded("new-request-456") + if !server.IsInputResponded("new-request-456") { + t.Error("expected marked request to be responded") + } + }) +} + +func TestInputBroadcastsUpdate(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + // Submit input + body := `{"request_id": "broadcast-test-123", "response": "broadcast response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + // Verify the input request was broadcast as responded + _, _, inputRequests := server.Streams().GetCurrentState() + + var found *InputRequest + for _, req := range inputRequests { + if req.ID == "broadcast-test-123" { + found = req + break + } + } + + if found == nil { + t.Fatal("expected input request to be broadcast") + } + + if !found.Responded { + t.Error("expected input request to be marked as responded") + } + + if found.Response == nil || *found.Response != "broadcast response" { + t.Error("expected response to be set correctly") + } +} + +// Tests for static asset serving + +func TestHandleStatic(t *testing.T) { + server := createTestServer(t) + + tests := []struct { + name string + method string + path string + accept string + wantStatus int + wantType string + wantContains string + wantCacheCtrl string + }{ + { + name: "root returns index.html", + method: "GET", + path: "/", + wantStatus: http.StatusOK, + wantType: "text/html; charset=utf-8", + wantContains: "Wisp", + wantCacheCtrl: "no-cache", + }, + { + name: "explicit index.html", + method: "GET", + path: "/index.html", + wantStatus: http.StatusOK, + wantType: "text/html; charset=utf-8", + wantContains: "", + wantCacheCtrl: "no-cache", + }, + { + name: "HEAD request works", + method: "HEAD", + path: "/", + wantStatus: http.StatusOK, + wantType: "text/html; charset=utf-8", + }, + { + name: "POST not allowed", + method: "POST", + path: "/", + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "PUT not allowed", + method: "PUT", + path: "/", + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "nonexistent file returns 404", + method: "GET", + path: "/nonexistent.txt", + wantStatus: http.StatusNotFound, + }, + { + name: "nonexistent path with HTML accept returns index.html (SPA support)", + method: "GET", + path: "/some/spa/route", + accept: "text/html", + wantStatus: http.StatusOK, + wantType: "text/html; charset=utf-8", + wantContains: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + if tt.accept != "" { + req.Header.Set("Accept", tt.accept) + } + w := httptest.NewRecorder() + + server.handleStatic(w, req) + + resp := w.Result() + if resp.StatusCode != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, resp.StatusCode) + } + + if tt.wantType != "" { + contentType := resp.Header.Get("Content-Type") + if contentType != tt.wantType { + t.Errorf("expected Content-Type %q, got %q", tt.wantType, contentType) + } + } + + if tt.wantContains != "" { + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), tt.wantContains) { + t.Errorf("expected body to contain %q, got %q", tt.wantContains, string(body)) + } + } + + if tt.wantCacheCtrl != "" { + cacheCtrl := resp.Header.Get("Cache-Control") + if cacheCtrl != tt.wantCacheCtrl { + t.Errorf("expected Cache-Control %q, got %q", tt.wantCacheCtrl, cacheCtrl) + } + } + }) + } +} + +func TestGetContentType(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"index.html", "text/html; charset=utf-8"}, + {"style.css", "text/css; charset=utf-8"}, + {"app.js", "application/javascript; charset=utf-8"}, + {"data.json", "application/json; charset=utf-8"}, + {"icon.svg", "image/svg+xml"}, + {"photo.png", "image/png"}, + {"photo.jpg", "image/jpeg"}, + {"photo.jpeg", "image/jpeg"}, + {"animation.gif", "image/gif"}, + {"favicon.ico", "image/x-icon"}, + {"font.woff", "font/woff"}, + {"font.woff2", "font/woff2"}, + {"font.ttf", "font/ttf"}, + {"image.webp", "image/webp"}, + {"unknown.xyz", "application/octet-stream"}, + {"nested/path/file.html", "text/html; charset=utf-8"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := getContentType(tt.path) + if got != tt.want { + t.Errorf("getContentType(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestIsImmutableAsset(t *testing.T) { + tests := []struct { + path string + want bool + }{ + // Hashed assets (immutable) + {"index-BcD123aF.js", true}, + {"style-abc456.css", true}, + {"vendor.def789.js", true}, + + // Non-hashed assets (mutable) + {"index.html", false}, + {"style.css", false}, + {"app.js", false}, + {"favicon.ico", false}, + + // Edge cases + {"a.b", false}, // too short + {"file.short.js", false}, // 5 chars is too short for hash + {"file.abc123.js", true}, // 6 chars is minimum for hash + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := isImmutableAsset(tt.path) + if got != tt.want { + t.Errorf("isImmutableAsset(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} diff --git a/internal/server/streams.go b/internal/server/streams.go new file mode 100644 index 0000000..daf93d4 --- /dev/null +++ b/internal/server/streams.go @@ -0,0 +1,289 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/durable-streams/durable-streams/packages/caddy-plugin/store" +) + +const ( + // streamPath is the default path for the wisp event stream. + streamPath = "/wisp/events" + // streamContentType is the content type for the event stream. + streamContentType = "application/json" +) + +// MessageType identifies the type of message in the stream. +type MessageType string + +const ( + // MessageTypeSession is a session state update. + MessageTypeSession MessageType = "session" + // MessageTypeTask is a task state update. + MessageTypeTask MessageType = "task" + // MessageTypeClaudeEvent is a Claude output event. + MessageTypeClaudeEvent MessageType = "claude_event" + // MessageTypeInputRequest is an input request. + MessageTypeInputRequest MessageType = "input_request" + // MessageTypeDelete indicates a deletion. + MessageTypeDelete MessageType = "delete" +) + +// StreamMessage represents a message in the durable stream. +type StreamMessage struct { + Type MessageType `json:"type"` + Data any `json:"data,omitempty"` + // For delete operations: + Collection string `json:"collection,omitempty"` + ID string `json:"id,omitempty"` +} + +// Session represents a wisp session for streaming to clients. +type Session struct { + ID string `json:"id"` + Repo string `json:"repo"` + Branch string `json:"branch"` + Spec string `json:"spec"` + Status SessionStatus `json:"status"` + Iteration int `json:"iteration"` + StartedAt string `json:"started_at"` +} + +// SessionStatus represents the status of a session. +type SessionStatus string + +const ( + SessionStatusRunning SessionStatus = "running" + SessionStatusNeedsInput SessionStatus = "needs_input" + SessionStatusBlocked SessionStatus = "blocked" + SessionStatusDone SessionStatus = "done" +) + +// Task represents a task within a session. +type Task struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Order int `json:"order"` + Content string `json:"content"` + Status TaskStatus `json:"status"` +} + +// TaskStatus represents the status of a task. +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" + TaskStatusInProgress TaskStatus = "in_progress" + TaskStatusCompleted TaskStatus = "completed" +) + +// ClaudeEvent represents a Claude output event. +// The Message field contains the raw SDK message (assistant, result, system, user). +type ClaudeEvent struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Iteration int `json:"iteration"` + Sequence int `json:"sequence"` + Message any `json:"message"` // Raw SDKMessage from Claude stream-json output + Timestamp string `json:"timestamp"` +} + +// InputRequest represents a request for user input. +type InputRequest struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Iteration int `json:"iteration"` + Question string `json:"question"` + Responded bool `json:"responded"` + Response *string `json:"response"` // nil if not responded +} + +// StreamManager wraps a MemoryStore for managing the event stream. +type StreamManager struct { + store *store.MemoryStore + mu sync.RWMutex + + // Track current state for initial sync + sessions map[string]*Session + tasks map[string]*Task + inputRequests map[string]*InputRequest +} + +// NewStreamManager creates a new StreamManager with an initialized MemoryStore. +func NewStreamManager() (*StreamManager, error) { + memStore := store.NewMemoryStore() + + // Create the stream + _, _, err := memStore.Create(streamPath, store.CreateOptions{ + ContentType: streamContentType, + }) + if err != nil { + return nil, fmt.Errorf("failed to create stream: %w", err) + } + + return &StreamManager{ + store: memStore, + sessions: make(map[string]*Session), + tasks: make(map[string]*Task), + inputRequests: make(map[string]*InputRequest), + }, nil +} + +// Store returns the underlying MemoryStore. +func (sm *StreamManager) Store() *store.MemoryStore { + return sm.store +} + +// StreamPath returns the path for the event stream. +func (sm *StreamManager) StreamPath() string { + return streamPath +} + +// append serializes and appends a message to the stream. +func (sm *StreamManager) append(msg StreamMessage) error { + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + _, err = sm.store.Append(streamPath, data, store.AppendOptions{}) + if err != nil { + return fmt.Errorf("failed to append message: %w", err) + } + + return nil +} + +// BroadcastSession broadcasts a session state update. +func (sm *StreamManager) BroadcastSession(session *Session) error { + if session == nil { + return errors.New("session is nil") + } + + sm.mu.Lock() + sm.sessions[session.ID] = session + sm.mu.Unlock() + + return sm.append(StreamMessage{ + Type: MessageTypeSession, + Data: session, + }) +} + +// BroadcastTask broadcasts a task state update. +func (sm *StreamManager) BroadcastTask(task *Task) error { + if task == nil { + return errors.New("task is nil") + } + + sm.mu.Lock() + sm.tasks[task.ID] = task + sm.mu.Unlock() + + return sm.append(StreamMessage{ + Type: MessageTypeTask, + Data: task, + }) +} + +// BroadcastClaudeEvent broadcasts a Claude output event. +func (sm *StreamManager) BroadcastClaudeEvent(event *ClaudeEvent) error { + if event == nil { + return errors.New("event is nil") + } + + // Claude events are not tracked for initial sync (too many) + return sm.append(StreamMessage{ + Type: MessageTypeClaudeEvent, + Data: event, + }) +} + +// BroadcastInputRequest broadcasts an input request. +func (sm *StreamManager) BroadcastInputRequest(req *InputRequest) error { + if req == nil { + return errors.New("input request is nil") + } + + sm.mu.Lock() + sm.inputRequests[req.ID] = req + sm.mu.Unlock() + + return sm.append(StreamMessage{ + Type: MessageTypeInputRequest, + Data: req, + }) +} + +// BroadcastDelete broadcasts a deletion message. +func (sm *StreamManager) BroadcastDelete(collection, id string) error { + if collection == "" || id == "" { + return errors.New("collection and id are required") + } + + // Remove from tracked state + sm.mu.Lock() + switch collection { + case "sessions": + delete(sm.sessions, id) + case "tasks": + delete(sm.tasks, id) + case "input_requests": + delete(sm.inputRequests, id) + } + sm.mu.Unlock() + + return sm.append(StreamMessage{ + Type: MessageTypeDelete, + Collection: collection, + ID: id, + }) +} + +// GetCurrentOffset returns the current tail offset of the stream. +func (sm *StreamManager) GetCurrentOffset() (store.Offset, error) { + return sm.store.GetCurrentOffset(streamPath) +} + +// Read reads messages from the stream starting at the given offset. +func (sm *StreamManager) Read(offset store.Offset) ([]store.Message, bool, error) { + return sm.store.Read(streamPath, offset) +} + +// WaitForMessages waits for new messages after the given offset. +func (sm *StreamManager) WaitForMessages(ctx context.Context, offset store.Offset, timeout time.Duration) ([]store.Message, bool, error) { + return sm.store.WaitForMessages(ctx, streamPath, offset, timeout) +} + +// GetCurrentState returns all currently tracked state for initial sync. +func (sm *StreamManager) GetCurrentState() (sessions []*Session, tasks []*Task, inputRequests []*InputRequest) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + sessions = make([]*Session, 0, len(sm.sessions)) + for _, s := range sm.sessions { + sessions = append(sessions, s) + } + + tasks = make([]*Task, 0, len(sm.tasks)) + for _, t := range sm.tasks { + tasks = append(tasks, t) + } + + inputRequests = make([]*InputRequest, 0, len(sm.inputRequests)) + for _, r := range sm.inputRequests { + inputRequests = append(inputRequests, r) + } + + return +} + +// Close releases resources held by the StreamManager. +func (sm *StreamManager) Close() error { + return sm.store.Close() +} diff --git a/internal/server/streams_test.go b/internal/server/streams_test.go new file mode 100644 index 0000000..62a8258 --- /dev/null +++ b/internal/server/streams_test.go @@ -0,0 +1,604 @@ +package server + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/durable-streams/durable-streams/packages/caddy-plugin/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStreamManager(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + require.NotNil(t, sm) + defer sm.Close() + + // Should have created a store + assert.NotNil(t, sm.Store()) + + // Should have a stream path + assert.Equal(t, "/wisp/events", sm.StreamPath()) +} + +func TestStreamManager_BroadcastSession(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + session := &Session{ + ID: "test-session-1", + Repo: "user/repo", + Branch: "wisp/feature", + Spec: "docs/rfc.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + + err = sm.BroadcastSession(session) + require.NoError(t, err) + + // Read the message back + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + // Verify serialization + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeSession, msg.Type) + + // Verify data can be converted to Session + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var s Session + err = json.Unmarshal(dataBytes, &s) + require.NoError(t, err) + assert.Equal(t, "test-session-1", s.ID) + assert.Equal(t, "user/repo", s.Repo) + assert.Equal(t, SessionStatusRunning, s.Status) +} + +func TestStreamManager_BroadcastSession_Nil(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastSession(nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "nil") +} + +func TestStreamManager_BroadcastTask(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + task := &Task{ + ID: "task-1", + SessionID: "test-session-1", + Order: 0, + Content: "Implement feature X", + Status: TaskStatusInProgress, + } + + err = sm.BroadcastTask(task) + require.NoError(t, err) + + // Read the message back + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + // Verify serialization + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeTask, msg.Type) + + // Verify data + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var tsk Task + err = json.Unmarshal(dataBytes, &tsk) + require.NoError(t, err) + assert.Equal(t, "task-1", tsk.ID) + assert.Equal(t, TaskStatusInProgress, tsk.Status) +} + +func TestStreamManager_BroadcastTask_Nil(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastTask(nil) + assert.Error(t, err) +} + +func TestStreamManager_BroadcastClaudeEvent(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Use a map to simulate raw SDK message + sdkMessage := map[string]any{ + "type": "assistant", + "message": map[string]any{ + "content": []any{ + map[string]any{ + "type": "text", + "text": "Hello, world!", + }, + }, + }, + } + + event := &ClaudeEvent{ + ID: "session-1-1-42", + SessionID: "session-1", + Iteration: 1, + Sequence: 42, + Message: sdkMessage, + Timestamp: "2024-01-15T10:30:00Z", + } + + err = sm.BroadcastClaudeEvent(event) + require.NoError(t, err) + + // Read and verify + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeClaudeEvent, msg.Type) + + // Verify the message field contains the SDK message + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var evt ClaudeEvent + err = json.Unmarshal(dataBytes, &evt) + require.NoError(t, err) + assert.Equal(t, "session-1-1-42", evt.ID) + assert.Equal(t, 42, evt.Sequence) + + // Verify the SDK message is preserved + msgMap, ok := evt.Message.(map[string]any) + require.True(t, ok) + assert.Equal(t, "assistant", msgMap["type"]) +} + +func TestStreamManager_BroadcastClaudeEvent_Nil(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastClaudeEvent(nil) + assert.Error(t, err) +} + +func TestStreamManager_BroadcastInputRequest(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + response := "yes, continue" + req := &InputRequest{ + ID: "input-1", + SessionID: "session-1", + Iteration: 2, + Question: "Should we continue?", + Responded: true, + Response: &response, + } + + err = sm.BroadcastInputRequest(req) + require.NoError(t, err) + + // Read and verify + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeInputRequest, msg.Type) + + // Verify data + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var ir InputRequest + err = json.Unmarshal(dataBytes, &ir) + require.NoError(t, err) + assert.Equal(t, "input-1", ir.ID) + assert.True(t, ir.Responded) + require.NotNil(t, ir.Response) + assert.Equal(t, "yes, continue", *ir.Response) +} + +func TestStreamManager_BroadcastInputRequest_NotResponded(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + req := &InputRequest{ + ID: "input-2", + SessionID: "session-1", + Iteration: 3, + Question: "What should we do?", + Responded: false, + Response: nil, + } + + err = sm.BroadcastInputRequest(req) + require.NoError(t, err) + + // Read and verify + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var ir InputRequest + err = json.Unmarshal(dataBytes, &ir) + require.NoError(t, err) + assert.False(t, ir.Responded) + assert.Nil(t, ir.Response) +} + +func TestStreamManager_BroadcastInputRequest_Nil(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastInputRequest(nil) + assert.Error(t, err) +} + +func TestStreamManager_BroadcastDelete(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastDelete("sessions", "session-1") + require.NoError(t, err) + + // Read and verify + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeDelete, msg.Type) + assert.Equal(t, "sessions", msg.Collection) + assert.Equal(t, "session-1", msg.ID) +} + +func TestStreamManager_BroadcastDelete_Empty(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastDelete("", "id") + assert.Error(t, err) + + err = sm.BroadcastDelete("sessions", "") + assert.Error(t, err) +} + +func TestStreamManager_MultipleMessages(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast multiple messages + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + task1 := &Task{ + ID: "task-1", + SessionID: "sess-1", + Order: 0, + Content: "Task one", + Status: TaskStatusPending, + } + require.NoError(t, sm.BroadcastTask(task1)) + + task2 := &Task{ + ID: "task-2", + SessionID: "sess-1", + Order: 1, + Content: "Task two", + Status: TaskStatusPending, + } + require.NoError(t, sm.BroadcastTask(task2)) + + // Read all messages + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + assert.Len(t, messages, 3) + + // Verify message order and types + var msg0, msg1, msg2 StreamMessage + require.NoError(t, json.Unmarshal(messages[0].Data, &msg0)) + require.NoError(t, json.Unmarshal(messages[1].Data, &msg1)) + require.NoError(t, json.Unmarshal(messages[2].Data, &msg2)) + + assert.Equal(t, MessageTypeSession, msg0.Type) + assert.Equal(t, MessageTypeTask, msg1.Type) + assert.Equal(t, MessageTypeTask, msg2.Type) +} + +func TestStreamManager_GetCurrentOffset(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Get initial offset + offset1, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Broadcast a message + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + // Get new offset + offset2, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Offset should have increased + assert.True(t, offset2.ByteOffset > offset1.ByteOffset) +} + +func TestStreamManager_ReadFromOffset(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast first message + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + // Get offset after first message + offset, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Broadcast second message + task := &Task{ + ID: "task-1", + SessionID: "sess-1", + Order: 0, + Content: "Task one", + Status: TaskStatusPending, + } + require.NoError(t, sm.BroadcastTask(task)) + + // Read from offset (should only get second message) + messages, _, err := sm.Read(offset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + require.NoError(t, json.Unmarshal(messages[0].Data, &msg)) + assert.Equal(t, MessageTypeTask, msg.Type) +} + +func TestStreamManager_WaitForMessages(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Get current offset + offset, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Start a goroutine to broadcast a message after a delay + go func() { + time.Sleep(50 * time.Millisecond) + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + sm.BroadcastSession(session) + }() + + // Wait for messages + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + messages, timedOut, err := sm.WaitForMessages(ctx, offset, 500*time.Millisecond) + require.NoError(t, err) + assert.False(t, timedOut) + require.Len(t, messages, 1) + + var msg StreamMessage + require.NoError(t, json.Unmarshal(messages[0].Data, &msg)) + assert.Equal(t, MessageTypeSession, msg.Type) +} + +func TestStreamManager_WaitForMessages_Timeout(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Get current offset + offset, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Wait with short timeout, no messages will arrive + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + messages, timedOut, err := sm.WaitForMessages(ctx, offset, 50*time.Millisecond) + require.NoError(t, err) + assert.True(t, timedOut) + assert.Empty(t, messages) +} + +func TestStreamManager_GetCurrentState(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast some state + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + task := &Task{ + ID: "task-1", + SessionID: "sess-1", + Order: 0, + Content: "Task one", + Status: TaskStatusPending, + } + require.NoError(t, sm.BroadcastTask(task)) + + req := &InputRequest{ + ID: "input-1", + SessionID: "sess-1", + Iteration: 1, + Question: "Question?", + Responded: false, + Response: nil, + } + require.NoError(t, sm.BroadcastInputRequest(req)) + + // Get current state + sessions, tasks, inputRequests := sm.GetCurrentState() + + assert.Len(t, sessions, 1) + assert.Equal(t, "sess-1", sessions[0].ID) + + assert.Len(t, tasks, 1) + assert.Equal(t, "task-1", tasks[0].ID) + + assert.Len(t, inputRequests, 1) + assert.Equal(t, "input-1", inputRequests[0].ID) +} + +func TestStreamManager_GetCurrentState_AfterDelete(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast a session + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + // Delete the session + require.NoError(t, sm.BroadcastDelete("sessions", "sess-1")) + + // Get current state + sessions, _, _ := sm.GetCurrentState() + assert.Empty(t, sessions) +} + +func TestStreamManager_GetCurrentState_UpdatesExisting(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast initial session + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + // Broadcast updated session + session.Status = SessionStatusNeedsInput + session.Iteration = 2 + require.NoError(t, sm.BroadcastSession(session)) + + // Get current state (should have updated session) + sessions, _, _ := sm.GetCurrentState() + require.Len(t, sessions, 1) + assert.Equal(t, SessionStatusNeedsInput, sessions[0].Status) + assert.Equal(t, 2, sessions[0].Iteration) +} + +func TestSessionStatus_Values(t *testing.T) { + assert.Equal(t, SessionStatus("running"), SessionStatusRunning) + assert.Equal(t, SessionStatus("needs_input"), SessionStatusNeedsInput) + assert.Equal(t, SessionStatus("blocked"), SessionStatusBlocked) + assert.Equal(t, SessionStatus("done"), SessionStatusDone) +} + +func TestTaskStatus_Values(t *testing.T) { + assert.Equal(t, TaskStatus("pending"), TaskStatusPending) + assert.Equal(t, TaskStatus("in_progress"), TaskStatusInProgress) + assert.Equal(t, TaskStatus("completed"), TaskStatusCompleted) +} + +func TestMessageType_Values(t *testing.T) { + assert.Equal(t, MessageType("session"), MessageTypeSession) + assert.Equal(t, MessageType("task"), MessageTypeTask) + assert.Equal(t, MessageType("claude_event"), MessageTypeClaudeEvent) + assert.Equal(t, MessageType("input_request"), MessageTypeInputRequest) + assert.Equal(t, MessageType("delete"), MessageTypeDelete) +} diff --git a/web/assets.go b/web/assets.go new file mode 100644 index 0000000..ddfc7aa --- /dev/null +++ b/web/assets.go @@ -0,0 +1,54 @@ +// Package web provides embedded web assets for the wisp remote access client. +// +// The dist/ directory is embedded at build time. During development, +// if dist/ exists on the filesystem, it will be used instead, allowing +// for hot reloading of the web client. +package web + +import ( + "embed" + "io/fs" + "os" + "path/filepath" +) + +// assets holds the embedded web client files from dist/. +// When the web client is built (npm run build), these files +// are embedded into the binary. +// +//go:embed dist/* +var assets embed.FS + +// GetAssets returns a filesystem containing the web client assets. +// In development mode (when the dist directory exists on the filesystem +// relative to the working directory), it returns the live filesystem +// for hot reloading. In production, it returns the embedded assets. +// +// The devPath parameter specifies the path to check for development mode. +// If empty, it defaults to "./web/dist" (relative to the working directory). +func GetAssets(devPath string) fs.FS { + if devPath == "" { + devPath = "./web/dist" + } + + // Development: check filesystem first for hot reloading + if stat, err := os.Stat(devPath); err == nil && stat.IsDir() { + return os.DirFS(devPath) + } + + // Production: use embedded assets + // The assets FS has "dist/" prefix, so we need to use Sub + subFS, err := fs.Sub(assets, "dist") + if err != nil { + // This should never happen with properly embedded assets + panic("failed to access embedded web assets: " + err.Error()) + } + return subFS +} + +// GetAssetsWithBase returns a filesystem for assets, checking for development +// mode at a path relative to the given base directory. +func GetAssetsWithBase(baseDir string) fs.FS { + devPath := filepath.Join(baseDir, "web", "dist") + return GetAssets(devPath) +} diff --git a/web/assets_test.go b/web/assets_test.go new file mode 100644 index 0000000..0a41cdd --- /dev/null +++ b/web/assets_test.go @@ -0,0 +1,71 @@ +package web + +import ( + "io/fs" + "testing" +) + +func TestGetAssets(t *testing.T) { + // Test that GetAssets returns a valid filesystem + assets := GetAssets("") + if assets == nil { + t.Fatal("GetAssets returned nil") + } + + // Verify we can read the embedded index.html + file, err := assets.Open("index.html") + if err != nil { + t.Fatalf("failed to open index.html: %v", err) + } + defer file.Close() + + // Verify it's a file, not a directory + stat, err := file.Stat() + if err != nil { + t.Fatalf("failed to stat index.html: %v", err) + } + if stat.IsDir() { + t.Error("index.html is a directory, expected file") + } + if stat.Size() == 0 { + t.Error("index.html is empty") + } +} + +func TestGetAssetsWithBase(t *testing.T) { + // Test GetAssetsWithBase with a non-existent path falls back to embedded + assets := GetAssetsWithBase("/nonexistent/path") + if assets == nil { + t.Fatal("GetAssetsWithBase returned nil") + } + + // Should still work with embedded assets + file, err := assets.Open("index.html") + if err != nil { + t.Fatalf("failed to open index.html: %v", err) + } + file.Close() +} + +func TestAssetsFSInterface(t *testing.T) { + // Verify GetAssets returns a proper fs.FS implementation + var assets fs.FS = GetAssets("") + + // Test that we can walk the filesystem + var fileCount int + err := fs.WalkDir(assets, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + fileCount++ + } + return nil + }) + if err != nil { + t.Fatalf("failed to walk assets: %v", err) + } + if fileCount == 0 { + t.Error("no files found in assets") + } +} diff --git a/web/dist/assets/index-CNDrCL7h.css b/web/dist/assets/index-CNDrCL7h.css new file mode 100644 index 0000000..616cc24 --- /dev/null +++ b/web/dist/assets/index-CNDrCL7h.css @@ -0,0 +1 @@ +*,*:before,*:after{box-sizing:border-box}:root{--color-bg: #f5f5f5;--color-surface: #ffffff;--color-text: #333333;--color-text-muted: #666666;--color-border: #e0e0e0;--color-primary: #2563eb;--color-primary-hover: #1d4ed8;--color-success: #16a34a;--color-warning: #ca8a04;--color-error: #dc2626;--radius: 8px;--shadow: 0 1px 3px rgba(0, 0, 0, .1)}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.5;background:var(--color-bg);color:var(--color-text)}.app{min-height:100vh;display:flex;flex-direction:column}.app-header{display:flex;justify-content:space-between;align-items:center;padding:1rem 2rem;background:var(--color-surface);border-bottom:1px solid var(--color-border);box-shadow:var(--shadow)}.app-header h1{margin:0;font-size:1.5rem}.logout-btn{padding:.5rem 1rem;border:1px solid var(--color-border);border-radius:var(--radius);background:transparent;cursor:pointer}.logout-btn:hover{background:var(--color-bg)}main{flex:1;padding:2rem;max-width:1400px;margin:0 auto;width:100%}.login{min-height:100vh;display:flex;justify-content:center;align-items:center;background:var(--color-bg)}.login-card{background:var(--color-surface);padding:2rem;border-radius:var(--radius);box-shadow:var(--shadow);text-align:center;width:100%;max-width:320px}.login-card h1{margin:0 0 .5rem}.login-card p{margin:0 0 1.5rem;color:var(--color-text-muted)}.login-card form{display:flex;flex-direction:column;gap:1rem}.login-card input{padding:.75rem;border:1px solid var(--color-border);border-radius:var(--radius);font-size:1rem}.login-card button{padding:.75rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer}.login-card button:hover:not(:disabled){background:var(--color-primary-hover)}.login-card button:disabled{opacity:.5;cursor:not-allowed}.error-message{color:var(--color-error);font-size:.875rem}.loading,.error{min-height:100vh;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:1rem}.error button{padding:.5rem 1rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;cursor:pointer}.dashboard h2{margin:0 0 1.5rem}.no-sessions{color:var(--color-text-muted)}.session-list{display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}.session-card{display:block;padding:1rem;background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);text-decoration:none;color:inherit;border-left:4px solid transparent;transition:transform .1s,box-shadow .1s}.session-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px #00000026}.session-card.status-running{border-left-color:var(--color-primary)}.session-card.status-needs_input{border-left-color:var(--color-warning)}.session-card.status-blocked{border-left-color:var(--color-error)}.session-card.status-done{border-left-color:var(--color-success)}.session-repo{font-weight:600;margin-bottom:.25rem}.session-branch{color:var(--color-text-muted);font-size:.875rem;margin-bottom:.5rem}.session-meta{display:flex;justify-content:space-between;align-items:center}.status-badge{display:inline-block;padding:.25rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600;text-transform:uppercase}.status-badge.running{background:#dbeafe;color:var(--color-primary)}.status-badge.needs_input{background:#fef3c7;color:var(--color-warning)}.status-badge.blocked{background:#fee2e2;color:var(--color-error)}.status-badge.done{background:#dcfce7;color:var(--color-success)}.iteration{font-size:.875rem;color:var(--color-text-muted)}.session-loading{text-align:center;padding:2rem}.session-header{display:flex;justify-content:space-between;align-items:flex-start;padding:1rem;background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:1.5rem}.session-info h2{margin:0 0 .25rem}.session-info .branch{margin:0;color:var(--color-text-muted)}.session-status{display:flex;flex-direction:column;align-items:flex-end;gap:.5rem}.session-content{display:grid;grid-template-columns:300px 1fr;gap:1.5rem}@media(max-width:768px){.session-content{grid-template-columns:1fr}}.session-sidebar{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem}.task-list h3{margin:0 0 1rem}.task-list ul{list-style:none;margin:0;padding:0}.task-list .task{display:flex;align-items:flex-start;gap:.5rem;padding:.5rem 0;border-bottom:1px solid var(--color-border)}.task-list .task:last-child{border-bottom:none}.task-status-icon{flex-shrink:0;width:1.25rem;text-align:center}.task.status-completed .task-status-icon{color:var(--color-success)}.task.status-in_progress .task-status-icon{color:var(--color-primary)}.task.status-pending .task-status-icon{color:var(--color-text-muted)}.task-content{flex:1;word-break:break-word}.task.status-completed .task-content{color:var(--color-text-muted)}.no-tasks{color:var(--color-text-muted);margin:0}.session-main{display:flex;flex-direction:column;gap:1rem}.output-log{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem;flex:1;min-height:400px;display:flex;flex-direction:column}.output-log h3{margin:0 0 1rem}.output-content{flex:1;overflow-y:auto;font-family:SF Mono,Monaco,Consolas,monospace;font-size:.875rem;background:#1a1a1a;color:#e0e0e0;padding:1rem;border-radius:4px;max-height:500px}.event{margin-bottom:.5rem;padding:.5rem;border-radius:4px}.event.assistant{background:#2563eb1a}.event.tool-result{background:#0003}.event.tool-result pre{margin:0;white-space:pre-wrap;word-break:break-all;font-size:.75rem;max-height:200px;overflow-y:auto}.event.result{background:#16a34a33;color:#4ade80}.event.system{color:var(--color-text-muted);font-size:.75rem}.content-text{white-space:pre-wrap;word-break:break-word}.content-tool-use{background:#0003;padding:.5rem;border-radius:4px;margin:.25rem 0}.content-tool-use summary{cursor:pointer;color:#93c5fd}.content-tool-use pre{margin:.5rem 0 0;white-space:pre-wrap;word-break:break-all;font-size:.75rem;max-height:200px;overflow-y:auto}.input-prompt{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem;border:2px solid var(--color-warning)}.input-prompt .question{margin:0 0 1rem;font-weight:600}.input-prompt .input-row{display:flex;gap:.5rem}.input-prompt input{flex:1;padding:.75rem;border:1px solid var(--color-border);border-radius:var(--radius);font-size:1rem}.input-prompt button{padding:.75rem 1.5rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer}.input-prompt button:hover:not(:disabled){background:var(--color-primary-hover)}.input-prompt button:disabled{opacity:.5;cursor:not-allowed}.input-prompt.responded{border-color:var(--color-success);background:#dcfce7}.input-prompt.responded .response{margin:0;color:var(--color-success)}.disconnected{min-height:100vh;display:flex;justify-content:center;align-items:center;background:var(--color-bg)}.disconnected-content{background:var(--color-surface);padding:3rem;border-radius:var(--radius);box-shadow:var(--shadow);text-align:center;max-width:400px;width:100%;margin:1rem}.disconnected-icon{font-size:4rem;margin-bottom:1rem;color:var(--color-warning)}.disconnected h1{margin:0 0 1rem;color:var(--color-text)}.disconnected-message{margin:0 0 .5rem;color:var(--color-text)}.disconnected-hint{margin:0 0 1.5rem;color:var(--color-text-muted);font-size:.875rem}.reconnect-btn{padding:.75rem 2rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer;transition:background .15s}.reconnect-btn:hover{background:var(--color-primary-hover)} diff --git a/web/dist/assets/index-twtfKIHy.js b/web/dist/assets/index-twtfKIHy.js new file mode 100644 index 0000000..07ed651 --- /dev/null +++ b/web/dist/assets/index-twtfKIHy.js @@ -0,0 +1,109 @@ +var A_=Object.defineProperty;var Gg=n=>{throw TypeError(n)};var M_=(n,t,s)=>t in n?A_(n,t,{enumerable:!0,configurable:!0,writable:!0,value:s}):n[t]=s;var He=(n,t,s)=>M_(n,typeof t!="symbol"?t+"":t,s),fh=(n,t,s)=>t.has(n)||Gg("Cannot "+s);var x=(n,t,s)=>(fh(n,t,"read from private field"),s?s.call(n):t.get(n)),ne=(n,t,s)=>t.has(n)?Gg("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(n):t.set(n,s),ee=(n,t,s,r)=>(fh(n,t,"write to private field"),r?r.call(n,s):t.set(n,s),s),V=(n,t,s)=>(fh(n,t,"access private method"),s);var hh=(n,t,s,r)=>({set _(l){ee(n,t,l,s)},get _(){return x(n,t,r)}});(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const u of l)if(u.type==="childList")for(const c of u.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&r(c)}).observe(document,{childList:!0,subtree:!0});function s(l){const u={};return l.integrity&&(u.integrity=l.integrity),l.referrerPolicy&&(u.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?u.credentials="include":l.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function r(l){if(l.ep)return;l.ep=!0;const u=s(l);fetch(l.href,u)}})();function D_(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var dh={exports:{}},La={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Yg;function k_(){if(Yg)return La;Yg=1;var n=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function s(r,l,u){var c=null;if(u!==void 0&&(c=""+u),l.key!==void 0&&(c=""+l.key),"key"in l){u={};for(var d in l)d!=="key"&&(u[d]=l[d])}else u=l;return l=u.ref,{$$typeof:n,type:r,key:c,ref:l!==void 0?l:null,props:u}}return La.Fragment=t,La.jsx=s,La.jsxs=s,La}var Jg;function N_(){return Jg||(Jg=1,dh.exports=k_()),dh.exports}var Y=N_(),ph={exports:{}},pe={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Qg;function j_(){if(Qg)return pe;Qg=1;var n=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),l=Symbol.for("react.profiler"),u=Symbol.for("react.consumer"),c=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),m=Symbol.for("react.memo"),g=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),w=Symbol.iterator;function S(R){return R===null||typeof R!="object"?null:(R=w&&R[w]||R["@@iterator"],typeof R=="function"?R:null)}var b={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},E=Object.assign,O={};function M(R,I,X){this.props=R,this.context=I,this.refs=O,this.updater=X||b}M.prototype.isReactComponent={},M.prototype.setState=function(R,I){if(typeof R!="object"&&typeof R!="function"&&R!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,R,I,"setState")},M.prototype.forceUpdate=function(R){this.updater.enqueueForceUpdate(this,R,"forceUpdate")};function L(){}L.prototype=M.prototype;function N(R,I,X){this.props=R,this.context=I,this.refs=O,this.updater=X||b}var K=N.prototype=new L;K.constructor=N,E(K,M.prototype),K.isPureReactComponent=!0;var F=Array.isArray;function P(){}var J={H:null,A:null,T:null,S:null},te=Object.prototype.hasOwnProperty;function W(R,I,X){var re=X.ref;return{$$typeof:n,type:R,key:I,ref:re!==void 0?re:null,props:X}}function le(R,I){return W(R.type,I,R.props)}function ie(R){return typeof R=="object"&&R!==null&&R.$$typeof===n}function he(R){var I={"=":"=0",":":"=2"};return"$"+R.replace(/[=:]/g,function(X){return I[X]})}var ve=/\/+/g;function xt(R,I){return typeof R=="object"&&R!==null&&R.key!=null?he(""+R.key):I.toString(36)}function at(R){switch(R.status){case"fulfilled":return R.value;case"rejected":throw R.reason;default:switch(typeof R.status=="string"?R.then(P,P):(R.status="pending",R.then(function(I){R.status==="pending"&&(R.status="fulfilled",R.value=I)},function(I){R.status==="pending"&&(R.status="rejected",R.reason=I)})),R.status){case"fulfilled":return R.value;case"rejected":throw R.reason}}throw R}function B(R,I,X,re,de){var ge=typeof R;(ge==="undefined"||ge==="boolean")&&(R=null);var ze=!1;if(R===null)ze=!0;else switch(ge){case"bigint":case"string":case"number":ze=!0;break;case"object":switch(R.$$typeof){case n:case t:ze=!0;break;case g:return ze=R._init,B(ze(R._payload),I,X,re,de)}}if(ze)return de=de(R),ze=re===""?"."+xt(R,0):re,F(de)?(X="",ze!=null&&(X=ze.replace(ve,"$&/")+"/"),B(de,I,X,"",function(ns){return ns})):de!=null&&(ie(de)&&(de=le(de,X+(de.key==null||R&&R.key===de.key?"":(""+de.key).replace(ve,"$&/")+"/")+ze)),I.push(de)),1;ze=0;var ct=re===""?".":re+":";if(F(R))for(var Le=0;Le>>1,Oe=B[Te];if(0>>1;Tel(X,oe))rel(de,X)?(B[Te]=de,B[re]=oe,Te=re):(B[Te]=X,B[I]=oe,Te=I);else if(rel(de,oe))B[Te]=de,B[re]=oe,Te=re;else break e}}return Q}function l(B,Q){var oe=B.sortIndex-Q.sortIndex;return oe!==0?oe:B.id-Q.id}if(n.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var u=performance;n.unstable_now=function(){return u.now()}}else{var c=Date,d=c.now();n.unstable_now=function(){return c.now()-d}}var p=[],m=[],g=1,y=null,w=3,S=!1,b=!1,E=!1,O=!1,M=typeof setTimeout=="function"?setTimeout:null,L=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function K(B){for(var Q=s(m);Q!==null;){if(Q.callback===null)r(m);else if(Q.startTime<=B)r(m),Q.sortIndex=Q.expirationTime,t(p,Q);else break;Q=s(m)}}function F(B){if(E=!1,K(B),!b)if(s(p)!==null)b=!0,P||(P=!0,he());else{var Q=s(m);Q!==null&&at(F,Q.startTime-B)}}var P=!1,J=-1,te=5,W=-1;function le(){return O?!0:!(n.unstable_now()-WB&&le());){var Te=y.callback;if(typeof Te=="function"){y.callback=null,w=y.priorityLevel;var Oe=Te(y.expirationTime<=B);if(B=n.unstable_now(),typeof Oe=="function"){y.callback=Oe,K(B),Q=!0;break t}y===s(p)&&r(p),K(B)}else r(p);y=s(p)}if(y!==null)Q=!0;else{var R=s(m);R!==null&&at(F,R.startTime-B),Q=!1}}break e}finally{y=null,w=oe,S=!1}Q=void 0}}finally{Q?he():P=!1}}}var he;if(typeof N=="function")he=function(){N(ie)};else if(typeof MessageChannel<"u"){var ve=new MessageChannel,xt=ve.port2;ve.port1.onmessage=ie,he=function(){xt.postMessage(null)}}else he=function(){M(ie,0)};function at(B,Q){J=M(function(){B(n.unstable_now())},Q)}n.unstable_IdlePriority=5,n.unstable_ImmediatePriority=1,n.unstable_LowPriority=4,n.unstable_NormalPriority=3,n.unstable_Profiling=null,n.unstable_UserBlockingPriority=2,n.unstable_cancelCallback=function(B){B.callback=null},n.unstable_forceFrameRate=function(B){0>B||125Te?(B.sortIndex=oe,t(m,B),s(p)===null&&B===s(m)&&(E?(L(J),J=-1):E=!0,at(F,oe-Te))):(B.sortIndex=Oe,t(p,B),b||S||(b=!0,P||(P=!0,he()))),B},n.unstable_shouldYield=le,n.unstable_wrapCallback=function(B){var Q=w;return function(){var oe=w;w=Q;try{return B.apply(this,arguments)}finally{w=oe}}}})(gh)),gh}var Pg;function B_(){return Pg||(Pg=1,yh.exports=U_()),yh.exports}var vh={exports:{}},Tt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Wg;function L_(){if(Wg)return Tt;Wg=1;var n=ud();function t(p){var m="https://react.dev/errors/"+p;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(t){console.error(t)}}return n(),vh.exports=L_(),vh.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var tv;function Z_(){if(tv)return Ha;tv=1;var n=B_(),t=ud(),s=H_();function r(e){var i="https://react.dev/errors/"+e;if(1Oe||(e.current=Te[Oe],Te[Oe]=null,Oe--)}function X(e,i){Oe++,Te[Oe]=e.current,e.current=i}var re=R(null),de=R(null),ge=R(null),ze=R(null);function ct(e,i){switch(X(ge,i),X(de,e),X(re,null),i.nodeType){case 9:case 11:e=(e=i.documentElement)&&(e=e.namespaceURI)?mg(e):0;break;default:if(e=i.tagName,i=i.namespaceURI)i=mg(i),e=yg(i,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}I(re),X(re,e)}function Le(){I(re),I(de),I(ge)}function ns(e){e.memoizedState!==null&&X(ze,e);var i=re.current,a=yg(i,e.type);i!==a&&(X(de,e),X(re,a))}function Bs(e){de.current===e&&(I(re),I(de)),ze.current===e&&(I(ze),Na._currentValue=oe)}var Dl,kl;function is(e){if(Dl===void 0)try{throw Error()}catch(a){var i=a.stack.trim().match(/\n( *(at )?)/);Dl=i&&i[1]||"",kl=-1)":-1f||T[o]!==D[f]){var H=` +`+T[o].replace(" at new "," at ");return e.displayName&&H.includes("")&&(H=H.replace("",e.displayName)),H}while(1<=o&&0<=f);break}}}finally{Yu=!1,Error.prepareStackTrace=a}return(a=e?e.displayName||e.name:"")?is(a):""}function lb(e,i){switch(e.tag){case 26:case 27:case 5:return is(e.type);case 16:return is("Lazy");case 13:return e.child!==i&&i!==null?is("Suspense Fallback"):is("Suspense");case 19:return is("SuspenseList");case 0:case 15:return Ju(e.type,!1);case 11:return Ju(e.type.render,!1);case 1:return Ju(e.type,!0);case 31:return is("Activity");default:return""}}function Gd(e){try{var i="",a=null;do i+=lb(e,a),a=e,e=e.return;while(e);return i}catch(o){return` +Error generating stack: `+o.message+` +`+o.stack}}var Qu=Object.prototype.hasOwnProperty,Xu=n.unstable_scheduleCallback,Fu=n.unstable_cancelCallback,ob=n.unstable_shouldYield,ub=n.unstable_requestPaint,Yt=n.unstable_now,cb=n.unstable_getCurrentPriorityLevel,Yd=n.unstable_ImmediatePriority,Jd=n.unstable_UserBlockingPriority,Nl=n.unstable_NormalPriority,fb=n.unstable_LowPriority,Qd=n.unstable_IdlePriority,hb=n.log,db=n.unstable_setDisableYieldValue,Gr=null,Jt=null;function mi(e){if(typeof hb=="function"&&db(e),Jt&&typeof Jt.setStrictMode=="function")try{Jt.setStrictMode(Gr,e)}catch{}}var Qt=Math.clz32?Math.clz32:yb,pb=Math.log,mb=Math.LN2;function yb(e){return e>>>=0,e===0?32:31-(pb(e)/mb|0)|0}var jl=256,Ul=262144,Bl=4194304;function ss(e){var i=e&42;if(i!==0)return i;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ll(e,i,a){var o=e.pendingLanes;if(o===0)return 0;var f=0,h=e.suspendedLanes,v=e.pingedLanes;e=e.warmLanes;var _=o&134217727;return _!==0?(o=_&~h,o!==0?f=ss(o):(v&=_,v!==0?f=ss(v):a||(a=_&~e,a!==0&&(f=ss(a))))):(_=o&~h,_!==0?f=ss(_):v!==0?f=ss(v):a||(a=o&~e,a!==0&&(f=ss(a)))),f===0?0:i!==0&&i!==f&&(i&h)===0&&(h=f&-f,a=i&-i,h>=a||h===32&&(a&4194048)!==0)?i:f}function Yr(e,i){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&i)===0}function gb(e,i){switch(e){case 1:case 2:case 4:case 8:case 64:return i+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return i+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Xd(){var e=Bl;return Bl<<=1,(Bl&62914560)===0&&(Bl=4194304),e}function Pu(e){for(var i=[],a=0;31>a;a++)i.push(e);return i}function Jr(e,i){e.pendingLanes|=i,i!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function vb(e,i,a,o,f,h){var v=e.pendingLanes;e.pendingLanes=a,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=a,e.entangledLanes&=a,e.errorRecoveryDisabledLanes&=a,e.shellSuspendCounter=0;var _=e.entanglements,T=e.expirationTimes,D=e.hiddenUpdates;for(a=v&~a;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var xb=/[\n"\\]/g;function ln(e){return e.replace(xb,function(i){return"\\"+i.charCodeAt(0).toString(16)+" "})}function sc(e,i,a,o,f,h,v,_){e.name="",v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"?e.type=v:e.removeAttribute("type"),i!=null?v==="number"?(i===0&&e.value===""||e.value!=i)&&(e.value=""+an(i)):e.value!==""+an(i)&&(e.value=""+an(i)):v!=="submit"&&v!=="reset"||e.removeAttribute("value"),i!=null?rc(e,v,an(i)):a!=null?rc(e,v,an(a)):o!=null&&e.removeAttribute("value"),f==null&&h!=null&&(e.defaultChecked=!!h),f!=null&&(e.checked=f&&typeof f!="function"&&typeof f!="symbol"),_!=null&&typeof _!="function"&&typeof _!="symbol"&&typeof _!="boolean"?e.name=""+an(_):e.removeAttribute("name")}function up(e,i,a,o,f,h,v,_){if(h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"&&(e.type=h),i!=null||a!=null){if(!(h!=="submit"&&h!=="reset"||i!=null)){ic(e);return}a=a!=null?""+an(a):"",i=i!=null?""+an(i):a,_||i===e.value||(e.value=i),e.defaultValue=i}o=o??f,o=typeof o!="function"&&typeof o!="symbol"&&!!o,e.checked=_?e.checked:!!o,e.defaultChecked=!!o,v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(e.name=v),ic(e)}function rc(e,i,a){i==="number"&&$l(e.ownerDocument)===e||e.defaultValue===""+a||(e.defaultValue=""+a)}function Is(e,i,a,o){if(e=e.options,i){i={};for(var f=0;f"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),cc=!1;if(In)try{var Pr={};Object.defineProperty(Pr,"passive",{get:function(){cc=!0}}),window.addEventListener("test",Pr,Pr),window.removeEventListener("test",Pr,Pr)}catch{cc=!1}var gi=null,fc=null,Il=null;function yp(){if(Il)return Il;var e,i=fc,a=i.length,o,f="value"in gi?gi.value:gi.textContent,h=f.length;for(e=0;e=ta),_p=" ",Ep=!1;function xp(e,i){switch(e){case"keyup":return Pb.indexOf(i.keyCode)!==-1;case"keydown":return i.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Tp(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Ys=!1;function ew(e,i){switch(e){case"compositionend":return Tp(i);case"keypress":return i.which!==32?null:(Ep=!0,_p);case"textInput":return e=i.data,e===_p&&Ep?null:e;default:return null}}function tw(e,i){if(Ys)return e==="compositionend"||!yc&&xp(e,i)?(e=yp(),Il=fc=gi=null,Ys=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(i.ctrlKey||i.altKey||i.metaKey)||i.ctrlKey&&i.altKey){if(i.char&&1=i)return{node:a,offset:i-e};e=o}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=kp(a)}}function jp(e,i){return e&&i?e===i?!0:e&&e.nodeType===3?!1:i&&i.nodeType===3?jp(e,i.parentNode):"contains"in e?e.contains(i):e.compareDocumentPosition?!!(e.compareDocumentPosition(i)&16):!1:!1}function Up(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var i=$l(e.document);i instanceof e.HTMLIFrameElement;){try{var a=typeof i.contentWindow.location.href=="string"}catch{a=!1}if(a)e=i.contentWindow;else break;i=$l(e.document)}return i}function Sc(e){var i=e&&e.nodeName&&e.nodeName.toLowerCase();return i&&(i==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||i==="textarea"||e.contentEditable==="true")}var uw=In&&"documentMode"in document&&11>=document.documentMode,Js=null,bc=null,ra=null,wc=!1;function Bp(e,i,a){var o=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;wc||Js==null||Js!==$l(o)||(o=Js,"selectionStart"in o&&Sc(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),ra&&sa(ra,o)||(ra=o,o=Bo(bc,"onSelect"),0>=v,f-=v,On=1<<32-Qt(i)+f|a<ye?(_e=ae,ae=null):_e=ae.sibling;var Ce=k(C,ae,A[ye],Z);if(Ce===null){ae===null&&(ae=_e);break}e&&ae&&Ce.alternate===null&&i(C,ae),z=h(Ce,z,ye),Re===null?ue=Ce:Re.sibling=Ce,Re=Ce,ae=_e}if(ye===A.length)return a(C,ae),xe&&Vn(C,ye),ue;if(ae===null){for(;yeye?(_e=ae,ae=null):_e=ae.sibling;var Hi=k(C,ae,Ce.value,Z);if(Hi===null){ae===null&&(ae=_e);break}e&&ae&&Hi.alternate===null&&i(C,ae),z=h(Hi,z,ye),Re===null?ue=Hi:Re.sibling=Hi,Re=Hi,ae=_e}if(Ce.done)return a(C,ae),xe&&Vn(C,ye),ue;if(ae===null){for(;!Ce.done;ye++,Ce=A.next())Ce=q(C,Ce.value,Z),Ce!==null&&(z=h(Ce,z,ye),Re===null?ue=Ce:Re.sibling=Ce,Re=Ce);return xe&&Vn(C,ye),ue}for(ae=o(ae);!Ce.done;ye++,Ce=A.next())Ce=U(ae,C,ye,Ce.value,Z),Ce!==null&&(e&&Ce.alternate!==null&&ae.delete(Ce.key===null?ye:Ce.key),z=h(Ce,z,ye),Re===null?ue=Ce:Re.sibling=Ce,Re=Ce);return e&&ae.forEach(function(C_){return i(C,C_)}),xe&&Vn(C,ye),ue}function Ue(C,z,A,Z){if(typeof A=="object"&&A!==null&&A.type===E&&A.key===null&&(A=A.props.children),typeof A=="object"&&A!==null){switch(A.$$typeof){case S:e:{for(var ue=A.key;z!==null;){if(z.key===ue){if(ue=A.type,ue===E){if(z.tag===7){a(C,z.sibling),Z=f(z,A.props.children),Z.return=C,C=Z;break e}}else if(z.elementType===ue||typeof ue=="object"&&ue!==null&&ue.$$typeof===te&&ms(ue)===z.type){a(C,z.sibling),Z=f(z,A.props),fa(Z,A),Z.return=C,C=Z;break e}a(C,z);break}else i(C,z);z=z.sibling}A.type===E?(Z=cs(A.props.children,C.mode,Z,A.key),Z.return=C,C=Z):(Z=Wl(A.type,A.key,A.props,null,C.mode,Z),fa(Z,A),Z.return=C,C=Z)}return v(C);case b:e:{for(ue=A.key;z!==null;){if(z.key===ue)if(z.tag===4&&z.stateNode.containerInfo===A.containerInfo&&z.stateNode.implementation===A.implementation){a(C,z.sibling),Z=f(z,A.children||[]),Z.return=C,C=Z;break e}else{a(C,z);break}else i(C,z);z=z.sibling}Z=Rc(A,C.mode,Z),Z.return=C,C=Z}return v(C);case te:return A=ms(A),Ue(C,z,A,Z)}if(at(A))return se(C,z,A,Z);if(he(A)){if(ue=he(A),typeof ue!="function")throw Error(r(150));return A=ue.call(A),fe(C,z,A,Z)}if(typeof A.then=="function")return Ue(C,z,ao(A),Z);if(A.$$typeof===N)return Ue(C,z,no(C,A),Z);lo(C,A)}return typeof A=="string"&&A!==""||typeof A=="number"||typeof A=="bigint"?(A=""+A,z!==null&&z.tag===6?(a(C,z.sibling),Z=f(z,A),Z.return=C,C=Z):(a(C,z),Z=zc(A,C.mode,Z),Z.return=C,C=Z),v(C)):a(C,z)}return function(C,z,A,Z){try{ca=0;var ue=Ue(C,z,A,Z);return rr=null,ue}catch(ae){if(ae===sr||ae===so)throw ae;var Re=Ft(29,ae,null,C.mode);return Re.lanes=Z,Re.return=C,Re}finally{}}}var gs=am(!0),lm=am(!1),_i=!1;function Zc(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function $c(e,i){e=e.updateQueue,i.updateQueue===e&&(i.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ei(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function xi(e,i,a){var o=e.updateQueue;if(o===null)return null;if(o=o.shared,(Ae&2)!==0){var f=o.pending;return f===null?i.next=i:(i.next=f.next,f.next=i),o.pending=i,i=Pl(e),Kp(e,null,a),i}return Fl(e,o,i,a),Pl(e)}function ha(e,i,a){if(i=i.updateQueue,i!==null&&(i=i.shared,(a&4194048)!==0)){var o=i.lanes;o&=e.pendingLanes,a|=o,i.lanes=a,Pd(e,a)}}function qc(e,i){var a=e.updateQueue,o=e.alternate;if(o!==null&&(o=o.updateQueue,a===o)){var f=null,h=null;if(a=a.firstBaseUpdate,a!==null){do{var v={lane:a.lane,tag:a.tag,payload:a.payload,callback:null,next:null};h===null?f=h=v:h=h.next=v,a=a.next}while(a!==null);h===null?f=h=i:h=h.next=i}else f=h=i;a={baseState:o.baseState,firstBaseUpdate:f,lastBaseUpdate:h,shared:o.shared,callbacks:o.callbacks},e.updateQueue=a;return}e=a.lastBaseUpdate,e===null?a.firstBaseUpdate=i:e.next=i,a.lastBaseUpdate=i}var Ic=!1;function da(){if(Ic){var e=ir;if(e!==null)throw e}}function pa(e,i,a,o){Ic=!1;var f=e.updateQueue;_i=!1;var h=f.firstBaseUpdate,v=f.lastBaseUpdate,_=f.shared.pending;if(_!==null){f.shared.pending=null;var T=_,D=T.next;T.next=null,v===null?h=D:v.next=D,v=T;var H=e.alternate;H!==null&&(H=H.updateQueue,_=H.lastBaseUpdate,_!==v&&(_===null?H.firstBaseUpdate=D:_.next=D,H.lastBaseUpdate=T))}if(h!==null){var q=f.baseState;v=0,H=D=T=null,_=h;do{var k=_.lane&-536870913,U=k!==_.lane;if(U?(we&k)===k:(o&k)===k){k!==0&&k===nr&&(Ic=!0),H!==null&&(H=H.next={lane:0,tag:_.tag,payload:_.payload,callback:null,next:null});e:{var se=e,fe=_;k=i;var Ue=a;switch(fe.tag){case 1:if(se=fe.payload,typeof se=="function"){q=se.call(Ue,q,k);break e}q=se;break e;case 3:se.flags=se.flags&-65537|128;case 0:if(se=fe.payload,k=typeof se=="function"?se.call(Ue,q,k):se,k==null)break e;q=y({},q,k);break e;case 2:_i=!0}}k=_.callback,k!==null&&(e.flags|=64,U&&(e.flags|=8192),U=f.callbacks,U===null?f.callbacks=[k]:U.push(k))}else U={lane:k,tag:_.tag,payload:_.payload,callback:_.callback,next:null},H===null?(D=H=U,T=q):H=H.next=U,v|=k;if(_=_.next,_===null){if(_=f.shared.pending,_===null)break;U=_,_=U.next,U.next=null,f.lastBaseUpdate=U,f.shared.pending=null}}while(!0);H===null&&(T=q),f.baseState=T,f.firstBaseUpdate=D,f.lastBaseUpdate=H,h===null&&(f.shared.lanes=0),Ci|=v,e.lanes=v,e.memoizedState=q}}function om(e,i){if(typeof e!="function")throw Error(r(191,e));e.call(i)}function um(e,i){var a=e.callbacks;if(a!==null)for(e.callbacks=null,e=0;eh?h:8;var v=B.T,_={};B.T=_,uf(e,!1,i,a);try{var T=f(),D=B.S;if(D!==null&&D(_,T),T!==null&&typeof T=="object"&&typeof T.then=="function"){var H=vw(T,o);ga(e,i,H,nn(e))}else ga(e,i,o,nn(e))}catch(q){ga(e,i,{then:function(){},status:"rejected",reason:q},nn())}finally{Q.p=h,v!==null&&_.types!==null&&(v.types=_.types),B.T=v}}function xw(){}function lf(e,i,a,o){if(e.tag!==5)throw Error(r(476));var f=$m(e).queue;Zm(e,f,i,oe,a===null?xw:function(){return qm(e),a(o)})}function $m(e){var i=e.memoizedState;if(i!==null)return i;i={memoizedState:oe,baseState:oe,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Qn,lastRenderedState:oe},next:null};var a={};return i.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Qn,lastRenderedState:a},next:null},e.memoizedState=i,e=e.alternate,e!==null&&(e.memoizedState=i),i}function qm(e){var i=$m(e);i.next===null&&(i=e.alternate.memoizedState),ga(e,i.next.queue,{},nn())}function of(){return yt(Na)}function Im(){return We().memoizedState}function Km(){return We().memoizedState}function Tw(e){for(var i=e.return;i!==null;){switch(i.tag){case 24:case 3:var a=nn();e=Ei(a);var o=xi(i,e,a);o!==null&&(qt(o,i,a),ha(o,i,a)),i={cache:Uc()},e.payload=i;return}i=i.return}}function Ow(e,i,a){var o=nn();a={lane:o,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},vo(e)?Gm(i,a):(a=Tc(e,i,a,o),a!==null&&(qt(a,e,o),Ym(a,i,o)))}function Vm(e,i,a){var o=nn();ga(e,i,a,o)}function ga(e,i,a,o){var f={lane:o,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null};if(vo(e))Gm(i,f);else{var h=e.alternate;if(e.lanes===0&&(h===null||h.lanes===0)&&(h=i.lastRenderedReducer,h!==null))try{var v=i.lastRenderedState,_=h(v,a);if(f.hasEagerState=!0,f.eagerState=_,Xt(_,v))return Fl(e,i,f,0),Be===null&&Xl(),!1}catch{}finally{}if(a=Tc(e,i,f,o),a!==null)return qt(a,e,o),Ym(a,i,o),!0}return!1}function uf(e,i,a,o){if(o={lane:2,revertLane:$f(),gesture:null,action:o,hasEagerState:!1,eagerState:null,next:null},vo(e)){if(i)throw Error(r(479))}else i=Tc(e,a,o,2),i!==null&&qt(i,e,2)}function vo(e){var i=e.alternate;return e===me||i!==null&&i===me}function Gm(e,i){lr=co=!0;var a=e.pending;a===null?i.next=i:(i.next=a.next,a.next=i),e.pending=i}function Ym(e,i,a){if((a&4194048)!==0){var o=i.lanes;o&=e.pendingLanes,a|=o,i.lanes=a,Pd(e,a)}}var va={readContext:yt,use:po,useCallback:Je,useContext:Je,useEffect:Je,useImperativeHandle:Je,useLayoutEffect:Je,useInsertionEffect:Je,useMemo:Je,useReducer:Je,useRef:Je,useState:Je,useDebugValue:Je,useDeferredValue:Je,useTransition:Je,useSyncExternalStore:Je,useId:Je,useHostTransitionStatus:Je,useFormState:Je,useActionState:Je,useOptimistic:Je,useMemoCache:Je,useCacheRefresh:Je};va.useEffectEvent=Je;var Jm={readContext:yt,use:po,useCallback:function(e,i){return Ct().memoizedState=[e,i===void 0?null:i],e},useContext:yt,useEffect:Mm,useImperativeHandle:function(e,i,a){a=a!=null?a.concat([e]):null,yo(4194308,4,jm.bind(null,i,e),a)},useLayoutEffect:function(e,i){return yo(4194308,4,e,i)},useInsertionEffect:function(e,i){yo(4,2,e,i)},useMemo:function(e,i){var a=Ct();i=i===void 0?null:i;var o=e();if(vs){mi(!0);try{e()}finally{mi(!1)}}return a.memoizedState=[o,i],o},useReducer:function(e,i,a){var o=Ct();if(a!==void 0){var f=a(i);if(vs){mi(!0);try{a(i)}finally{mi(!1)}}}else f=i;return o.memoizedState=o.baseState=f,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:f},o.queue=e,e=e.dispatch=Ow.bind(null,me,e),[o.memoizedState,e]},useRef:function(e){var i=Ct();return e={current:e},i.memoizedState=e},useState:function(e){e=tf(e);var i=e.queue,a=Vm.bind(null,me,i);return i.dispatch=a,[e.memoizedState,a]},useDebugValue:rf,useDeferredValue:function(e,i){var a=Ct();return af(a,e,i)},useTransition:function(){var e=tf(!1);return e=Zm.bind(null,me,e.queue,!0,!1),Ct().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,i,a){var o=me,f=Ct();if(xe){if(a===void 0)throw Error(r(407));a=a()}else{if(a=i(),Be===null)throw Error(r(349));(we&127)!==0||mm(o,i,a)}f.memoizedState=a;var h={value:a,getSnapshot:i};return f.queue=h,Mm(gm.bind(null,o,h,e),[e]),o.flags|=2048,ur(9,{destroy:void 0},ym.bind(null,o,h,a,i),null),a},useId:function(){var e=Ct(),i=Be.identifierPrefix;if(xe){var a=zn,o=On;a=(o&~(1<<32-Qt(o)-1)).toString(32)+a,i="_"+i+"R_"+a,a=fo++,0<\/script>",h=h.removeChild(h.firstChild);break;case"select":h=typeof o.is=="string"?v.createElement("select",{is:o.is}):v.createElement("select"),o.multiple?h.multiple=!0:o.size&&(h.size=o.size);break;default:h=typeof o.is=="string"?v.createElement(f,{is:o.is}):v.createElement(f)}}h[pt]=i,h[Ut]=o;e:for(v=i.child;v!==null;){if(v.tag===5||v.tag===6)h.appendChild(v.stateNode);else if(v.tag!==4&&v.tag!==27&&v.child!==null){v.child.return=v,v=v.child;continue}if(v===i)break e;for(;v.sibling===null;){if(v.return===null||v.return===i)break e;v=v.return}v.sibling.return=v.return,v=v.sibling}i.stateNode=h;e:switch(vt(h,f,o),f){case"button":case"input":case"select":case"textarea":o=!!o.autoFocus;break e;case"img":o=!0;break e;default:o=!1}o&&Fn(i)}}return $e(i),Ef(i,i.type,e===null?null:e.memoizedProps,i.pendingProps,a),null;case 6:if(e&&i.stateNode!=null)e.memoizedProps!==o&&Fn(i);else{if(typeof o!="string"&&i.stateNode===null)throw Error(r(166));if(e=ge.current,er(i)){if(e=i.stateNode,a=i.memoizedProps,o=null,f=mt,f!==null)switch(f.tag){case 27:case 5:o=f.memoizedProps}e[pt]=i,e=!!(e.nodeValue===a||o!==null&&o.suppressHydrationWarning===!0||dg(e.nodeValue,a)),e||bi(i,!0)}else e=Lo(e).createTextNode(o),e[pt]=i,i.stateNode=e}return $e(i),null;case 31:if(a=i.memoizedState,e===null||e.memoizedState!==null){if(o=er(i),a!==null){if(e===null){if(!o)throw Error(r(318));if(e=i.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(r(557));e[pt]=i}else fs(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;$e(i),e=!1}else a=Dc(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),e=!0;if(!e)return i.flags&256?(Wt(i),i):(Wt(i),null);if((i.flags&128)!==0)throw Error(r(558))}return $e(i),null;case 13:if(o=i.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(f=er(i),o!==null&&o.dehydrated!==null){if(e===null){if(!f)throw Error(r(318));if(f=i.memoizedState,f=f!==null?f.dehydrated:null,!f)throw Error(r(317));f[pt]=i}else fs(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;$e(i),f=!1}else f=Dc(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=f),f=!0;if(!f)return i.flags&256?(Wt(i),i):(Wt(i),null)}return Wt(i),(i.flags&128)!==0?(i.lanes=a,i):(a=o!==null,e=e!==null&&e.memoizedState!==null,a&&(o=i.child,f=null,o.alternate!==null&&o.alternate.memoizedState!==null&&o.alternate.memoizedState.cachePool!==null&&(f=o.alternate.memoizedState.cachePool.pool),h=null,o.memoizedState!==null&&o.memoizedState.cachePool!==null&&(h=o.memoizedState.cachePool.pool),h!==f&&(o.flags|=2048)),a!==e&&a&&(i.child.flags|=8192),Eo(i,i.updateQueue),$e(i),null);case 4:return Le(),e===null&&Vf(i.stateNode.containerInfo),$e(i),null;case 10:return Yn(i.type),$e(i),null;case 19:if(I(Pe),o=i.memoizedState,o===null)return $e(i),null;if(f=(i.flags&128)!==0,h=o.rendering,h===null)if(f)ba(o,!1);else{if(Qe!==0||e!==null&&(e.flags&128)!==0)for(e=i.child;e!==null;){if(h=uo(e),h!==null){for(i.flags|=128,ba(o,!1),e=h.updateQueue,i.updateQueue=e,Eo(i,e),i.subtreeFlags=0,e=a,a=i.child;a!==null;)Vp(a,e),a=a.sibling;return X(Pe,Pe.current&1|2),xe&&Vn(i,o.treeForkCount),i.child}e=e.sibling}o.tail!==null&&Yt()>Ro&&(i.flags|=128,f=!0,ba(o,!1),i.lanes=4194304)}else{if(!f)if(e=uo(h),e!==null){if(i.flags|=128,f=!0,e=e.updateQueue,i.updateQueue=e,Eo(i,e),ba(o,!0),o.tail===null&&o.tailMode==="hidden"&&!h.alternate&&!xe)return $e(i),null}else 2*Yt()-o.renderingStartTime>Ro&&a!==536870912&&(i.flags|=128,f=!0,ba(o,!1),i.lanes=4194304);o.isBackwards?(h.sibling=i.child,i.child=h):(e=o.last,e!==null?e.sibling=h:i.child=h,o.last=h)}return o.tail!==null?(e=o.tail,o.rendering=e,o.tail=e.sibling,o.renderingStartTime=Yt(),e.sibling=null,a=Pe.current,X(Pe,f?a&1|2:a&1),xe&&Vn(i,o.treeForkCount),e):($e(i),null);case 22:case 23:return Wt(i),Vc(),o=i.memoizedState!==null,e!==null?e.memoizedState!==null!==o&&(i.flags|=8192):o&&(i.flags|=8192),o?(a&536870912)!==0&&(i.flags&128)===0&&($e(i),i.subtreeFlags&6&&(i.flags|=8192)):$e(i),a=i.updateQueue,a!==null&&Eo(i,a.retryQueue),a=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(a=e.memoizedState.cachePool.pool),o=null,i.memoizedState!==null&&i.memoizedState.cachePool!==null&&(o=i.memoizedState.cachePool.pool),o!==a&&(i.flags|=2048),e!==null&&I(ps),null;case 24:return a=null,e!==null&&(a=e.memoizedState.cache),i.memoizedState.cache!==a&&(i.flags|=2048),Yn(et),$e(i),null;case 25:return null;case 30:return null}throw Error(r(156,i.tag))}function Mw(e,i){switch(Ac(i),i.tag){case 1:return e=i.flags,e&65536?(i.flags=e&-65537|128,i):null;case 3:return Yn(et),Le(),e=i.flags,(e&65536)!==0&&(e&128)===0?(i.flags=e&-65537|128,i):null;case 26:case 27:case 5:return Bs(i),null;case 31:if(i.memoizedState!==null){if(Wt(i),i.alternate===null)throw Error(r(340));fs()}return e=i.flags,e&65536?(i.flags=e&-65537|128,i):null;case 13:if(Wt(i),e=i.memoizedState,e!==null&&e.dehydrated!==null){if(i.alternate===null)throw Error(r(340));fs()}return e=i.flags,e&65536?(i.flags=e&-65537|128,i):null;case 19:return I(Pe),null;case 4:return Le(),null;case 10:return Yn(i.type),null;case 22:case 23:return Wt(i),Vc(),e!==null&&I(ps),e=i.flags,e&65536?(i.flags=e&-65537|128,i):null;case 24:return Yn(et),null;case 25:return null;default:return null}}function vy(e,i){switch(Ac(i),i.tag){case 3:Yn(et),Le();break;case 26:case 27:case 5:Bs(i);break;case 4:Le();break;case 31:i.memoizedState!==null&&Wt(i);break;case 13:Wt(i);break;case 19:I(Pe);break;case 10:Yn(i.type);break;case 22:case 23:Wt(i),Vc(),e!==null&&I(ps);break;case 24:Yn(et)}}function wa(e,i){try{var a=i.updateQueue,o=a!==null?a.lastEffect:null;if(o!==null){var f=o.next;a=f;do{if((a.tag&e)===e){o=void 0;var h=a.create,v=a.inst;o=h(),v.destroy=o}a=a.next}while(a!==f)}}catch(_){De(i,i.return,_)}}function zi(e,i,a){try{var o=i.updateQueue,f=o!==null?o.lastEffect:null;if(f!==null){var h=f.next;o=h;do{if((o.tag&e)===e){var v=o.inst,_=v.destroy;if(_!==void 0){v.destroy=void 0,f=i;var T=a,D=_;try{D()}catch(H){De(f,T,H)}}}o=o.next}while(o!==h)}}catch(H){De(i,i.return,H)}}function Sy(e){var i=e.updateQueue;if(i!==null){var a=e.stateNode;try{um(i,a)}catch(o){De(e,e.return,o)}}}function by(e,i,a){a.props=Ss(e.type,e.memoizedProps),a.state=e.memoizedState;try{a.componentWillUnmount()}catch(o){De(e,i,o)}}function _a(e,i){try{var a=e.ref;if(a!==null){switch(e.tag){case 26:case 27:case 5:var o=e.stateNode;break;case 30:o=e.stateNode;break;default:o=e.stateNode}typeof a=="function"?e.refCleanup=a(o):a.current=o}}catch(f){De(e,i,f)}}function Rn(e,i){var a=e.ref,o=e.refCleanup;if(a!==null)if(typeof o=="function")try{o()}catch(f){De(e,i,f)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof a=="function")try{a(null)}catch(f){De(e,i,f)}else a.current=null}function wy(e){var i=e.type,a=e.memoizedProps,o=e.stateNode;try{e:switch(i){case"button":case"input":case"select":case"textarea":a.autoFocus&&o.focus();break e;case"img":a.src?o.src=a.src:a.srcSet&&(o.srcset=a.srcSet)}}catch(f){De(e,e.return,f)}}function xf(e,i,a){try{var o=e.stateNode;Ww(o,e.type,a,i),o[Ut]=i}catch(f){De(e,e.return,f)}}function _y(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Ni(e.type)||e.tag===4}function Tf(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||_y(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Ni(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Of(e,i,a){var o=e.tag;if(o===5||o===6)e=e.stateNode,i?(a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a).insertBefore(e,i):(i=a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a,i.appendChild(e),a=a._reactRootContainer,a!=null||i.onclick!==null||(i.onclick=qn));else if(o!==4&&(o===27&&Ni(e.type)&&(a=e.stateNode,i=null),e=e.child,e!==null))for(Of(e,i,a),e=e.sibling;e!==null;)Of(e,i,a),e=e.sibling}function xo(e,i,a){var o=e.tag;if(o===5||o===6)e=e.stateNode,i?a.insertBefore(e,i):a.appendChild(e);else if(o!==4&&(o===27&&Ni(e.type)&&(a=e.stateNode),e=e.child,e!==null))for(xo(e,i,a),e=e.sibling;e!==null;)xo(e,i,a),e=e.sibling}function Ey(e){var i=e.stateNode,a=e.memoizedProps;try{for(var o=e.type,f=i.attributes;f.length;)i.removeAttributeNode(f[0]);vt(i,o,a),i[pt]=e,i[Ut]=a}catch(h){De(e,e.return,h)}}var Pn=!1,it=!1,zf=!1,xy=typeof WeakSet=="function"?WeakSet:Set,ht=null;function Dw(e,i){if(e=e.containerInfo,Jf=Vo,e=Up(e),Sc(e)){if("selectionStart"in e)var a={start:e.selectionStart,end:e.selectionEnd};else e:{a=(a=e.ownerDocument)&&a.defaultView||window;var o=a.getSelection&&a.getSelection();if(o&&o.rangeCount!==0){a=o.anchorNode;var f=o.anchorOffset,h=o.focusNode;o=o.focusOffset;try{a.nodeType,h.nodeType}catch{a=null;break e}var v=0,_=-1,T=-1,D=0,H=0,q=e,k=null;t:for(;;){for(var U;q!==a||f!==0&&q.nodeType!==3||(_=v+f),q!==h||o!==0&&q.nodeType!==3||(T=v+o),q.nodeType===3&&(v+=q.nodeValue.length),(U=q.firstChild)!==null;)k=q,q=U;for(;;){if(q===e)break t;if(k===a&&++D===f&&(_=v),k===h&&++H===o&&(T=v),(U=q.nextSibling)!==null)break;q=k,k=q.parentNode}q=U}a=_===-1||T===-1?null:{start:_,end:T}}else a=null}a=a||{start:0,end:0}}else a=null;for(Qf={focusedElem:e,selectionRange:a},Vo=!1,ht=i;ht!==null;)if(i=ht,e=i.child,(i.subtreeFlags&1028)!==0&&e!==null)e.return=i,ht=e;else for(;ht!==null;){switch(i=ht,h=i.alternate,e=i.flags,i.tag){case 0:if((e&4)!==0&&(e=i.updateQueue,e=e!==null?e.events:null,e!==null))for(a=0;a title"))),vt(h,o,a),h[pt]=e,ft(h),o=h;break e;case"link":var v=Ag("link","href",f).get(o+(a.href||""));if(v){for(var _=0;_Ue&&(v=Ue,Ue=fe,fe=v);var C=Np(_,fe),z=Np(_,Ue);if(C&&z&&(U.rangeCount!==1||U.anchorNode!==C.node||U.anchorOffset!==C.offset||U.focusNode!==z.node||U.focusOffset!==z.offset)){var A=q.createRange();A.setStart(C.node,C.offset),U.removeAllRanges(),fe>Ue?(U.addRange(A),U.extend(z.node,z.offset)):(A.setEnd(z.node,z.offset),U.addRange(A))}}}}for(q=[],U=_;U=U.parentNode;)U.nodeType===1&&q.push({element:U,left:U.scrollLeft,top:U.scrollTop});for(typeof _.focus=="function"&&_.focus(),_=0;_a?32:a,B.T=null,a=Nf,Nf=null;var h=Mi,v=ii;if(lt=0,pr=Mi=null,ii=0,(Ae&6)!==0)throw Error(r(331));var _=Ae;if(Ae|=4,jy(h.current),Dy(h,h.current,v,a),Ae=_,Ra(0,!1),Jt&&typeof Jt.onPostCommitFiberRoot=="function")try{Jt.onPostCommitFiberRoot(Gr,h)}catch{}return!0}finally{Q.p=f,B.T=o,Wy(e,i)}}function tg(e,i,a){i=un(a,i),i=df(e.stateNode,i,2),e=xi(e,i,2),e!==null&&(Jr(e,2),Cn(e))}function De(e,i,a){if(e.tag===3)tg(e,e,a);else for(;i!==null;){if(i.tag===3){tg(i,e,a);break}else if(i.tag===1){var o=i.stateNode;if(typeof i.type.getDerivedStateFromError=="function"||typeof o.componentDidCatch=="function"&&(Ai===null||!Ai.has(o))){e=un(a,e),a=ny(2),o=xi(i,a,2),o!==null&&(iy(a,o,i,e),Jr(o,2),Cn(o));break}}i=i.return}}function Lf(e,i,a){var o=e.pingCache;if(o===null){o=e.pingCache=new jw;var f=new Set;o.set(i,f)}else f=o.get(i),f===void 0&&(f=new Set,o.set(i,f));f.has(a)||(Af=!0,f.add(a),e=Zw.bind(null,e,i,a),i.then(e,e))}function Zw(e,i,a){var o=e.pingCache;o!==null&&o.delete(i),e.pingedLanes|=e.suspendedLanes&a,e.warmLanes&=~a,Be===e&&(we&a)===a&&(Qe===4||Qe===3&&(we&62914560)===we&&300>Yt()-zo?(Ae&2)===0&&mr(e,0):Mf|=a,dr===we&&(dr=0)),Cn(e)}function ng(e,i){i===0&&(i=Xd()),e=us(e,i),e!==null&&(Jr(e,i),Cn(e))}function $w(e){var i=e.memoizedState,a=0;i!==null&&(a=i.retryLane),ng(e,a)}function qw(e,i){var a=0;switch(e.tag){case 31:case 13:var o=e.stateNode,f=e.memoizedState;f!==null&&(a=f.retryLane);break;case 19:o=e.stateNode;break;case 22:o=e.stateNode._retryCache;break;default:throw Error(r(314))}o!==null&&o.delete(i),ng(e,a)}function Iw(e,i){return Xu(e,i)}var No=null,gr=null,Hf=!1,jo=!1,Zf=!1,ki=0;function Cn(e){e!==gr&&e.next===null&&(gr===null?No=gr=e:gr=gr.next=e),jo=!0,Hf||(Hf=!0,Vw())}function Ra(e,i){if(!Zf&&jo){Zf=!0;do for(var a=!1,o=No;o!==null;){if(e!==0){var f=o.pendingLanes;if(f===0)var h=0;else{var v=o.suspendedLanes,_=o.pingedLanes;h=(1<<31-Qt(42|e)+1)-1,h&=f&~(v&~_),h=h&201326741?h&201326741|1:h?h|2:0}h!==0&&(a=!0,ag(o,h))}else h=we,h=Ll(o,o===Be?h:0,o.cancelPendingCommit!==null||o.timeoutHandle!==-1),(h&3)===0||Yr(o,h)||(a=!0,ag(o,h));o=o.next}while(a);Zf=!1}}function Kw(){ig()}function ig(){jo=Hf=!1;var e=0;ki!==0&&t_()&&(e=ki);for(var i=Yt(),a=null,o=No;o!==null;){var f=o.next,h=sg(o,i);h===0?(o.next=null,a===null?No=f:a.next=f,f===null&&(gr=a)):(a=o,(e!==0||(h&3)!==0)&&(jo=!0)),o=f}lt!==0&<!==5||Ra(e),ki!==0&&(ki=0)}function sg(e,i){for(var a=e.suspendedLanes,o=e.pingedLanes,f=e.expirationTimes,h=e.pendingLanes&-62914561;0_)break;var H=T.transferSize,q=T.initiatorType;H&&pg(q)&&(T=T.responseEnd,v+=H*(T<_?1:(_-D)/(T-D)))}if(--o,i+=8*(h+v)/(f.duration/1e3),e++,10"u"?null:document;function Og(e,i,a){var o=vr;if(o&&typeof i=="string"&&i){var f=ln(i);f='link[rel="'+e+'"][href="'+f+'"]',typeof a=="string"&&(f+='[crossorigin="'+a+'"]'),Tg.has(f)||(Tg.add(f),e={rel:e,crossOrigin:a,href:i},o.querySelector(f)===null&&(i=o.createElement("link"),vt(i,"link",e),ft(i),o.head.appendChild(i)))}}function c_(e){si.D(e),Og("dns-prefetch",e,null)}function f_(e,i){si.C(e,i),Og("preconnect",e,i)}function h_(e,i,a){si.L(e,i,a);var o=vr;if(o&&e&&i){var f='link[rel="preload"][as="'+ln(i)+'"]';i==="image"&&a&&a.imageSrcSet?(f+='[imagesrcset="'+ln(a.imageSrcSet)+'"]',typeof a.imageSizes=="string"&&(f+='[imagesizes="'+ln(a.imageSizes)+'"]')):f+='[href="'+ln(e)+'"]';var h=f;switch(i){case"style":h=Sr(e);break;case"script":h=br(e)}mn.has(h)||(e=y({rel:"preload",href:i==="image"&&a&&a.imageSrcSet?void 0:e,as:i},a),mn.set(h,e),o.querySelector(f)!==null||i==="style"&&o.querySelector(Da(h))||i==="script"&&o.querySelector(ka(h))||(i=o.createElement("link"),vt(i,"link",e),ft(i),o.head.appendChild(i)))}}function d_(e,i){si.m(e,i);var a=vr;if(a&&e){var o=i&&typeof i.as=="string"?i.as:"script",f='link[rel="modulepreload"][as="'+ln(o)+'"][href="'+ln(e)+'"]',h=f;switch(o){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":h=br(e)}if(!mn.has(h)&&(e=y({rel:"modulepreload",href:e},i),mn.set(h,e),a.querySelector(f)===null)){switch(o){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(a.querySelector(ka(h)))return}o=a.createElement("link"),vt(o,"link",e),ft(o),a.head.appendChild(o)}}}function p_(e,i,a){si.S(e,i,a);var o=vr;if(o&&e){var f=$s(o).hoistableStyles,h=Sr(e);i=i||"default";var v=f.get(h);if(!v){var _={loading:0,preload:null};if(v=o.querySelector(Da(h)))_.loading=5;else{e=y({rel:"stylesheet",href:e,"data-precedence":i},a),(a=mn.get(h))&&nh(e,a);var T=v=o.createElement("link");ft(T),vt(T,"link",e),T._p=new Promise(function(D,H){T.onload=D,T.onerror=H}),T.addEventListener("load",function(){_.loading|=1}),T.addEventListener("error",function(){_.loading|=2}),_.loading|=4,Zo(v,i,o)}v={type:"stylesheet",instance:v,count:1,state:_},f.set(h,v)}}}function m_(e,i){si.X(e,i);var a=vr;if(a&&e){var o=$s(a).hoistableScripts,f=br(e),h=o.get(f);h||(h=a.querySelector(ka(f)),h||(e=y({src:e,async:!0},i),(i=mn.get(f))&&ih(e,i),h=a.createElement("script"),ft(h),vt(h,"link",e),a.head.appendChild(h)),h={type:"script",instance:h,count:1,state:null},o.set(f,h))}}function y_(e,i){si.M(e,i);var a=vr;if(a&&e){var o=$s(a).hoistableScripts,f=br(e),h=o.get(f);h||(h=a.querySelector(ka(f)),h||(e=y({src:e,async:!0,type:"module"},i),(i=mn.get(f))&&ih(e,i),h=a.createElement("script"),ft(h),vt(h,"link",e),a.head.appendChild(h)),h={type:"script",instance:h,count:1,state:null},o.set(f,h))}}function zg(e,i,a,o){var f=(f=ge.current)?Ho(f):null;if(!f)throw Error(r(446));switch(e){case"meta":case"title":return null;case"style":return typeof a.precedence=="string"&&typeof a.href=="string"?(i=Sr(a.href),a=$s(f).hoistableStyles,o=a.get(i),o||(o={type:"style",instance:null,count:0,state:null},a.set(i,o)),o):{type:"void",instance:null,count:0,state:null};case"link":if(a.rel==="stylesheet"&&typeof a.href=="string"&&typeof a.precedence=="string"){e=Sr(a.href);var h=$s(f).hoistableStyles,v=h.get(e);if(v||(f=f.ownerDocument||f,v={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},h.set(e,v),(h=f.querySelector(Da(e)))&&!h._p&&(v.instance=h,v.state.loading=5),mn.has(e)||(a={rel:"preload",as:"style",href:a.href,crossOrigin:a.crossOrigin,integrity:a.integrity,media:a.media,hrefLang:a.hrefLang,referrerPolicy:a.referrerPolicy},mn.set(e,a),h||g_(f,e,a,v.state))),i&&o===null)throw Error(r(528,""));return v}if(i&&o!==null)throw Error(r(529,""));return null;case"script":return i=a.async,a=a.src,typeof a=="string"&&i&&typeof i!="function"&&typeof i!="symbol"?(i=br(a),a=$s(f).hoistableScripts,o=a.get(i),o||(o={type:"script",instance:null,count:0,state:null},a.set(i,o)),o):{type:"void",instance:null,count:0,state:null};default:throw Error(r(444,e))}}function Sr(e){return'href="'+ln(e)+'"'}function Da(e){return'link[rel="stylesheet"]['+e+"]"}function Rg(e){return y({},e,{"data-precedence":e.precedence,precedence:null})}function g_(e,i,a,o){e.querySelector('link[rel="preload"][as="style"]['+i+"]")?o.loading=1:(i=e.createElement("link"),o.preload=i,i.addEventListener("load",function(){return o.loading|=1}),i.addEventListener("error",function(){return o.loading|=2}),vt(i,"link",a),ft(i),e.head.appendChild(i))}function br(e){return'[src="'+ln(e)+'"]'}function ka(e){return"script[async]"+e}function Cg(e,i,a){if(i.count++,i.instance===null)switch(i.type){case"style":var o=e.querySelector('style[data-href~="'+ln(a.href)+'"]');if(o)return i.instance=o,ft(o),o;var f=y({},a,{"data-href":a.href,"data-precedence":a.precedence,href:null,precedence:null});return o=(e.ownerDocument||e).createElement("style"),ft(o),vt(o,"style",f),Zo(o,a.precedence,e),i.instance=o;case"stylesheet":f=Sr(a.href);var h=e.querySelector(Da(f));if(h)return i.state.loading|=4,i.instance=h,ft(h),h;o=Rg(a),(f=mn.get(f))&&nh(o,f),h=(e.ownerDocument||e).createElement("link"),ft(h);var v=h;return v._p=new Promise(function(_,T){v.onload=_,v.onerror=T}),vt(h,"link",o),i.state.loading|=4,Zo(h,a.precedence,e),i.instance=h;case"script":return h=br(a.src),(f=e.querySelector(ka(h)))?(i.instance=f,ft(f),f):(o=a,(f=mn.get(h))&&(o=y({},a),ih(o,f)),e=e.ownerDocument||e,f=e.createElement("script"),ft(f),vt(f,"link",o),e.head.appendChild(f),i.instance=f);case"void":return null;default:throw Error(r(443,i.type))}else i.type==="stylesheet"&&(i.state.loading&4)===0&&(o=i.instance,i.state.loading|=4,Zo(o,a.precedence,e));return i.instance}function Zo(e,i,a){for(var o=a.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),f=o.length?o[o.length-1]:null,h=f,v=0;v title"):null)}function v_(e,i,a){if(a===1||i.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof i.precedence!="string"||typeof i.href!="string"||i.href==="")break;return!0;case"link":if(typeof i.rel!="string"||typeof i.href!="string"||i.href===""||i.onLoad||i.onError)break;switch(i.rel){case"stylesheet":return e=i.disabled,typeof i.precedence=="string"&&e==null;default:return!0}case"script":if(i.async&&typeof i.async!="function"&&typeof i.async!="symbol"&&!i.onLoad&&!i.onError&&i.src&&typeof i.src=="string")return!0}return!1}function Dg(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function S_(e,i,a,o){if(a.type==="stylesheet"&&(typeof o.media!="string"||matchMedia(o.media).matches!==!1)&&(a.state.loading&4)===0){if(a.instance===null){var f=Sr(o.href),h=i.querySelector(Da(f));if(h){i=h._p,i!==null&&typeof i=="object"&&typeof i.then=="function"&&(e.count++,e=qo.bind(e),i.then(e,e)),a.state.loading|=4,a.instance=h,ft(h);return}h=i.ownerDocument||i,o=Rg(o),(f=mn.get(f))&&nh(o,f),h=h.createElement("link"),ft(h);var v=h;v._p=new Promise(function(_,T){v.onload=_,v.onerror=T}),vt(h,"link",o),a.instance=h}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(a,i),(i=a.state.preload)&&(a.state.loading&3)===0&&(e.count++,a=qo.bind(e),i.addEventListener("load",a),i.addEventListener("error",a))}}var sh=0;function b_(e,i){return e.stylesheets&&e.count===0&&Ko(e,e.stylesheets),0sh?50:800)+i);return e.unsuspend=a,function(){e.unsuspend=null,clearTimeout(o),clearTimeout(f)}}:null}function qo(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Ko(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Io=null;function Ko(e,i){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Io=new Map,i.forEach(w_,e),Io=null,qo.call(e))}function w_(e,i){if(!(i.state.loading&4)){var a=Io.get(e);if(a)var o=a.get(null);else{a=new Map,Io.set(e,a);for(var f=e.querySelectorAll("link[data-precedence],style[data-precedence]"),h=0;h"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(t){console.error(t)}}return n(),mh.exports=Z_(),mh.exports}var q_=$_();/** + * react-router v7.12.0 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var iv="popstate";function I_(n={}){function t(r,l){let{pathname:u,search:c,hash:d}=r.location;return Uh("",{pathname:u,search:c,hash:d},l.state&&l.state.usr||null,l.state&&l.state.key||"default")}function s(r,l){return typeof l=="string"?l:Pa(l)}return V_(t,s,null,n)}function Ie(n,t){if(n===!1||n===null||typeof n>"u")throw new Error(t)}function gn(n,t){if(!n){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function K_(){return Math.random().toString(36).substring(2,10)}function sv(n,t){return{usr:n.state,key:n.key,idx:t}}function Uh(n,t,s=null,r){return{pathname:typeof n=="string"?n:n.pathname,search:"",hash:"",...typeof t=="string"?Zr(t):t,state:s,key:t&&t.key||r||K_()}}function Pa({pathname:n="/",search:t="",hash:s=""}){return t&&t!=="?"&&(n+=t.charAt(0)==="?"?t:"?"+t),s&&s!=="#"&&(n+=s.charAt(0)==="#"?s:"#"+s),n}function Zr(n){let t={};if(n){let s=n.indexOf("#");s>=0&&(t.hash=n.substring(s),n=n.substring(0,s));let r=n.indexOf("?");r>=0&&(t.search=n.substring(r),n=n.substring(0,r)),n&&(t.pathname=n)}return t}function V_(n,t,s,r={}){let{window:l=document.defaultView,v5Compat:u=!1}=r,c=l.history,d="POP",p=null,m=g();m==null&&(m=0,c.replaceState({...c.state,idx:m},""));function g(){return(c.state||{idx:null}).idx}function y(){d="POP";let O=g(),M=O==null?null:O-m;m=O,p&&p({action:d,location:E.location,delta:M})}function w(O,M){d="PUSH";let L=Uh(E.location,O,M);m=g()+1;let N=sv(L,m),K=E.createHref(L);try{c.pushState(N,"",K)}catch(F){if(F instanceof DOMException&&F.name==="DataCloneError")throw F;l.location.assign(K)}u&&p&&p({action:d,location:E.location,delta:1})}function S(O,M){d="REPLACE";let L=Uh(E.location,O,M);m=g();let N=sv(L,m),K=E.createHref(L);c.replaceState(N,"",K),u&&p&&p({action:d,location:E.location,delta:0})}function b(O){return G_(O)}let E={get action(){return d},get location(){return n(l,c)},listen(O){if(p)throw new Error("A history only accepts one active listener");return l.addEventListener(iv,y),p=O,()=>{l.removeEventListener(iv,y),p=null}},createHref(O){return t(l,O)},createURL:b,encodeLocation(O){let M=b(O);return{pathname:M.pathname,search:M.search,hash:M.hash}},push:w,replace:S,go(O){return c.go(O)}};return E}function G_(n,t=!1){let s="http://localhost";typeof window<"u"&&(s=window.location.origin!=="null"?window.location.origin:window.location.href),Ie(s,"No window.location.(origin|href) available to create URL");let r=typeof n=="string"?n:Pa(n);return r=r.replace(/ $/,"%20"),!t&&r.startsWith("//")&&(r=s+r),new URL(r,s)}function E0(n,t,s="/"){return Y_(n,t,s,!1)}function Y_(n,t,s,r){let l=typeof t=="string"?Zr(t):t,u=di(l.pathname||"/",s);if(u==null)return null;let c=x0(n);J_(c);let d=null;for(let p=0;d==null&&p{let g={relativePath:m===void 0?c.path||"":m,caseSensitive:c.caseSensitive===!0,childrenIndex:d,route:c};if(g.relativePath.startsWith("/")){if(!g.relativePath.startsWith(r)&&p)return;Ie(g.relativePath.startsWith(r),`Absolute route path "${g.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),g.relativePath=g.relativePath.slice(r.length)}let y=hi([r,g.relativePath]),w=s.concat(g);c.children&&c.children.length>0&&(Ie(c.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${y}".`),x0(c.children,t,w,y,p)),!(c.path==null&&!c.index)&&t.push({path:y,score:tE(y,c.index),routesMeta:w})};return n.forEach((c,d)=>{var p;if(c.path===""||!((p=c.path)!=null&&p.includes("?")))u(c,d);else for(let m of T0(c.path))u(c,d,!0,m)}),t}function T0(n){let t=n.split("/");if(t.length===0)return[];let[s,...r]=t,l=s.endsWith("?"),u=s.replace(/\?$/,"");if(r.length===0)return l?[u,""]:[u];let c=T0(r.join("/")),d=[];return d.push(...c.map(p=>p===""?u:[u,p].join("/"))),l&&d.push(...c),d.map(p=>n.startsWith("/")&&p===""?"/":p)}function J_(n){n.sort((t,s)=>t.score!==s.score?s.score-t.score:nE(t.routesMeta.map(r=>r.childrenIndex),s.routesMeta.map(r=>r.childrenIndex)))}var Q_=/^:[\w-]+$/,X_=3,F_=2,P_=1,W_=10,eE=-2,rv=n=>n==="*";function tE(n,t){let s=n.split("/"),r=s.length;return s.some(rv)&&(r+=eE),t&&(r+=F_),s.filter(l=>!rv(l)).reduce((l,u)=>l+(Q_.test(u)?X_:u===""?P_:W_),r)}function nE(n,t){return n.length===t.length&&n.slice(0,-1).every((r,l)=>r===t[l])?n[n.length-1]-t[t.length-1]:0}function iE(n,t,s=!1){let{routesMeta:r}=n,l={},u="/",c=[];for(let d=0;d{if(g==="*"){let b=d[w]||"";c=u.slice(0,u.length-b.length).replace(/(.)\/+$/,"$1")}const S=d[w];return y&&!S?m[g]=void 0:m[g]=(S||"").replace(/%2F/g,"/"),m},{}),pathname:u,pathnameBase:c,pattern:n}}function sE(n,t=!1,s=!0){gn(n==="*"||!n.endsWith("*")||n.endsWith("/*"),`Route path "${n}" will be treated as if it were "${n.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${n.replace(/\*$/,"/*")}".`);let r=[],l="^"+n.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(c,d,p)=>(r.push({paramName:d,isOptional:p!=null}),p?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return n.endsWith("*")?(r.push({paramName:"*"}),l+=n==="*"||n==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):s?l+="\\/*$":n!==""&&n!=="/"&&(l+="(?:(?=\\/|$))"),[new RegExp(l,t?void 0:"i"),r]}function rE(n){try{return n.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return gn(!1,`The URL path "${n}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),n}}function di(n,t){if(t==="/")return n;if(!n.toLowerCase().startsWith(t.toLowerCase()))return null;let s=t.endsWith("/")?t.length-1:t.length,r=n.charAt(s);return r&&r!=="/"?null:n.slice(s)||"/"}var O0=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,aE=n=>O0.test(n);function lE(n,t="/"){let{pathname:s,search:r="",hash:l=""}=typeof n=="string"?Zr(n):n,u;if(s)if(aE(s))u=s;else{if(s.includes("//")){let c=s;s=s.replace(/\/\/+/g,"/"),gn(!1,`Pathnames cannot have embedded double slashes - normalizing ${c} -> ${s}`)}s.startsWith("/")?u=av(s.substring(1),"/"):u=av(s,t)}else u=t;return{pathname:u,search:cE(r),hash:fE(l)}}function av(n,t){let s=t.replace(/\/+$/,"").split("/");return n.split("/").forEach(l=>{l===".."?s.length>1&&s.pop():l!=="."&&s.push(l)}),s.length>1?s.join("/"):"/"}function Sh(n,t,s,r){return`Cannot include a '${n}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${s}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function oE(n){return n.filter((t,s)=>s===0||t.route.path&&t.route.path.length>0)}function cd(n){let t=oE(n);return t.map((s,r)=>r===t.length-1?s.pathname:s.pathnameBase)}function fd(n,t,s,r=!1){let l;typeof n=="string"?l=Zr(n):(l={...n},Ie(!l.pathname||!l.pathname.includes("?"),Sh("?","pathname","search",l)),Ie(!l.pathname||!l.pathname.includes("#"),Sh("#","pathname","hash",l)),Ie(!l.search||!l.search.includes("#"),Sh("#","search","hash",l)));let u=n===""||l.pathname==="",c=u?"/":l.pathname,d;if(c==null)d=s;else{let y=t.length-1;if(!r&&c.startsWith("..")){let w=c.split("/");for(;w[0]==="..";)w.shift(),y-=1;l.pathname=w.join("/")}d=y>=0?t[y]:"/"}let p=lE(l,d),m=c&&c!=="/"&&c.endsWith("/"),g=(u||c===".")&&s.endsWith("/");return!p.pathname.endsWith("/")&&(m||g)&&(p.pathname+="/"),p}var hi=n=>n.join("/").replace(/\/\/+/g,"/"),uE=n=>n.replace(/\/+$/,"").replace(/^\/*/,"/"),cE=n=>!n||n==="?"?"":n.startsWith("?")?n:"?"+n,fE=n=>!n||n==="#"?"":n.startsWith("#")?n:"#"+n,hE=class{constructor(n,t,s,r=!1){this.status=n,this.statusText=t||"",this.internal=r,s instanceof Error?(this.data=s.toString(),this.error=s):this.data=s}};function dE(n){return n!=null&&typeof n.status=="number"&&typeof n.statusText=="string"&&typeof n.internal=="boolean"&&"data"in n}function pE(n){return n.map(t=>t.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var z0=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function R0(n,t){let s=n;if(typeof s!="string"||!O0.test(s))return{absoluteURL:void 0,isExternal:!1,to:s};let r=s,l=!1;if(z0)try{let u=new URL(window.location.href),c=s.startsWith("//")?new URL(u.protocol+s):new URL(s),d=di(c.pathname,t);c.origin===u.origin&&d!=null?s=d+c.search+c.hash:l=!0}catch{gn(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:l,to:s}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var C0=["POST","PUT","PATCH","DELETE"];new Set(C0);var mE=["GET",...C0];new Set(mE);var $r=j.createContext(null);$r.displayName="DataRouter";var Bu=j.createContext(null);Bu.displayName="DataRouterState";var yE=j.createContext(!1),A0=j.createContext({isTransitioning:!1});A0.displayName="ViewTransition";var gE=j.createContext(new Map);gE.displayName="Fetchers";var vE=j.createContext(null);vE.displayName="Await";var sn=j.createContext(null);sn.displayName="Navigation";var zl=j.createContext(null);zl.displayName="Location";var Tn=j.createContext({outlet:null,matches:[],isDataRoute:!1});Tn.displayName="Route";var hd=j.createContext(null);hd.displayName="RouteError";var M0="REACT_ROUTER_ERROR",SE="REDIRECT",bE="ROUTE_ERROR_RESPONSE";function wE(n){if(n.startsWith(`${M0}:${SE}:{`))try{let t=JSON.parse(n.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function _E(n){if(n.startsWith(`${M0}:${bE}:{`))try{let t=JSON.parse(n.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new hE(t.status,t.statusText,t.data)}catch{}}function EE(n,{relative:t}={}){Ie(qr(),"useHref() may be used only in the context of a component.");let{basename:s,navigator:r}=j.useContext(sn),{hash:l,pathname:u,search:c}=Rl(n,{relative:t}),d=u;return s!=="/"&&(d=u==="/"?s:hi([s,u])),r.createHref({pathname:d,search:c,hash:l})}function qr(){return j.useContext(zl)!=null}function Wi(){return Ie(qr(),"useLocation() may be used only in the context of a component."),j.useContext(zl).location}var D0="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function k0(n){j.useContext(sn).static||j.useLayoutEffect(n)}function N0(){let{isDataRoute:n}=j.useContext(Tn);return n?BE():xE()}function xE(){Ie(qr(),"useNavigate() may be used only in the context of a component.");let n=j.useContext($r),{basename:t,navigator:s}=j.useContext(sn),{matches:r}=j.useContext(Tn),{pathname:l}=Wi(),u=JSON.stringify(cd(r)),c=j.useRef(!1);return k0(()=>{c.current=!0}),j.useCallback((p,m={})=>{if(gn(c.current,D0),!c.current)return;if(typeof p=="number"){s.go(p);return}let g=fd(p,JSON.parse(u),l,m.relative==="path");n==null&&t!=="/"&&(g.pathname=g.pathname==="/"?t:hi([t,g.pathname])),(m.replace?s.replace:s.push)(g,m.state,m)},[t,s,u,l,n])}j.createContext(null);function TE(){let{matches:n}=j.useContext(Tn),t=n[n.length-1];return t?t.params:{}}function Rl(n,{relative:t}={}){let{matches:s}=j.useContext(Tn),{pathname:r}=Wi(),l=JSON.stringify(cd(s));return j.useMemo(()=>fd(n,JSON.parse(l),r,t==="path"),[n,l,r,t])}function OE(n,t){return j0(n,t)}function j0(n,t,s,r,l){var L;Ie(qr(),"useRoutes() may be used only in the context of a component.");let{navigator:u}=j.useContext(sn),{matches:c}=j.useContext(Tn),d=c[c.length-1],p=d?d.params:{},m=d?d.pathname:"/",g=d?d.pathnameBase:"/",y=d&&d.route;{let N=y&&y.path||"";B0(m,!y||N.endsWith("*")||N.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${m}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let w=Wi(),S;if(t){let N=typeof t=="string"?Zr(t):t;Ie(g==="/"||((L=N.pathname)==null?void 0:L.startsWith(g)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${g}" but pathname "${N.pathname}" was given in the \`location\` prop.`),S=N}else S=w;let b=S.pathname||"/",E=b;if(g!=="/"){let N=g.replace(/^\//,"").split("/");E="/"+b.replace(/^\//,"").split("/").slice(N.length).join("/")}let O=E0(n,{pathname:E});gn(y||O!=null,`No routes matched location "${S.pathname}${S.search}${S.hash}" `),gn(O==null||O[O.length-1].route.element!==void 0||O[O.length-1].route.Component!==void 0||O[O.length-1].route.lazy!==void 0,`Matched leaf route at location "${S.pathname}${S.search}${S.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let M=ME(O&&O.map(N=>Object.assign({},N,{params:Object.assign({},p,N.params),pathname:hi([g,u.encodeLocation?u.encodeLocation(N.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:N.pathname]),pathnameBase:N.pathnameBase==="/"?g:hi([g,u.encodeLocation?u.encodeLocation(N.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:N.pathnameBase])})),c,s,r,l);return t&&M?j.createElement(zl.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...S},navigationType:"POP"}},M):M}function zE(){let n=UE(),t=dE(n)?`${n.status} ${n.statusText}`:n instanceof Error?n.message:JSON.stringify(n),s=n instanceof Error?n.stack:null,r="rgba(200,200,200, 0.5)",l={padding:"0.5rem",backgroundColor:r},u={padding:"2px 4px",backgroundColor:r},c=null;return console.error("Error handled by React Router default ErrorBoundary:",n),c=j.createElement(j.Fragment,null,j.createElement("p",null,"💿 Hey developer 👋"),j.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",j.createElement("code",{style:u},"ErrorBoundary")," or"," ",j.createElement("code",{style:u},"errorElement")," prop on your route.")),j.createElement(j.Fragment,null,j.createElement("h2",null,"Unexpected Application Error!"),j.createElement("h3",{style:{fontStyle:"italic"}},t),s?j.createElement("pre",{style:l},s):null,c)}var RE=j.createElement(zE,null),U0=class extends j.Component{constructor(n){super(n),this.state={location:n.location,revalidation:n.revalidation,error:n.error}}static getDerivedStateFromError(n){return{error:n}}static getDerivedStateFromProps(n,t){return t.location!==n.location||t.revalidation!=="idle"&&n.revalidation==="idle"?{error:n.error,location:n.location,revalidation:n.revalidation}:{error:n.error!==void 0?n.error:t.error,location:t.location,revalidation:n.revalidation||t.revalidation}}componentDidCatch(n,t){this.props.onError?this.props.onError(n,t):console.error("React Router caught the following error during render",n)}render(){let n=this.state.error;if(this.context&&typeof n=="object"&&n&&"digest"in n&&typeof n.digest=="string"){const s=_E(n.digest);s&&(n=s)}let t=n!==void 0?j.createElement(Tn.Provider,{value:this.props.routeContext},j.createElement(hd.Provider,{value:n,children:this.props.component})):this.props.children;return this.context?j.createElement(CE,{error:n},t):t}};U0.contextType=yE;var bh=new WeakMap;function CE({children:n,error:t}){let{basename:s}=j.useContext(sn);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let r=wE(t.digest);if(r){let l=bh.get(t);if(l)throw l;let u=R0(r.location,s);if(z0&&!bh.get(t))if(u.isExternal||r.reloadDocument)window.location.href=u.absoluteURL||u.to;else{const c=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(u.to,{replace:r.replace}));throw bh.set(t,c),c}return j.createElement("meta",{httpEquiv:"refresh",content:`0;url=${u.absoluteURL||u.to}`})}}return n}function AE({routeContext:n,match:t,children:s}){let r=j.useContext($r);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),j.createElement(Tn.Provider,{value:n},s)}function ME(n,t=[],s=null,r=null,l=null){if(n==null){if(!s)return null;if(s.errors)n=s.matches;else if(t.length===0&&!s.initialized&&s.matches.length>0)n=s.matches;else return null}let u=n,c=s==null?void 0:s.errors;if(c!=null){let g=u.findIndex(y=>y.route.id&&(c==null?void 0:c[y.route.id])!==void 0);Ie(g>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(c).join(",")}`),u=u.slice(0,Math.min(u.length,g+1))}let d=!1,p=-1;if(s)for(let g=0;g=0?u=u.slice(0,p+1):u=[u[0]];break}}}let m=s&&r?(g,y)=>{var w,S;r(g,{location:s.location,params:((S=(w=s.matches)==null?void 0:w[0])==null?void 0:S.params)??{},unstable_pattern:pE(s.matches),errorInfo:y})}:void 0;return u.reduceRight((g,y,w)=>{let S,b=!1,E=null,O=null;s&&(S=c&&y.route.id?c[y.route.id]:void 0,E=y.route.errorElement||RE,d&&(p<0&&w===0?(B0("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),b=!0,O=null):p===w&&(b=!0,O=y.route.hydrateFallbackElement||null)));let M=t.concat(u.slice(0,w+1)),L=()=>{let N;return S?N=E:b?N=O:y.route.Component?N=j.createElement(y.route.Component,null):y.route.element?N=y.route.element:N=g,j.createElement(AE,{match:y,routeContext:{outlet:g,matches:M,isDataRoute:s!=null},children:N})};return s&&(y.route.ErrorBoundary||y.route.errorElement||w===0)?j.createElement(U0,{location:s.location,revalidation:s.revalidation,component:E,error:S,children:L(),routeContext:{outlet:null,matches:M,isDataRoute:!0},onError:m}):L()},null)}function dd(n){return`${n} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function DE(n){let t=j.useContext($r);return Ie(t,dd(n)),t}function kE(n){let t=j.useContext(Bu);return Ie(t,dd(n)),t}function NE(n){let t=j.useContext(Tn);return Ie(t,dd(n)),t}function pd(n){let t=NE(n),s=t.matches[t.matches.length-1];return Ie(s.route.id,`${n} can only be used on routes that contain a unique "id"`),s.route.id}function jE(){return pd("useRouteId")}function UE(){var r;let n=j.useContext(hd),t=kE("useRouteError"),s=pd("useRouteError");return n!==void 0?n:(r=t.errors)==null?void 0:r[s]}function BE(){let{router:n}=DE("useNavigate"),t=pd("useNavigate"),s=j.useRef(!1);return k0(()=>{s.current=!0}),j.useCallback(async(l,u={})=>{gn(s.current,D0),s.current&&(typeof l=="number"?await n.navigate(l):await n.navigate(l,{fromRouteId:t,...u}))},[n,t])}var lv={};function B0(n,t,s){!t&&!lv[n]&&(lv[n]=!0,gn(!1,s))}j.memo(LE);function LE({routes:n,future:t,state:s,onError:r}){return j0(n,void 0,s,r,t)}function L0({to:n,replace:t,state:s,relative:r}){Ie(qr()," may be used only in the context of a component.");let{static:l}=j.useContext(sn);gn(!l," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:u}=j.useContext(Tn),{pathname:c}=Wi(),d=N0(),p=fd(n,cd(u),c,r==="path"),m=JSON.stringify(p);return j.useEffect(()=>{d(JSON.parse(m),{replace:t,state:s,relative:r})},[d,m,r,t,s]),null}function lu(n){Ie(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function HE({basename:n="/",children:t=null,location:s,navigationType:r="POP",navigator:l,static:u=!1,unstable_useTransitions:c}){Ie(!qr(),"You cannot render a inside another . You should never have more than one in your app.");let d=n.replace(/^\/*/,"/"),p=j.useMemo(()=>({basename:d,navigator:l,static:u,unstable_useTransitions:c,future:{}}),[d,l,u,c]);typeof s=="string"&&(s=Zr(s));let{pathname:m="/",search:g="",hash:y="",state:w=null,key:S="default"}=s,b=j.useMemo(()=>{let E=di(m,d);return E==null?null:{location:{pathname:E,search:g,hash:y,state:w,key:S},navigationType:r}},[d,m,g,y,w,S,r]);return gn(b!=null,` is not able to match the URL "${m}${g}${y}" because it does not start with the basename, so the won't render anything.`),b==null?null:j.createElement(sn.Provider,{value:p},j.createElement(zl.Provider,{children:t,value:b}))}function ZE({children:n,location:t}){return OE(Bh(n),t)}function Bh(n,t=[]){let s=[];return j.Children.forEach(n,(r,l)=>{if(!j.isValidElement(r))return;let u=[...t,l];if(r.type===j.Fragment){s.push.apply(s,Bh(r.props.children,u));return}Ie(r.type===lu,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),Ie(!r.props.index||!r.props.children,"An index route cannot have child routes.");let c={id:r.props.id||u.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(c.children=Bh(r.props.children,u)),s.push(c)}),s}var ou="get",uu="application/x-www-form-urlencoded";function Lu(n){return typeof HTMLElement<"u"&&n instanceof HTMLElement}function $E(n){return Lu(n)&&n.tagName.toLowerCase()==="button"}function qE(n){return Lu(n)&&n.tagName.toLowerCase()==="form"}function IE(n){return Lu(n)&&n.tagName.toLowerCase()==="input"}function KE(n){return!!(n.metaKey||n.altKey||n.ctrlKey||n.shiftKey)}function VE(n,t){return n.button===0&&(!t||t==="_self")&&!KE(n)}var Po=null;function GE(){if(Po===null)try{new FormData(document.createElement("form"),0),Po=!1}catch{Po=!0}return Po}var YE=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function wh(n){return n!=null&&!YE.has(n)?(gn(!1,`"${n}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${uu}"`),null):n}function JE(n,t){let s,r,l,u,c;if(qE(n)){let d=n.getAttribute("action");r=d?di(d,t):null,s=n.getAttribute("method")||ou,l=wh(n.getAttribute("enctype"))||uu,u=new FormData(n)}else if($E(n)||IE(n)&&(n.type==="submit"||n.type==="image")){let d=n.form;if(d==null)throw new Error('Cannot submit a + + ) + } + + // Show disconnected page when connection is lost after being connected + if (connectionState === 'disconnected' && token && !db) { + return + } + + if (!db) { + return null + } + + const actions = createActions(db, token) + + return ( + +
+
+

Wisp

+ +
+
+ + } /> + } /> + } /> + +
+
+
+ ) +} diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx new file mode 100644 index 0000000..438f818 --- /dev/null +++ b/web/src/components/Dashboard.tsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router-dom' +import { useLiveQuery } from '@tanstack/react-db' +import type { WispDB } from '../db/store' + +interface DashboardProps { + db: WispDB +} + +export function Dashboard({ db }: DashboardProps) { + const sessions = useLiveQuery((q) => + q.from({ sessions: db.collections.sessions }) + .orderBy(({ sessions }) => sessions.started_at, 'desc') + ) + + return ( +
+

Sessions

+ {sessions.data?.length === 0 ? ( +

No active sessions

+ ) : ( +
+ {sessions.data?.map((session) => ( + +
{session.repo}
+
{session.branch}
+
+ + {session.status} + + Iteration {session.iteration} +
+ + ))} +
+ )} +
+ ) +} diff --git a/web/src/components/Disconnected.tsx b/web/src/components/Disconnected.tsx new file mode 100644 index 0000000..424fda6 --- /dev/null +++ b/web/src/components/Disconnected.tsx @@ -0,0 +1,24 @@ +interface DisconnectedProps { + onReconnect: () => void + error?: string +} + +export function Disconnected({ onReconnect, error }: DisconnectedProps) { + return ( +
+
+
+

Disconnected

+

+ {error || 'The connection to the wisp server was lost.'} +

+

+ The wisp session may have ended or the server may be unreachable. +

+ +
+
+ ) +} diff --git a/web/src/components/InputPrompt.tsx b/web/src/components/InputPrompt.tsx new file mode 100644 index 0000000..dbf510f --- /dev/null +++ b/web/src/components/InputPrompt.tsx @@ -0,0 +1,83 @@ +import { useState, type FormEvent, useEffect } from 'react' +import type { WispDB } from '../db/store' +import type { Actions } from '../db/actions' +import type { InputRequest } from '../db/schema' + +interface InputPromptProps { + db: WispDB + actions: Actions + request: InputRequest +} + +export function InputPrompt({ actions, request }: InputPromptProps) { + const [value, setValue] = useState('') + + // Request notification permission and show notification when needed + useEffect(() => { + if (!request.responded && document.hidden) { + requestNotificationPermission().then(() => { + showNotification('Wisp needs input', request.question) + }) + } + }, [request.id, request.responded, request.question]) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + if (!value.trim()) return + actions.submitInput({ requestId: request.id, response: value }) + setValue('') + } + + if (request.responded) { + return ( +
+

{request.question}

+

Responded: {request.response}

+
+ ) + } + + return ( +
+ +

{request.question}

+
+ setValue(e.target.value)} + placeholder="Type your response..." + autoFocus + /> + +
+ +
+ ) +} + +async function requestNotificationPermission(): Promise { + if (!('Notification' in window)) { + return false + } + if (Notification.permission === 'granted') { + return true + } + if (Notification.permission !== 'denied') { + const permission = await Notification.requestPermission() + return permission === 'granted' + } + return false +} + +function showNotification(title: string, body: string): void { + if (Notification.permission === 'granted') { + const notification = new Notification(title, { body }) + notification.onclick = () => { + window.focus() + notification.close() + } + } +} diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx new file mode 100644 index 0000000..dabf942 --- /dev/null +++ b/web/src/components/Login.tsx @@ -0,0 +1,64 @@ +import { useState, type FormEvent } from 'react' + +interface LoginProps { + onLogin: (token: string) => void +} + +export function Login({ onLogin }: LoginProps) { + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + const res = await fetch('/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) + + if (!res.ok) { + if (res.status === 401) { + setError('Invalid password') + } else { + setError(`Authentication failed: ${res.status}`) + } + setLoading(false) + return + } + + const data = await res.json() + onLogin(data.token) + } catch (err) { + setError(err instanceof Error ? err.message : 'Connection failed') + setLoading(false) + } + } + + return ( +
+
+

Wisp

+

Remote Access

+
+ setPassword(e.target.value)} + placeholder="Password" + disabled={loading} + autoFocus + /> + + {error &&
{error}
} +
+
+
+ ) +} diff --git a/web/src/components/OutputLog.tsx b/web/src/components/OutputLog.tsx new file mode 100644 index 0000000..b953b18 --- /dev/null +++ b/web/src/components/OutputLog.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef } from 'react' +import { useLiveQuery, eq } from '@tanstack/react-db' +import type { WispDB, ClaudeEvent, SDKMessage } from '../db/store' + +interface OutputLogProps { + db: WispDB + sessionId: string +} + +export function OutputLog({ db, sessionId }: OutputLogProps) { + const bottomRef = useRef(null) + + const events = useLiveQuery((q) => + q.from({ events: db.collections.claude_events }) + .where(({ events }) => eq(events.session_id, sessionId)) + .orderBy(({ events }) => events.sequence, 'asc') + ) + + // Auto-scroll to latest output + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [events.data?.length]) + + return ( +
+

Output

+
+ {events.data?.map((e) => ( + + ))} +
+
+
+ ) +} + +// Render based on SDKMessage.type - uses types from @anthropic-ai/claude-agent-sdk +function ClaudeEventRow({ event }: { event: ClaudeEvent }) { + const msg = event.message as SDKMessage + + switch (msg.type) { + case 'assistant': + // msg.message.content is ContentBlock[] (text, tool_use, etc.) + return ( +
+ {(msg as any).message?.content?.map((block: any, i: number) => ( + + ))} +
+ ) + case 'user': + // Tool results from Claude + return ( +
+
{JSON.stringify((msg as any).message?.content, null, 2)}
+
+ ) + case 'result': + return ( +
+ {(msg as any).subtype === 'success' + ? `Done: ${(msg as any).num_turns} turns, $${(msg as any).total_cost_usd?.toFixed(2) || '?'}` + : `Error: ${(msg as any).subtype}`} +
+ ) + case 'system': + return
Session: {(msg as any).session_id}
+ default: + return null + } +} + +// Render content blocks from assistant messages +function ContentBlock({ block }: { block: any }) { + if (block.type === 'text') { + return
{block.text}
+ } + if (block.type === 'tool_use') { + return ( +
+ [{block.name}] +
{JSON.stringify(block.input, null, 2)}
+
+ ) + } + return null +} diff --git a/web/src/components/Session.tsx b/web/src/components/Session.tsx new file mode 100644 index 0000000..3aa5414 --- /dev/null +++ b/web/src/components/Session.tsx @@ -0,0 +1,79 @@ +import { useParams, Navigate } from 'react-router-dom' +import { useLiveQuery, eq } from '@tanstack/react-db' +import type { WispDB } from '../db/store' +import type { Actions } from '../db/actions' +import { TaskList } from './TaskList' +import { OutputLog } from './OutputLog' +import { InputPrompt } from './InputPrompt' + +interface SessionProps { + db: WispDB + actions: Actions +} + +export function Session({ db, actions }: SessionProps) { + const { id } = useParams<{ id: string }>() + + const sessionQuery = useLiveQuery((q) => + q.from({ sessions: db.collections.sessions }) + .where(({ sessions }) => eq(sessions.id, id!)) + .limit(1) + ) + + const inputRequests = useLiveQuery((q) => + q.from({ requests: db.collections.input_requests }) + .where(({ requests }) => eq(requests.session_id, id!)) + .where(({ requests }) => eq(requests.responded, false)) + .orderBy(({ requests }) => requests.iteration, 'desc') + .limit(1) + ) + + const session = sessionQuery.data?.[0] + const activeRequest = inputRequests.data?.[0] + + if (!id) { + return + } + + if (!session) { + return ( +
+

Loading session...

+
+ ) + } + + return ( +
+
+
+

{session.repo}

+

{session.branch}

+
+
+ + {session.status} + + Iteration {session.iteration} +
+
+ +
+ + +
+ {activeRequest && ( + + )} + +
+
+
+ ) +} diff --git a/web/src/components/TaskList.tsx b/web/src/components/TaskList.tsx new file mode 100644 index 0000000..5d22f2e --- /dev/null +++ b/web/src/components/TaskList.tsx @@ -0,0 +1,37 @@ +import { useLiveQuery, eq } from '@tanstack/react-db' +import type { WispDB } from '../db/store' + +interface TaskListProps { + db: WispDB + sessionId: string +} + +export function TaskList({ db, sessionId }: TaskListProps) { + const tasks = useLiveQuery((q) => + q.from({ tasks: db.collections.tasks }) + .where(({ tasks }) => eq(tasks.session_id, sessionId)) + .orderBy(({ tasks }) => tasks.order, 'asc') + ) + + return ( +
+

Tasks

+ {tasks.data?.length === 0 ? ( +

No tasks yet

+ ) : ( +
    + {tasks.data?.map((task) => ( +
  • + + {task.status === 'completed' && '✓'} + {task.status === 'in_progress' && '→'} + {task.status === 'pending' && '○'} + + {task.content} +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/db/actions.ts b/web/src/db/actions.ts new file mode 100644 index 0000000..8f97b45 --- /dev/null +++ b/web/src/db/actions.ts @@ -0,0 +1,33 @@ +import { createOptimisticAction } from '@tanstack/react-db' +import type { WispDB } from './store' + +export function createActions(db: WispDB, token: string) { + const submitInput = createOptimisticAction<{ requestId: string; response: string }>({ + onMutate: ({ requestId, response }) => { + // Instant optimistic update + db.collections.input_requests.update(requestId, (draft) => { + draft.responded = true + draft.response = response + }) + }, + mutationFn: async ({ requestId, response }) => { + // POST to server + const res = await fetch('/input', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ request_id: requestId, response }), + }) + if (!res.ok) { + throw new Error(`Failed to submit input: ${res.status}`) + } + // No refetch needed - server sends confirmed state via stream + }, + }) + + return { submitInput } +} + +export type Actions = ReturnType diff --git a/web/src/db/schema.ts b/web/src/db/schema.ts new file mode 100644 index 0000000..cc5ca7d --- /dev/null +++ b/web/src/db/schema.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import { createStateSchema } from '@durable-streams/state' + +export const sessionSchema = z.object({ + id: z.string(), + repo: z.string(), + branch: z.string(), + spec: z.string(), + status: z.enum(['running', 'needs_input', 'blocked', 'done']), + iteration: z.number(), + started_at: z.string(), +}) + +export const taskSchema = z.object({ + id: z.string(), + session_id: z.string(), + order: z.number(), + content: z.string(), + status: z.enum(['pending', 'in_progress', 'completed']), +}) + +// SDKMessage schema - passthrough since types come from @anthropic-ai/claude-agent-sdk +// The SDK types (SDKAssistantMessage, SDKResultMessage, etc.) are complex unions. +// Use z.any() and rely on TypeScript for type safety at boundaries. +export const claudeEventSchema = z.object({ + id: z.string(), + session_id: z.string(), + iteration: z.number(), + sequence: z.number(), + message: z.any(), // SDKMessage from @anthropic-ai/claude-agent-sdk + timestamp: z.string(), +}) + +export const inputRequestSchema = z.object({ + id: z.string(), + session_id: z.string(), + iteration: z.number(), + question: z.string(), + responded: z.boolean(), + response: z.string().nullable(), +}) + +export const stateSchema = createStateSchema({ + sessions: { schema: sessionSchema, type: 'session', primaryKey: 'id' }, + tasks: { schema: taskSchema, type: 'task', primaryKey: 'id' }, + claude_events: { schema: claudeEventSchema, type: 'claude_event', primaryKey: 'id' }, + input_requests: { schema: inputRequestSchema, type: 'input_request', primaryKey: 'id' }, +}) + +export type Session = z.infer +export type Task = z.infer +export type ClaudeEvent = z.infer +export type InputRequest = z.infer diff --git a/web/src/db/store.ts b/web/src/db/store.ts new file mode 100644 index 0000000..964bee4 --- /dev/null +++ b/web/src/db/store.ts @@ -0,0 +1,39 @@ +import { createStreamDB } from '@durable-streams/state' +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import { stateSchema } from './schema' + +export type { SDKMessage } + +export interface CreateDbOptions { + token: string + onDisconnect?: (error: Error) => void +} + +export function createDb({ token, onDisconnect }: CreateDbOptions) { + return createStreamDB({ + streamOptions: { + url: `${window.location.origin}/stream`, + headers: { Authorization: `Bearer ${token}` }, + onError: (error) => { + // For connection errors (server gone), trigger disconnect + // Don't return anything to let the error propagate and close the stream + if (onDisconnect) { + onDisconnect(error) + } + // Return undefined to propagate the error and stop the stream + return undefined + }, + }, + state: stateSchema, + }) +} + +export type WispDB = ReturnType +export type ClaudeEvent = { + id: string + session_id: string + iteration: number + sequence: number + message: SDKMessage + timestamp: string +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3a58b71 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './styles/main.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/styles/main.css b/web/src/styles/main.css new file mode 100644 index 0000000..cbdefa2 --- /dev/null +++ b/web/src/styles/main.css @@ -0,0 +1,570 @@ +/* Reset and base styles */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --color-bg: #f5f5f5; + --color-surface: #ffffff; + --color-text: #333333; + --color-text-muted: #666666; + --color-border: #e0e0e0; + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-success: #16a34a; + --color-warning: #ca8a04; + --color-error: #dc2626; + --radius: 8px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + background: var(--color-bg); + color: var(--color-text); +} + +/* App layout */ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow); +} + +.app-header h1 { + margin: 0; + font-size: 1.5rem; +} + +.logout-btn { + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: transparent; + cursor: pointer; +} + +.logout-btn:hover { + background: var(--color-bg); +} + +main { + flex: 1; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +/* Login */ +.login { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: var(--color-bg); +} + +.login-card { + background: var(--color-surface); + padding: 2rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + text-align: center; + width: 100%; + max-width: 320px; +} + +.login-card h1 { + margin: 0 0 0.5rem; +} + +.login-card p { + margin: 0 0 1.5rem; + color: var(--color-text-muted); +} + +.login-card form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-card input { + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: 1rem; +} + +.login-card button { + padding: 0.75rem; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: white; + font-size: 1rem; + cursor: pointer; +} + +.login-card button:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.login-card button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.error-message { + color: var(--color-error); + font-size: 0.875rem; +} + +/* Loading and error states */ +.loading, +.error { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1rem; +} + +.error button { + padding: 0.5rem 1rem; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: white; + cursor: pointer; +} + +/* Dashboard */ +.dashboard h2 { + margin: 0 0 1.5rem; +} + +.no-sessions { + color: var(--color-text-muted); +} + +.session-list { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.session-card { + display: block; + padding: 1rem; + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + text-decoration: none; + color: inherit; + border-left: 4px solid transparent; + transition: transform 0.1s, box-shadow 0.1s; +} + +.session-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.session-card.status-running { + border-left-color: var(--color-primary); +} + +.session-card.status-needs_input { + border-left-color: var(--color-warning); +} + +.session-card.status-blocked { + border-left-color: var(--color-error); +} + +.session-card.status-done { + border-left-color: var(--color-success); +} + +.session-repo { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.session-branch { + color: var(--color-text-muted); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.session-meta { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* Status badges */ +.status-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.status-badge.running { + background: #dbeafe; + color: var(--color-primary); +} + +.status-badge.needs_input { + background: #fef3c7; + color: var(--color-warning); +} + +.status-badge.blocked { + background: #fee2e2; + color: var(--color-error); +} + +.status-badge.done { + background: #dcfce7; + color: var(--color-success); +} + +.iteration { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +/* Session view */ +.session-loading { + text-align: center; + padding: 2rem; +} + +.session-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem; + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1.5rem; +} + +.session-info h2 { + margin: 0 0 0.25rem; +} + +.session-info .branch { + margin: 0; + color: var(--color-text-muted); +} + +.session-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; +} + +.session-content { + display: grid; + grid-template-columns: 300px 1fr; + gap: 1.5rem; +} + +@media (max-width: 768px) { + .session-content { + grid-template-columns: 1fr; + } +} + +/* Task list */ +.session-sidebar { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem; +} + +.task-list h3 { + margin: 0 0 1rem; +} + +.task-list ul { + list-style: none; + margin: 0; + padding: 0; +} + +.task-list .task { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--color-border); +} + +.task-list .task:last-child { + border-bottom: none; +} + +.task-status-icon { + flex-shrink: 0; + width: 1.25rem; + text-align: center; +} + +.task.status-completed .task-status-icon { + color: var(--color-success); +} + +.task.status-in_progress .task-status-icon { + color: var(--color-primary); +} + +.task.status-pending .task-status-icon { + color: var(--color-text-muted); +} + +.task-content { + flex: 1; + word-break: break-word; +} + +.task.status-completed .task-content { + color: var(--color-text-muted); +} + +.no-tasks { + color: var(--color-text-muted); + margin: 0; +} + +/* Output log */ +.session-main { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.output-log { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem; + flex: 1; + min-height: 400px; + display: flex; + flex-direction: column; +} + +.output-log h3 { + margin: 0 0 1rem; +} + +.output-content { + flex: 1; + overflow-y: auto; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 0.875rem; + background: #1a1a1a; + color: #e0e0e0; + padding: 1rem; + border-radius: 4px; + max-height: 500px; +} + +.event { + margin-bottom: 0.5rem; + padding: 0.5rem; + border-radius: 4px; +} + +.event.assistant { + background: rgba(37, 99, 235, 0.1); +} + +.event.tool-result { + background: rgba(0, 0, 0, 0.2); +} + +.event.tool-result pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + font-size: 0.75rem; + max-height: 200px; + overflow-y: auto; +} + +.event.result { + background: rgba(22, 163, 74, 0.2); + color: #4ade80; +} + +.event.system { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.content-text { + white-space: pre-wrap; + word-break: break-word; +} + +.content-tool-use { + background: rgba(0, 0, 0, 0.2); + padding: 0.5rem; + border-radius: 4px; + margin: 0.25rem 0; +} + +.content-tool-use summary { + cursor: pointer; + color: #93c5fd; +} + +.content-tool-use pre { + margin: 0.5rem 0 0; + white-space: pre-wrap; + word-break: break-all; + font-size: 0.75rem; + max-height: 200px; + overflow-y: auto; +} + +/* Input prompt */ +.input-prompt { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem; + border: 2px solid var(--color-warning); +} + +.input-prompt .question { + margin: 0 0 1rem; + font-weight: 600; +} + +.input-prompt .input-row { + display: flex; + gap: 0.5rem; +} + +.input-prompt input { + flex: 1; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: 1rem; +} + +.input-prompt button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: white; + font-size: 1rem; + cursor: pointer; +} + +.input-prompt button:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.input-prompt button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input-prompt.responded { + border-color: var(--color-success); + background: #dcfce7; +} + +.input-prompt.responded .response { + margin: 0; + color: var(--color-success); +} + +/* Disconnected page */ +.disconnected { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: var(--color-bg); +} + +.disconnected-content { + background: var(--color-surface); + padding: 3rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + text-align: center; + max-width: 400px; + width: 100%; + margin: 1rem; +} + +.disconnected-icon { + font-size: 4rem; + margin-bottom: 1rem; + color: var(--color-warning); +} + +.disconnected h1 { + margin: 0 0 1rem; + color: var(--color-text); +} + +.disconnected-message { + margin: 0 0 0.5rem; + color: var(--color-text); +} + +.disconnected-hint { + margin: 0 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.reconnect-btn { + padding: 0.75rem 2rem; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: white; + font-size: 1rem; + cursor: pointer; + transition: background 0.15s; +} + +.reconnect-btn:hover { + background: var(--color-primary-hover); +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..0426f7b --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..2aed95d --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + emptyDirOnBuild: false, + }, + server: { + proxy: { + '/auth': 'http://localhost:8374', + '/stream': 'http://localhost:8374', + '/input': 'http://localhost:8374', + }, + }, +})