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.
@@ -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
+ }
@@ -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 cwd, err := os.Getwd(); err == nil {
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
+ }
@@ -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
- return "", errors.New("Error searching the web: All search providers failed to return results")
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 "", errors.New("Error reading web page: FIRECRAWL_API_KEY environment variable is required for non-.md URLs")
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 "", errors.New("Error mapping website: FIRECRAWL_API_KEY environment variable is required")
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 == "" {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pure Go web search and browsing CLI using Brave, Tavily, Exa, and Firecrawl",
5
5
  "license": "MIT",
6
6
  "author": "amxv",