webctx 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +97 -0
- package/CONTRIBUTORS.md +126 -0
- package/LICENSE +202 -0
- package/Makefile +69 -0
- package/README.md +121 -0
- package/bin/webctx.js +28 -0
- package/cmd/webctx/main.go +11 -0
- package/docs/porting-status.md +173 -0
- package/go.mod +3 -0
- package/internal/app/app.go +139 -0
- package/internal/app/app_test.go +194 -0
- package/internal/app/github.go +2084 -0
- package/internal/app/github_acceptance_test.go +166 -0
- package/internal/app/github_actions.go +1008 -0
- package/internal/app/github_actions_test.go +484 -0
- package/internal/app/github_activity_deployments.go +503 -0
- package/internal/app/github_activity_deployments_test.go +321 -0
- package/internal/app/github_commits.go +1004 -0
- package/internal/app/github_commits_test.go +503 -0
- package/internal/app/github_discussions_gists.go +1156 -0
- package/internal/app/github_discussions_gists_test.go +425 -0
- package/internal/app/github_issues.go +1616 -0
- package/internal/app/github_issues_test.go +731 -0
- package/internal/app/github_packages_projects.go +429 -0
- package/internal/app/github_packages_projects_test.go +168 -0
- package/internal/app/github_phase9_overview_stress_test.go +178 -0
- package/internal/app/github_pull_views.go +1141 -0
- package/internal/app/github_pull_views_test.go +603 -0
- package/internal/app/github_pulls.go +1459 -0
- package/internal/app/github_pulls_test.go +715 -0
- package/internal/app/github_refs.go +621 -0
- package/internal/app/github_refs_test.go +260 -0
- package/internal/app/github_search_profiles.go +505 -0
- package/internal/app/github_search_profiles_test.go +352 -0
- package/internal/app/github_test.go +888 -0
- package/internal/app/scrape.go +264 -0
- package/internal/app/tools.go +670 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/package.json +66 -0
- package/scripts/postinstall.js +137 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# webctx TypeScript -> Go porting status
|
|
2
|
+
|
|
3
|
+
This document is the handoff reference for future agents working on `amxv/webctx`.
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Port the CLI behavior from `amxv/webctx-ts` into pure Go while keeping the command-line interface and provider behavior effectively one-to-one for the CLI use case.
|
|
8
|
+
|
|
9
|
+
Scope intentionally excludes the MCP/server/dashboard pieces from the TypeScript repo.
|
|
10
|
+
|
|
11
|
+
## Source areas reviewed in `webctx-ts`
|
|
12
|
+
|
|
13
|
+
- `cli.ts`
|
|
14
|
+
- `tools/search.ts`
|
|
15
|
+
- `tools/read-link.ts`
|
|
16
|
+
- `tools/map-site.ts`
|
|
17
|
+
- `lib/search/brave.ts`
|
|
18
|
+
- `lib/search/tavily.ts`
|
|
19
|
+
- `lib/search/exa.ts`
|
|
20
|
+
- `lib/ranking.ts`
|
|
21
|
+
- `lib/utils.ts`
|
|
22
|
+
- `lib/scraping.ts`
|
|
23
|
+
- `lib/firecrawl-queue.ts`
|
|
24
|
+
- `lib/rate-limiter.ts`
|
|
25
|
+
|
|
26
|
+
## Completed
|
|
27
|
+
|
|
28
|
+
### CLI surface
|
|
29
|
+
|
|
30
|
+
Implemented in Go:
|
|
31
|
+
|
|
32
|
+
- `webctx search <query> [--exclude domain1,domain2] [--keyword phrase]`
|
|
33
|
+
- `webctx read-link <url>`
|
|
34
|
+
- `webctx map-site <url>`
|
|
35
|
+
- `webctx --help`
|
|
36
|
+
- `webctx --version`
|
|
37
|
+
|
|
38
|
+
Notes:
|
|
39
|
+
|
|
40
|
+
- `--version` now prints the bare version string, matching the TypeScript CLI.
|
|
41
|
+
- Error handling is exit-code based in Go rather than promise rejection based.
|
|
42
|
+
|
|
43
|
+
### Search port
|
|
44
|
+
|
|
45
|
+
Implemented:
|
|
46
|
+
|
|
47
|
+
- Brave HTTP client
|
|
48
|
+
- Tavily HTTP client using direct HTTP API instead of the TypeScript SDK
|
|
49
|
+
- Exa HTTP client
|
|
50
|
+
- provider fan-out with per-provider timeout
|
|
51
|
+
- duplicate-aware reranking
|
|
52
|
+
- excluded-domain filtering
|
|
53
|
+
- HTML entity decoding
|
|
54
|
+
- keyword truncation to 5 words for Exa include-text mode
|
|
55
|
+
- top 35 result output limit
|
|
56
|
+
|
|
57
|
+
Current behavior matches the TypeScript CLI design:
|
|
58
|
+
|
|
59
|
+
- normal search mode queries Brave + Tavily + Exa
|
|
60
|
+
- keyword mode queries Exa only
|
|
61
|
+
- user/domain exclusions are applied after provider collection, matching the TypeScript tool flow
|
|
62
|
+
|
|
63
|
+
### Read-link port
|
|
64
|
+
|
|
65
|
+
Implemented:
|
|
66
|
+
|
|
67
|
+
- GitHub raw-content fast path
|
|
68
|
+
- `.md` fast path with HEAD probe
|
|
69
|
+
- Firecrawl scrape fallback with the same agent-oriented request settings
|
|
70
|
+
- PDF parser enablement for `.pdf` URLs
|
|
71
|
+
|
|
72
|
+
Kept settings aligned with the TypeScript CLI:
|
|
73
|
+
|
|
74
|
+
- `formats: ["markdown"]`
|
|
75
|
+
- `onlyMainContent: true`
|
|
76
|
+
- `skipTlsVerification: true`
|
|
77
|
+
- `blockAds: true`
|
|
78
|
+
- `removeBase64Images: true`
|
|
79
|
+
- `maxAge: 600000`
|
|
80
|
+
- same excluded tags list
|
|
81
|
+
|
|
82
|
+
### Map-site port
|
|
83
|
+
|
|
84
|
+
Implemented with the same Firecrawl map settings:
|
|
85
|
+
|
|
86
|
+
- `sitemap: "include"`
|
|
87
|
+
- `includeSubdomains: true`
|
|
88
|
+
- `ignoreQueryParameters: true`
|
|
89
|
+
- `limit: 5000`
|
|
90
|
+
|
|
91
|
+
### Firecrawl queue / rate limiting
|
|
92
|
+
|
|
93
|
+
Implemented in Go:
|
|
94
|
+
|
|
95
|
+
- singleton-style queue wrapper
|
|
96
|
+
- token bucket limiter at 10 requests/minute
|
|
97
|
+
- serialized queue processing for Firecrawl operations
|
|
98
|
+
|
|
99
|
+
This is not a literal line-by-line port, but preserves the same operational intent.
|
|
100
|
+
|
|
101
|
+
### Release/publish setup
|
|
102
|
+
|
|
103
|
+
Updated to be release-ready for the real `webctx` CLI:
|
|
104
|
+
|
|
105
|
+
- GitHub Actions workflow now builds `webctx` instead of the old template placeholder
|
|
106
|
+
- release binaries embed the tagged version into `internal/buildinfo.Version`
|
|
107
|
+
- npm metadata now describes the actual CLI instead of the template
|
|
108
|
+
- README / agent / maintainer docs updated for the real repo
|
|
109
|
+
|
|
110
|
+
## Intentionally not ported
|
|
111
|
+
|
|
112
|
+
- MCP/server behavior
|
|
113
|
+
- Next.js app/dashboard code
|
|
114
|
+
- database/logging layers unrelated to the CLI
|
|
115
|
+
|
|
116
|
+
These can be added later only if explicitly requested.
|
|
117
|
+
|
|
118
|
+
## Current repo files of interest
|
|
119
|
+
|
|
120
|
+
- `cmd/webctx/main.go`
|
|
121
|
+
- `internal/app/app.go`
|
|
122
|
+
- `internal/app/tools.go`
|
|
123
|
+
- `internal/app/scrape.go`
|
|
124
|
+
- `internal/app/app_test.go`
|
|
125
|
+
- `.github/workflows/release.yml`
|
|
126
|
+
- `scripts/postinstall.js`
|
|
127
|
+
- `README.md`
|
|
128
|
+
|
|
129
|
+
## Verification already completed
|
|
130
|
+
|
|
131
|
+
- `go test ./...`
|
|
132
|
+
- `go build ./cmd/webctx`
|
|
133
|
+
|
|
134
|
+
## Live validation notes
|
|
135
|
+
|
|
136
|
+
Live CLI validation was run against a real `.env.local` on the Sprite machine.
|
|
137
|
+
|
|
138
|
+
Confirmed working live:
|
|
139
|
+
|
|
140
|
+
- combined `search` path returns real web results
|
|
141
|
+
- public GitHub blob `read-link` fast path works
|
|
142
|
+
- Firecrawl-backed `read-link` works
|
|
143
|
+
- Firecrawl-backed `map-site` works
|
|
144
|
+
|
|
145
|
+
Observed external/provider constraints during live validation:
|
|
146
|
+
|
|
147
|
+
- `search --keyword` currently depends on Exa-only results and could not be fully validated because the live Exa account returned `NO_MORE_CREDITS`
|
|
148
|
+
- private GitHub blob URLs are not readable via unauthenticated raw-content fetch, so they fall through to the general scrape path
|
|
149
|
+
|
|
150
|
+
These findings were from live provider behavior, not from compile/test failures in the Go port.
|
|
151
|
+
|
|
152
|
+
## Good next checks for future agents
|
|
153
|
+
|
|
154
|
+
1. Run live end-to-end checks against real provider keys for:
|
|
155
|
+
- normal multi-provider search
|
|
156
|
+
- Exa keyword-only search mode
|
|
157
|
+
- GitHub raw-content read-link
|
|
158
|
+
- `.md` fast path read-link
|
|
159
|
+
- Firecrawl scrape fallback
|
|
160
|
+
- Firecrawl map-site
|
|
161
|
+
|
|
162
|
+
2. Compare a handful of live outputs from `webctx-ts` and Go `webctx` for formatting parity.
|
|
163
|
+
|
|
164
|
+
3. If performance tuning is needed, focus on:
|
|
165
|
+
- HTTP client reuse
|
|
166
|
+
- provider timeout tuning
|
|
167
|
+
- Firecrawl queue behavior under concurrent use
|
|
168
|
+
|
|
169
|
+
## Constraints to preserve
|
|
170
|
+
|
|
171
|
+
- Keep the CLI output simple and agent-friendly.
|
|
172
|
+
- Keep the Firecrawl request settings stable unless explicitly asked to change them.
|
|
173
|
+
- Keep the release asset naming contract stable unless postinstall/workflow are updated together.
|
package/go.mod
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/amxv/webctx/internal/buildinfo"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const commandName = "webctx"
|
|
12
|
+
|
|
13
|
+
var version = buildinfo.CurrentVersion()
|
|
14
|
+
|
|
15
|
+
func Run(args []string, stdout, stderr io.Writer) int {
|
|
16
|
+
loadEnvLocal()
|
|
17
|
+
|
|
18
|
+
if len(args) == 0 || isHelpArg(args[0]) {
|
|
19
|
+
_, _ = fmt.Fprintln(stdout, usageText())
|
|
20
|
+
return 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if args[0] == "--version" || args[0] == "-v" {
|
|
24
|
+
_, _ = fmt.Fprintln(stdout, version)
|
|
25
|
+
return 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
tool := args[0]
|
|
29
|
+
flags, positional := parseArgs(args[1:])
|
|
30
|
+
input := ""
|
|
31
|
+
if len(positional) > 0 {
|
|
32
|
+
input = positional[0]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
switch tool {
|
|
36
|
+
case "search":
|
|
37
|
+
query := strings.Join(positional, " ")
|
|
38
|
+
if strings.TrimSpace(query) == "" {
|
|
39
|
+
_, _ = fmt.Fprintln(stderr, "Error: search requires a query")
|
|
40
|
+
_, _ = fmt.Fprintln(stdout, "Usage: webctx search <query> [--exclude domains] [--keyword phrase]")
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
43
|
+
excludeDomains := splitCSV(flags["exclude"])
|
|
44
|
+
text, err := Search(SearchParams{Query: query, ExcludeDomains: excludeDomains, IncludeKeyword: flags["keyword"]})
|
|
45
|
+
if err != nil {
|
|
46
|
+
_, _ = fmt.Fprintln(stderr, err.Error())
|
|
47
|
+
return 1
|
|
48
|
+
}
|
|
49
|
+
_, _ = fmt.Fprintln(stdout, text)
|
|
50
|
+
return 0
|
|
51
|
+
case "read-link":
|
|
52
|
+
if strings.TrimSpace(input) == "" {
|
|
53
|
+
_, _ = fmt.Fprintln(stderr, "Error: read-link requires a URL")
|
|
54
|
+
_, _ = fmt.Fprintln(stdout, "Usage: webctx read-link <url>")
|
|
55
|
+
return 1
|
|
56
|
+
}
|
|
57
|
+
text, err := ReadLink(input)
|
|
58
|
+
if err != nil {
|
|
59
|
+
_, _ = fmt.Fprintln(stderr, err.Error())
|
|
60
|
+
return 1
|
|
61
|
+
}
|
|
62
|
+
_, _ = fmt.Fprintln(stdout, text)
|
|
63
|
+
return 0
|
|
64
|
+
case "map-site":
|
|
65
|
+
if strings.TrimSpace(input) == "" {
|
|
66
|
+
_, _ = fmt.Fprintln(stderr, "Error: map-site requires a URL")
|
|
67
|
+
_, _ = fmt.Fprintln(stdout, "Usage: webctx map-site <url>")
|
|
68
|
+
return 1
|
|
69
|
+
}
|
|
70
|
+
text, err := MapSite(input)
|
|
71
|
+
if err != nil {
|
|
72
|
+
_, _ = fmt.Fprintln(stderr, err.Error())
|
|
73
|
+
return 1
|
|
74
|
+
}
|
|
75
|
+
_, _ = fmt.Fprintln(stdout, text)
|
|
76
|
+
return 0
|
|
77
|
+
default:
|
|
78
|
+
_, _ = fmt.Fprintln(stderr, "Unknown tool:", tool)
|
|
79
|
+
_, _ = fmt.Fprintln(stdout, usageText())
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func usageText() string {
|
|
85
|
+
return fmt.Sprintf(`webctx v%s - Web search & browsing CLI
|
|
86
|
+
|
|
87
|
+
Usage:
|
|
88
|
+
webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
|
|
89
|
+
webctx read-link <url>
|
|
90
|
+
webctx map-site <url>
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
webctx search "next.js server components"
|
|
94
|
+
webctx search "react hooks" --exclude youtube.com,vimeo.com
|
|
95
|
+
webctx search "drizzle orm" --keyword "migration guide"
|
|
96
|
+
webctx read-link https://docs.example.com/guide
|
|
97
|
+
webctx map-site https://example.com`, version)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func parseArgs(args []string) (map[string]string, []string) {
|
|
101
|
+
flags := map[string]string{}
|
|
102
|
+
positional := make([]string, 0, len(args))
|
|
103
|
+
for i := 0; i < len(args); i++ {
|
|
104
|
+
if strings.HasPrefix(args[i], "--") && i+1 < len(args) {
|
|
105
|
+
flags[strings.TrimPrefix(args[i], "--")] = args[i+1]
|
|
106
|
+
i++
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
positional = append(positional, args[i])
|
|
110
|
+
}
|
|
111
|
+
return flags, positional
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func splitCSV(v string) []string {
|
|
115
|
+
if strings.TrimSpace(v) == "" {
|
|
116
|
+
return nil
|
|
117
|
+
}
|
|
118
|
+
parts := strings.Split(v, ",")
|
|
119
|
+
out := make([]string, 0, len(parts))
|
|
120
|
+
for _, part := range parts {
|
|
121
|
+
trimmed := strings.TrimSpace(part)
|
|
122
|
+
if trimmed != "" {
|
|
123
|
+
out = append(out, trimmed)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if len(out) == 0 {
|
|
127
|
+
return nil
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func isHelpArg(v string) bool {
|
|
133
|
+
switch v {
|
|
134
|
+
case "-h", "--help", "help":
|
|
135
|
+
return true
|
|
136
|
+
default:
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"runtime"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestRunRootHelp(t *testing.T) {
|
|
13
|
+
var out bytes.Buffer
|
|
14
|
+
var errBuf bytes.Buffer
|
|
15
|
+
|
|
16
|
+
code := Run([]string{"--help"}, &out, &errBuf)
|
|
17
|
+
if code != 0 {
|
|
18
|
+
t.Fatalf("Run returned code %d", code)
|
|
19
|
+
}
|
|
20
|
+
if !strings.Contains(out.String(), "webctx v") || !strings.Contains(out.String(), "read-link") {
|
|
21
|
+
t.Fatalf("unexpected help output: %q", out.String())
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func TestRunVersion(t *testing.T) {
|
|
26
|
+
var out bytes.Buffer
|
|
27
|
+
var errBuf bytes.Buffer
|
|
28
|
+
|
|
29
|
+
code := Run([]string{"--version"}, &out, &errBuf)
|
|
30
|
+
if code != 0 {
|
|
31
|
+
t.Fatalf("Run returned code %d", code)
|
|
32
|
+
}
|
|
33
|
+
if strings.TrimSpace(out.String()) == "" {
|
|
34
|
+
t.Fatalf("unexpected empty version output")
|
|
35
|
+
}
|
|
36
|
+
if strings.Contains(out.String(), "webctx ") {
|
|
37
|
+
t.Fatalf("expected bare version output, got: %q", out.String())
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestRunSearchWithoutQuery(t *testing.T) {
|
|
42
|
+
var out bytes.Buffer
|
|
43
|
+
var errBuf bytes.Buffer
|
|
44
|
+
|
|
45
|
+
code := Run([]string{"search"}, &out, &errBuf)
|
|
46
|
+
if code != 1 {
|
|
47
|
+
t.Fatalf("expected exit code 1, got %d", code)
|
|
48
|
+
}
|
|
49
|
+
if !strings.Contains(errBuf.String(), "search requires a query") {
|
|
50
|
+
t.Fatalf("unexpected stderr: %q", errBuf.String())
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func TestNormalizeURL(t *testing.T) {
|
|
55
|
+
got := normalizeURL("https://Example.com/docs/?utm_source=x&ref=y&q=go")
|
|
56
|
+
want := "https://example.com/docs?q=go"
|
|
57
|
+
if got != want {
|
|
58
|
+
t.Fatalf("normalizeURL mismatch: got %q want %q", got, want)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestScoreAndRankResultsDuplicateBoost(t *testing.T) {
|
|
63
|
+
results, duplicatesRemoved := scoreAndRankResults([]providerDocs{
|
|
64
|
+
{Provider: "Brave", Docs: []SearchDoc{{URL: "https://a.com", Title: "A"}, {URL: "https://b.com", Title: "B"}}},
|
|
65
|
+
{Provider: "Tavily", Docs: []SearchDoc{{URL: "https://b.com", Title: "B2"}, {URL: "https://c.com", Title: "C"}}},
|
|
66
|
+
})
|
|
67
|
+
if duplicatesRemoved != 1 {
|
|
68
|
+
t.Fatalf("expected 1 duplicate removed, got %d", duplicatesRemoved)
|
|
69
|
+
}
|
|
70
|
+
if len(results) == 0 || results[0].URL != "https://b.com" {
|
|
71
|
+
t.Fatalf("expected duplicate URL to rank first, got %#v", results)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestScoreAndRankResultsPreservesInsertionOrderForTies(t *testing.T) {
|
|
76
|
+
results, _ := scoreAndRankResults([]providerDocs{
|
|
77
|
+
{Provider: "Brave", Docs: []SearchDoc{{URL: "https://z.example", Title: "Z"}}},
|
|
78
|
+
{Provider: "Tavily", Docs: []SearchDoc{{URL: "https://a.example", Title: "A"}}},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if len(results) != 2 || results[0].URL != "https://z.example" || results[1].URL != "https://a.example" {
|
|
82
|
+
t.Fatalf("expected insertion order for tied results, got %#v", results)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func TestSearchMissingCredentialsErrorIsHelpful(t *testing.T) {
|
|
87
|
+
t.Setenv("BRAVE_API_KEY", "")
|
|
88
|
+
t.Setenv("TAVILY_API_KEY", "")
|
|
89
|
+
t.Setenv("EXA_API_KEY", "")
|
|
90
|
+
|
|
91
|
+
text, err := Search(SearchParams{Query: "openai api"})
|
|
92
|
+
if err == nil {
|
|
93
|
+
t.Fatalf("expected error, got text %q", text)
|
|
94
|
+
}
|
|
95
|
+
if !strings.Contains(err.Error(), "missing BRAVE_API_KEY, EXA_API_KEY, TAVILY_API_KEY") {
|
|
96
|
+
t.Fatalf("unexpected error: %q", err.Error())
|
|
97
|
+
}
|
|
98
|
+
if !strings.Contains(err.Error(), "macOS Keychain") {
|
|
99
|
+
t.Fatalf("expected keychain guidance, got %q", err.Error())
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func TestMapSiteMissingCredentialErrorIsHelpful(t *testing.T) {
|
|
104
|
+
t.Setenv("FIRECRAWL_API_KEY", "")
|
|
105
|
+
|
|
106
|
+
text, err := MapSite("https://example.com")
|
|
107
|
+
if err == nil {
|
|
108
|
+
t.Fatalf("expected error, got text %q", text)
|
|
109
|
+
}
|
|
110
|
+
if !strings.Contains(err.Error(), "missing FIRECRAWL_API_KEY") {
|
|
111
|
+
t.Fatalf("unexpected error: %q", err.Error())
|
|
112
|
+
}
|
|
113
|
+
if !strings.Contains(err.Error(), ".env.local next to the binary") {
|
|
114
|
+
t.Fatalf("expected .env.local guidance, got %q", err.Error())
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func TestLoadDotEnvFileDoesNotOverrideExistingEnv(t *testing.T) {
|
|
119
|
+
tmpDir := t.TempDir()
|
|
120
|
+
envPath := filepath.Join(tmpDir, ".env.local")
|
|
121
|
+
if err := os.WriteFile(envPath, []byte("BRAVE_API_KEY=from-file\nEXA_API_KEY=from-file\n"), 0o644); err != nil {
|
|
122
|
+
t.Fatalf("write env file: %v", err)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
t.Setenv("BRAVE_API_KEY", "from-env")
|
|
126
|
+
t.Setenv("EXA_API_KEY", "")
|
|
127
|
+
|
|
128
|
+
loadDotEnvFile(envPath)
|
|
129
|
+
|
|
130
|
+
if got := os.Getenv("BRAVE_API_KEY"); got != "from-env" {
|
|
131
|
+
t.Fatalf("expected existing env to win, got %q", got)
|
|
132
|
+
}
|
|
133
|
+
if got := os.Getenv("EXA_API_KEY"); got != "" {
|
|
134
|
+
t.Fatalf("expected explicitly empty env to remain untouched, got %q", got)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func TestEnvLocalCandidatesPreferExecutableDir(t *testing.T) {
|
|
139
|
+
originalGetwd := getwdFunc
|
|
140
|
+
originalExecutable := executableFunc
|
|
141
|
+
t.Cleanup(func() {
|
|
142
|
+
getwdFunc = originalGetwd
|
|
143
|
+
executableFunc = originalExecutable
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
getwdFunc = func() (string, error) { return "/tmp/project", nil }
|
|
147
|
+
executableFunc = func() (string, error) { return "/opt/webctx/bin/webctx", nil }
|
|
148
|
+
|
|
149
|
+
got := envLocalCandidates()
|
|
150
|
+
want := []string{
|
|
151
|
+
filepath.Join("/opt/webctx/bin", ".env.local"),
|
|
152
|
+
filepath.Join("/opt/webctx", ".env.local"),
|
|
153
|
+
filepath.Join("/tmp/project", ".env.local"),
|
|
154
|
+
}
|
|
155
|
+
if runtime.GOOS == "windows" {
|
|
156
|
+
for i := range want {
|
|
157
|
+
want[i] = filepath.Clean(want[i])
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if len(got) != len(want) {
|
|
162
|
+
t.Fatalf("unexpected candidate count: got %v want %v", got, want)
|
|
163
|
+
}
|
|
164
|
+
for i := range want {
|
|
165
|
+
if filepath.Clean(got[i]) != filepath.Clean(want[i]) {
|
|
166
|
+
t.Fatalf("candidate %d mismatch: got %q want %q", i, got[i], want[i])
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func TestLoadKeychainEnvLoadsMissingOnly(t *testing.T) {
|
|
172
|
+
originalLookup := keychainLookup
|
|
173
|
+
t.Cleanup(func() { keychainLookup = originalLookup })
|
|
174
|
+
|
|
175
|
+
keychainLookup = func(key string) (string, error) {
|
|
176
|
+
return "from-keychain-" + key, nil
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
t.Setenv("BRAVE_API_KEY", "already-set")
|
|
180
|
+
t.Setenv("TAVILY_API_KEY", "")
|
|
181
|
+
t.Setenv("EXA_API_KEY", "")
|
|
182
|
+
t.Setenv("FIRECRAWL_API_KEY", "")
|
|
183
|
+
t.Setenv("GH_TOKEN", "")
|
|
184
|
+
t.Setenv("GITHUB_TOKEN", "")
|
|
185
|
+
|
|
186
|
+
loadKeychainEnv()
|
|
187
|
+
|
|
188
|
+
if got := os.Getenv("BRAVE_API_KEY"); got != "already-set" {
|
|
189
|
+
t.Fatalf("expected existing env to remain, got %q", got)
|
|
190
|
+
}
|
|
191
|
+
if got := os.Getenv("TAVILY_API_KEY"); got != "" {
|
|
192
|
+
t.Fatalf("expected explicitly empty env to remain untouched, got %q", got)
|
|
193
|
+
}
|
|
194
|
+
}
|