webctx 0.1.0 → 0.1.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/internal/app/app_test.go +111 -0
- package/internal/app/scrape.go +47 -4
- package/internal/app/tools.go +62 -4
- package/package.json +1 -1
package/internal/app/app_test.go
CHANGED
|
@@ -2,6 +2,9 @@ package app
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"runtime"
|
|
5
8
|
"strings"
|
|
6
9
|
"testing"
|
|
7
10
|
)
|
|
@@ -75,3 +78,111 @@ func TestParseGitHubURL(t *testing.T) {
|
|
|
75
78
|
t.Fatalf("unexpected parse result: %#v", info)
|
|
76
79
|
}
|
|
77
80
|
}
|
|
81
|
+
|
|
82
|
+
func TestSearchMissingCredentialsErrorIsHelpful(t *testing.T) {
|
|
83
|
+
t.Setenv("BRAVE_API_KEY", "")
|
|
84
|
+
t.Setenv("TAVILY_API_KEY", "")
|
|
85
|
+
t.Setenv("EXA_API_KEY", "")
|
|
86
|
+
|
|
87
|
+
text, err := Search(SearchParams{Query: "openai api"})
|
|
88
|
+
if err == nil {
|
|
89
|
+
t.Fatalf("expected error, got text %q", text)
|
|
90
|
+
}
|
|
91
|
+
if !strings.Contains(err.Error(), "missing BRAVE_API_KEY, EXA_API_KEY, TAVILY_API_KEY") {
|
|
92
|
+
t.Fatalf("unexpected error: %q", err.Error())
|
|
93
|
+
}
|
|
94
|
+
if !strings.Contains(err.Error(), "macOS Keychain") {
|
|
95
|
+
t.Fatalf("expected keychain guidance, got %q", err.Error())
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestMapSiteMissingCredentialErrorIsHelpful(t *testing.T) {
|
|
100
|
+
t.Setenv("FIRECRAWL_API_KEY", "")
|
|
101
|
+
|
|
102
|
+
text, err := MapSite("https://example.com")
|
|
103
|
+
if err == nil {
|
|
104
|
+
t.Fatalf("expected error, got text %q", text)
|
|
105
|
+
}
|
|
106
|
+
if !strings.Contains(err.Error(), "missing FIRECRAWL_API_KEY") {
|
|
107
|
+
t.Fatalf("unexpected error: %q", err.Error())
|
|
108
|
+
}
|
|
109
|
+
if !strings.Contains(err.Error(), ".env.local next to the binary") {
|
|
110
|
+
t.Fatalf("expected .env.local guidance, got %q", err.Error())
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func TestLoadDotEnvFileDoesNotOverrideExistingEnv(t *testing.T) {
|
|
115
|
+
tmpDir := t.TempDir()
|
|
116
|
+
envPath := filepath.Join(tmpDir, ".env.local")
|
|
117
|
+
if err := os.WriteFile(envPath, []byte("BRAVE_API_KEY=from-file\nEXA_API_KEY=from-file\n"), 0o644); err != nil {
|
|
118
|
+
t.Fatalf("write env file: %v", err)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
t.Setenv("BRAVE_API_KEY", "from-env")
|
|
122
|
+
t.Setenv("EXA_API_KEY", "")
|
|
123
|
+
|
|
124
|
+
loadDotEnvFile(envPath)
|
|
125
|
+
|
|
126
|
+
if got := os.Getenv("BRAVE_API_KEY"); got != "from-env" {
|
|
127
|
+
t.Fatalf("expected existing env to win, got %q", got)
|
|
128
|
+
}
|
|
129
|
+
if got := os.Getenv("EXA_API_KEY"); got != "" {
|
|
130
|
+
t.Fatalf("expected explicitly empty env to remain untouched, got %q", got)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func TestEnvLocalCandidatesPreferExecutableDir(t *testing.T) {
|
|
135
|
+
originalGetwd := getwdFunc
|
|
136
|
+
originalExecutable := executableFunc
|
|
137
|
+
t.Cleanup(func() {
|
|
138
|
+
getwdFunc = originalGetwd
|
|
139
|
+
executableFunc = originalExecutable
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
getwdFunc = func() (string, error) { return "/tmp/project", nil }
|
|
143
|
+
executableFunc = func() (string, error) { return "/opt/webctx/bin/webctx", nil }
|
|
144
|
+
|
|
145
|
+
got := envLocalCandidates()
|
|
146
|
+
want := []string{
|
|
147
|
+
filepath.Join("/opt/webctx/bin", ".env.local"),
|
|
148
|
+
filepath.Join("/opt/webctx", ".env.local"),
|
|
149
|
+
filepath.Join("/tmp/project", ".env.local"),
|
|
150
|
+
}
|
|
151
|
+
if runtime.GOOS == "windows" {
|
|
152
|
+
for i := range want {
|
|
153
|
+
want[i] = filepath.Clean(want[i])
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if len(got) != len(want) {
|
|
158
|
+
t.Fatalf("unexpected candidate count: got %v want %v", got, want)
|
|
159
|
+
}
|
|
160
|
+
for i := range want {
|
|
161
|
+
if filepath.Clean(got[i]) != filepath.Clean(want[i]) {
|
|
162
|
+
t.Fatalf("candidate %d mismatch: got %q want %q", i, got[i], want[i])
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func TestLoadKeychainEnvLoadsMissingOnly(t *testing.T) {
|
|
168
|
+
originalLookup := keychainLookup
|
|
169
|
+
t.Cleanup(func() { keychainLookup = originalLookup })
|
|
170
|
+
|
|
171
|
+
keychainLookup = func(key string) (string, error) {
|
|
172
|
+
return "from-keychain-" + key, nil
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
t.Setenv("BRAVE_API_KEY", "already-set")
|
|
176
|
+
t.Setenv("TAVILY_API_KEY", "")
|
|
177
|
+
t.Setenv("EXA_API_KEY", "")
|
|
178
|
+
t.Setenv("FIRECRAWL_API_KEY", "")
|
|
179
|
+
|
|
180
|
+
loadKeychainEnv()
|
|
181
|
+
|
|
182
|
+
if got := os.Getenv("BRAVE_API_KEY"); got != "already-set" {
|
|
183
|
+
t.Fatalf("expected existing env to remain, got %q", got)
|
|
184
|
+
}
|
|
185
|
+
if got := os.Getenv("TAVILY_API_KEY"); got != "" {
|
|
186
|
+
t.Fatalf("expected explicitly empty env to remain untouched, got %q", got)
|
|
187
|
+
}
|
|
188
|
+
}
|
package/internal/app/scrape.go
CHANGED
|
@@ -7,7 +7,9 @@ import (
|
|
|
7
7
|
"net/http"
|
|
8
8
|
"net/url"
|
|
9
9
|
"os"
|
|
10
|
+
"os/exec"
|
|
10
11
|
"path/filepath"
|
|
12
|
+
"runtime"
|
|
11
13
|
"strings"
|
|
12
14
|
"sync"
|
|
13
15
|
"time"
|
|
@@ -19,6 +21,15 @@ type MarkdownResult struct {
|
|
|
19
21
|
Markdown string
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
const keychainServiceName = "webctx"
|
|
25
|
+
|
|
26
|
+
var (
|
|
27
|
+
credentialEnvKeys = []string{"BRAVE_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY", "FIRECRAWL_API_KEY"}
|
|
28
|
+
getwdFunc = os.Getwd
|
|
29
|
+
executableFunc = os.Executable
|
|
30
|
+
keychainLookup = lookupKeychainSecret
|
|
31
|
+
)
|
|
32
|
+
|
|
22
33
|
type githubURLInfo struct {
|
|
23
34
|
Owner string
|
|
24
35
|
Repo string
|
|
@@ -263,17 +274,18 @@ func loadEnvLocal() {
|
|
|
263
274
|
for _, candidate := range envLocalCandidates() {
|
|
264
275
|
loadDotEnvFile(candidate)
|
|
265
276
|
}
|
|
277
|
+
loadKeychainEnv()
|
|
266
278
|
}
|
|
267
279
|
|
|
268
280
|
func envLocalCandidates() []string {
|
|
269
281
|
candidates := []string{}
|
|
270
|
-
if
|
|
271
|
-
candidates = append(candidates, filepath.Join(cwd, ".env.local"))
|
|
272
|
-
}
|
|
273
|
-
if exe, err := os.Executable(); err == nil {
|
|
282
|
+
if exe, err := executableFunc(); err == nil {
|
|
274
283
|
exeDir := filepath.Dir(exe)
|
|
275
284
|
candidates = append(candidates, filepath.Join(exeDir, ".env.local"), filepath.Join(filepath.Dir(exeDir), ".env.local"))
|
|
276
285
|
}
|
|
286
|
+
if cwd, err := getwdFunc(); err == nil {
|
|
287
|
+
candidates = append(candidates, filepath.Join(cwd, ".env.local"))
|
|
288
|
+
}
|
|
277
289
|
seen := map[string]struct{}{}
|
|
278
290
|
unique := []string{}
|
|
279
291
|
for _, c := range candidates {
|
|
@@ -304,7 +316,38 @@ func loadDotEnvFile(path string) {
|
|
|
304
316
|
key = strings.TrimSpace(key)
|
|
305
317
|
value = strings.Trim(strings.TrimSpace(value), `"'`)
|
|
306
318
|
if key != "" {
|
|
319
|
+
if _, exists := os.LookupEnv(key); exists {
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
307
322
|
_ = os.Setenv(key, value)
|
|
308
323
|
}
|
|
309
324
|
}
|
|
310
325
|
}
|
|
326
|
+
|
|
327
|
+
func loadKeychainEnv() {
|
|
328
|
+
for _, key := range credentialEnvKeys {
|
|
329
|
+
if _, exists := os.LookupEnv(key); exists {
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
value, err := keychainLookup(key)
|
|
333
|
+
if err != nil || strings.TrimSpace(value) == "" {
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
_ = os.Setenv(key, value)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
func lookupKeychainSecret(account string) (string, error) {
|
|
341
|
+
if runtime.GOOS != "darwin" || strings.TrimSpace(account) == "" {
|
|
342
|
+
return "", nil
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
out, err := exec.Command("security", "find-generic-password", "-s", keychainServiceName, "-a", account, "-w").Output()
|
|
346
|
+
if err != nil {
|
|
347
|
+
if _, ok := err.(*exec.ExitError); ok {
|
|
348
|
+
return "", nil
|
|
349
|
+
}
|
|
350
|
+
return "", err
|
|
351
|
+
}
|
|
352
|
+
return strings.TrimSpace(string(out)), nil
|
|
353
|
+
}
|
package/internal/app/tools.go
CHANGED
|
@@ -34,6 +34,7 @@ type SearchResult struct {
|
|
|
34
34
|
type providerDocs struct {
|
|
35
35
|
Docs []SearchDoc
|
|
36
36
|
Provider string
|
|
37
|
+
Err error
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
type scoredURL struct {
|
|
@@ -77,7 +78,7 @@ func Search(params SearchParams) (string, error) {
|
|
|
77
78
|
defer cancel()
|
|
78
79
|
res, err := s.fn(ctx)
|
|
79
80
|
if err != nil {
|
|
80
|
-
results[i] = providerDocs{Provider: s.name, Docs: []SearchDoc{}}
|
|
81
|
+
results[i] = providerDocs{Provider: s.name, Docs: []SearchDoc{}, Err: err}
|
|
81
82
|
return
|
|
82
83
|
}
|
|
83
84
|
results[i] = providerDocs{Provider: s.name, Docs: res.Docs}
|
|
@@ -90,7 +91,23 @@ func Search(params SearchParams) (string, error) {
|
|
|
90
91
|
total += len(r.Docs)
|
|
91
92
|
}
|
|
92
93
|
if total == 0 {
|
|
93
|
-
|
|
94
|
+
missingKeys := uniqueMissingCredentialKeys(results)
|
|
95
|
+
if len(missingKeys) > 0 {
|
|
96
|
+
return "", missingCredentialsError("Error searching the web", missingKeys, "")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
failures := make([]string, 0, len(results))
|
|
100
|
+
for _, r := range results {
|
|
101
|
+
if r.Err == nil {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
failures = append(failures, fmt.Sprintf("%s: %v", r.Provider, r.Err))
|
|
105
|
+
}
|
|
106
|
+
if len(failures) > 0 {
|
|
107
|
+
return "", fmt.Errorf("Error searching the web: all search providers failed (%s)", strings.Join(failures, "; "))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return "", errors.New("Error searching the web: all search providers failed to return results")
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
filtered := filterExcludedDomains(results, allExcludedDomains)
|
|
@@ -126,7 +143,7 @@ func ReadLink(rawURL string) (string, error) {
|
|
|
126
143
|
|
|
127
144
|
apiKey := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
|
|
128
145
|
if apiKey == "" {
|
|
129
|
-
return "",
|
|
146
|
+
return "", missingCredentialsError("Error reading web page", []string{"FIRECRAWL_API_KEY"}, "for non-.md URLs")
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
requestBody := map[string]any{
|
|
@@ -179,7 +196,7 @@ func ReadLink(rawURL string) (string, error) {
|
|
|
179
196
|
func MapSite(rawURL string) (string, error) {
|
|
180
197
|
apiKey := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
|
|
181
198
|
if apiKey == "" {
|
|
182
|
-
return "",
|
|
199
|
+
return "", missingCredentialsError("Error mapping website", []string{"FIRECRAWL_API_KEY"}, "")
|
|
183
200
|
}
|
|
184
201
|
|
|
185
202
|
requestBody := map[string]any{
|
|
@@ -237,6 +254,47 @@ func formatReadLink(title, rawURL, markdown string) string {
|
|
|
237
254
|
return strings.Join(parts, "\n")
|
|
238
255
|
}
|
|
239
256
|
|
|
257
|
+
func uniqueMissingCredentialKeys(results []providerDocs) []string {
|
|
258
|
+
keys := map[string]struct{}{}
|
|
259
|
+
for _, r := range results {
|
|
260
|
+
if r.Err == nil {
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
msg := r.Err.Error()
|
|
264
|
+
if !strings.HasPrefix(msg, "missing ") {
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
key := strings.TrimSpace(strings.TrimPrefix(msg, "missing "))
|
|
268
|
+
if key != "" {
|
|
269
|
+
keys[key] = struct{}{}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
out := make([]string, 0, len(keys))
|
|
274
|
+
for key := range keys {
|
|
275
|
+
out = append(out, key)
|
|
276
|
+
}
|
|
277
|
+
sort.Strings(out)
|
|
278
|
+
return out
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func missingCredentialsError(prefix string, keys []string, detail string) error {
|
|
282
|
+
sort.Strings(keys)
|
|
283
|
+
keysText := strings.Join(keys, ", ")
|
|
284
|
+
suffix := ""
|
|
285
|
+
if strings.TrimSpace(detail) != "" {
|
|
286
|
+
suffix = " " + strings.TrimSpace(detail)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return fmt.Errorf(
|
|
290
|
+
"%s: missing %s%s. Set them as environment variables, place them in a .env.local next to the binary, or store them in macOS Keychain with service %q and account names matching the env vars",
|
|
291
|
+
prefix,
|
|
292
|
+
keysText,
|
|
293
|
+
suffix,
|
|
294
|
+
keychainServiceName,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
240
298
|
func searchWithBrave(ctx context.Context, query string) (SearchResult, error) {
|
|
241
299
|
apiKey := strings.TrimSpace(os.Getenv("BRAVE_API_KEY"))
|
|
242
300
|
if apiKey == "" {
|