diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ai.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ai.go new file mode 100644 index 00000000..62beefcb --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ai.go @@ -0,0 +1,22 @@ +// ©AngelaMos | 2026 +// ai.go + +package main + +import ( + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/nadezhda/internal/setup" +) + +var aiCmd = &cobra.Command{ + Use: "ai", + Short: "Set up an AI provider for ideation (interactive, re-runnable)", + RunE: func(cmd *cobra.Command, args []string) error { + return setup.Run(cmd.InOrStdin(), cmd.OutOrStdout()) + }, +} + +func init() { + rootCmd.AddCommand(aiCmd) +} diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/main.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/main.go index a4c7e183..3c1d5bcc 100644 --- a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/main.go +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/main.go @@ -3,10 +3,16 @@ package main -import "os" +import ( + "context" + "os" + "os/signal" +) func main() { - if err := rootCmd.Execute(); err != nil { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := rootCmd.ExecuteContext(ctx); err != nil { os.Exit(1) } } diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/root.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/root.go index 50cd452d..25b7ea95 100644 --- a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/root.go +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/root.go @@ -4,9 +4,12 @@ package main import ( + "os" + "github.com/spf13/cobra" "github.com/CarterPerez-dev/nadezhda/internal/config" + "github.com/CarterPerez-dev/nadezhda/internal/setup" ) var ( @@ -27,6 +30,9 @@ func init() { } func loadConfig() (config.Config, error) { + if err := setup.Load(); err != nil { + return config.Config{}, err + } cfg, err := config.Load(flagConfig) if err != nil { return config.Config{}, err @@ -34,5 +40,21 @@ func loadConfig() (config.Config, error) { if flagDB != "" { cfg.DBPath = flagDB } + if p := os.Getenv(setup.EnvProvider); p != "" { + cfg.AI.Enabled = true + cfg.AI.Provider = p + } + if u := os.Getenv(setup.EnvQwenURL); u != "" { + cfg.AI.Qwen.BaseURL = u + } return cfg, nil } + +func isInteractive(cmd *cobra.Command) bool { + f, ok := cmd.InOrStdin().(*os.File) + if !ok { + return false + } + fi, err := f.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/PROJECTS/intermediate/security-news-scraper/go.mod b/PROJECTS/intermediate/security-news-scraper/go.mod index 382c7a78..f51665b2 100644 --- a/PROJECTS/intermediate/security-news-scraper/go.mod +++ b/PROJECTS/intermediate/security-news-scraper/go.mod @@ -11,6 +11,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/temoto/robotstxt v1.1.2 golang.org/x/sync v0.21.0 + golang.org/x/term v0.41.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.53.0 diff --git a/PROJECTS/intermediate/security-news-scraper/go.sum b/PROJECTS/intermediate/security-news-scraper/go.sum index 7cf2a882..5445ca88 100644 --- a/PROJECTS/intermediate/security-news-scraper/go.sum +++ b/PROJECTS/intermediate/security-news-scraper/go.sum @@ -149,6 +149,8 @@ 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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/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= diff --git a/PROJECTS/intermediate/security-news-scraper/internal/config/config.go b/PROJECTS/intermediate/security-news-scraper/internal/config/config.go index 030567f7..b26b0f96 100644 --- a/PROJECTS/intermediate/security-news-scraper/internal/config/config.go +++ b/PROJECTS/intermediate/security-news-scraper/internal/config/config.go @@ -42,14 +42,14 @@ const ( defaultWeightEPSS = 0.08 defaultAIProvider = "qwen" - defaultQwenBaseURL = "http://localhost:11434/v1" + defaultQwenBaseURL = "http://localhost:39847/v1" defaultQwenModel = "qwen2.5:7b" defaultOpenAIURL = "https://api.openai.com/v1" defaultOpenAIModel = "gpt-4o-mini" defaultGeminiURL = "https://generativelanguage.googleapis.com/v1beta/openai/" defaultGeminiModel = "gemini-2.5-flash" defaultAnthropicURL = "https://api.anthropic.com/v1" - defaultClaudeModel = "claude-opus-4-8" + defaultClaudeModel = "claude-sonnet-4-6" ) var defaultTrackingParams = []string{ diff --git a/PROJECTS/intermediate/security-news-scraper/internal/setup/credentials.go b/PROJECTS/intermediate/security-news-scraper/internal/setup/credentials.go new file mode 100644 index 00000000..178e8030 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/setup/credentials.go @@ -0,0 +1,145 @@ +// ©AngelaMos | 2026 +// credentials.go + +package setup + +import ( + "bufio" + "fmt" + "os" + "sort" + "strings" +) + +const ( + EnvProvider = "NADEZHDA_AI_PROVIDER" + EnvQwenURL = "NADEZHDA_QWEN_BASE_URL" + + commentMark = "#" + kvSeparator = "=" + + credentialKeyPrefix = "NADEZHDA_" + credentialKeySuffix = "_API_KEY" + tmpSuffix = ".tmp" + + credentialsHeader = "# ©AngelaMos | 2026\n# nadezhda credentials — auto-managed by `nadezhda ai`; keep private, do not commit\n\n" +) + +func Load() error { + path, err := CredentialsPath() + if err != nil { + return err + } + kv, err := readCredentials(path) + if err != nil { + return err + } + for k, v := range kv { + if !allowedKey(k) { + continue + } + if _, set := os.LookupEnv(k); set { + continue + } + if err := os.Setenv(k, v); err != nil { + return fmt.Errorf("setup: set %s: %w", k, err) + } + } + return nil +} + +func allowedKey(k string) bool { + return strings.HasPrefix(k, credentialKeyPrefix) || strings.HasSuffix(k, credentialKeySuffix) +} + +func NonSecretEnviron() []string { + env := os.Environ() + out := make([]string, 0, len(env)) + for _, e := range env { + if k, _, ok := strings.Cut(e, kvSeparator); ok && allowedKey(k) { + continue + } + out = append(out, e) + } + return out +} + +func Save(updates map[string]string) error { + path, err := CredentialsPath() + if err != nil { + return err + } + dir, err := ConfigDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, dirPerm); err != nil { + return fmt.Errorf("setup: create config dir: %w", err) + } + kv, err := readCredentials(path) + if err != nil { + return err + } + for k, v := range updates { + kv[k] = v + } + return writeCredentials(path, kv) +} + +func readCredentials(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, fmt.Errorf("setup: read credentials: %w", err) + } + defer f.Close() + + kv := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, commentMark) { + continue + } + k, v, ok := strings.Cut(line, kvSeparator) + if !ok { + continue + } + key := strings.TrimSpace(k) + if key == "" { + continue + } + kv[key] = strings.TrimSpace(v) + } + return kv, sc.Err() +} + +func writeCredentials(path string, kv map[string]string) error { + keys := make([]string, 0, len(kv)) + for k := range kv { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString(credentialsHeader) + for _, k := range keys { + fmt.Fprintf(&b, "%s=%s\n", k, kv[k]) + } + + tmp := path + tmpSuffix + if err := os.WriteFile(tmp, []byte(b.String()), filePerm); err != nil { + return fmt.Errorf("setup: write credentials: %w", err) + } + if err := os.Chmod(tmp, filePerm); err != nil { + os.Remove(tmp) + return fmt.Errorf("setup: secure credentials: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return fmt.Errorf("setup: replace credentials: %w", err) + } + return nil +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/setup/credentials_test.go b/PROJECTS/intermediate/security-news-scraper/internal/setup/credentials_test.go new file mode 100644 index 00000000..e58c4fd5 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/setup/credentials_test.go @@ -0,0 +1,144 @@ +// ©AngelaMos | 2026 +// credentials_test.go + +package setup + +import ( + "os" + "testing" +) + +func TestCredentialsRoundTripAndPerms(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + if err := Save(map[string]string{"OPENAI_API_KEY": "sk-abc", EnvProvider: "openai"}); err != nil { + t.Fatalf("Save: %v", err) + } + if err := Save(map[string]string{"NVD_API_KEY": "nvd-1"}); err != nil { + t.Fatalf("Save merge: %v", err) + } + + path, _ := CredentialsPath() + kv, err := readCredentials(path) + if err != nil { + t.Fatalf("readCredentials: %v", err) + } + if kv["OPENAI_API_KEY"] != "sk-abc" || kv[EnvProvider] != "openai" || kv["NVD_API_KEY"] != "nvd-1" { + t.Errorf("merge lost data: %v", kv) + } + + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Errorf("credentials perm = %o, want 600", perm) + } +} + +func TestLoadEnvWins(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := Save(map[string]string{"OPENAI_API_KEY": "fromfile", "NADEZHDA_TEST_ONLY": "x"}); err != nil { + t.Fatal(err) + } + t.Setenv("OPENAI_API_KEY", "fromenv") + defer os.Unsetenv("NADEZHDA_TEST_ONLY") + + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := os.Getenv("OPENAI_API_KEY"); got != "fromenv" { + t.Errorf("OPENAI_API_KEY = %q, want fromenv (env must win over file)", got) + } + if got := os.Getenv("NADEZHDA_TEST_ONLY"); got != "x" { + t.Errorf("NADEZHDA_TEST_ONLY = %q, want x (unset var loads from file)", got) + } +} + +func TestReadCredentialsSkipsCommentsAndBlanks(t *testing.T) { + path := t.TempDir() + "/creds" + content := "# header\n\nOPENAI_API_KEY=sk-1\n # indented comment\nbadline_no_sep\nNVD_API_KEY = nvd-2 \n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + kv, err := readCredentials(path) + if err != nil { + t.Fatal(err) + } + if kv["OPENAI_API_KEY"] != "sk-1" || kv["NVD_API_KEY"] != "nvd-2" || len(kv) != 2 { + t.Errorf("parsed = %v, want exactly 2 clean entries", kv) + } +} + +func TestSaveCreatesDir0700AndFile0600(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := Save(map[string]string{"OPENAI_API_KEY": "k"}); err != nil { + t.Fatal(err) + } + dir, _ := ConfigDir() + if di, _ := os.Stat(dir); di.Mode().Perm() != 0o700 { + t.Errorf("config dir perms = %o, want 700", di.Mode().Perm()) + } + path, _ := CredentialsPath() + if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 { + t.Errorf("credentials perms = %o, want 600", fi.Mode().Perm()) + } +} + +func TestSaveTightensExistingLooseFile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + dir, _ := ConfigDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path, _ := CredentialsPath() + if err := os.WriteFile(path, []byte("OPENAI_API_KEY=old\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := Save(map[string]string{"OPENAI_API_KEY": "new"}); err != nil { + t.Fatal(err) + } + if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 { + t.Errorf("Save left perms %o on a pre-existing 0644 file, want 600", fi.Mode().Perm()) + } +} + +func TestLoadRespectsSetEmptyEnv(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := Save(map[string]string{"OPENAI_API_KEY": "fromfile"}); err != nil { + t.Fatal(err) + } + t.Setenv("OPENAI_API_KEY", "") + if err := Load(); err != nil { + t.Fatal(err) + } + if got := os.Getenv("OPENAI_API_KEY"); got != "" { + t.Errorf("an explicitly-cleared env var was overridden from file: %q", got) + } +} + +func TestLoadBlocksDisallowedAndEmptyKeys(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + dir, _ := ConfigDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path, _ := CredentialsPath() + if err := os.WriteFile(path, []byte("=orphan\nLD_PRELOAD=/tmp/evil.so\nOPENAI_API_KEY=ok\n"), 0o600); err != nil { + t.Fatal(err) + } + os.Unsetenv("LD_PRELOAD") + os.Unsetenv("OPENAI_API_KEY") + defer os.Unsetenv("LD_PRELOAD") + defer os.Unsetenv("OPENAI_API_KEY") + + if err := Load(); err != nil { + t.Fatalf("Load must tolerate an empty-key line, got: %v", err) + } + if _, set := os.LookupEnv("LD_PRELOAD"); set { + t.Error("LD_PRELOAD injected from credentials file — allowlist bypass (RCE vector)") + } + if os.Getenv("OPENAI_API_KEY") != "ok" { + t.Error("allowlisted OPENAI_API_KEY not loaded") + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama.compose.yml b/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama.compose.yml new file mode 100644 index 00000000..91d3f289 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama.compose.yml @@ -0,0 +1,30 @@ +# ©AngelaMos | 2026 +# ollama.compose.yml + +name: nadezhda-ollama + +services: + ollama: + image: ollama/ollama:latest + container_name: nadezhda-ollama + ports: + - "127.0.0.1:${OLLAMA_HOST_PORT:-39847}:11434" + volumes: + - nadezhda-ollama-models:/root/.ollama + entrypoint: ["/bin/sh", "-c"] + command: + - | + ollama serve & + until ollama list >/dev/null 2>&1; do sleep 1; done + ollama pull qwen2.5:7b + wait + healthcheck: + test: ["CMD-SHELL", "ollama list | grep -q qwen2.5"] + interval: 10s + timeout: 5s + retries: 60 + start_period: 30s + restart: unless-stopped + +volumes: + nadezhda-ollama-models: diff --git a/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama.go b/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama.go new file mode 100644 index 00000000..4be0a70d --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama.go @@ -0,0 +1,67 @@ +// ©AngelaMos | 2026 +// ollama.go + +package setup + +import ( + _ "embed" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +//go:embed ollama.compose.yml +var ComposeYAML []byte + +const ( + composeFileName = "ollama.compose.yml" + composePerm = 0o644 + versionPath = "/api/version" + apiV1Suffix = "/v1" +) + +var ollamaCandidates = []string{ + "http://localhost:39847", + "http://localhost:11434", +} + +func DetectOllama(client *http.Client) string { + for _, base := range ollamaCandidates { + resp, err := client.Get(base + versionPath) + if err != nil { + continue + } + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return base + } + } + return "" +} + +func WriteCompose(dir string) (string, error) { + path := filepath.Join(dir, composeFileName) + if err := os.WriteFile(path, ComposeYAML, composePerm); err != nil { + return "", fmt.Errorf("setup: write compose: %w", err) + } + return path, nil +} + +func HasBinary(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func OllamaReachable(baseURL string) bool { + base := strings.TrimSuffix(baseURL, apiV1Suffix) + client := &http.Client{Timeout: detectTimeout} + resp, err := client.Get(base + versionPath) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama_test.go b/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama_test.go new file mode 100644 index 00000000..4b9d7f07 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/setup/ollama_test.go @@ -0,0 +1,62 @@ +// ©AngelaMos | 2026 +// ollama_test.go + +package setup + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDetectOllama(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == versionPath { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + old := ollamaCandidates + defer func() { ollamaCandidates = old }() + client := &http.Client{Timeout: 2 * time.Second} + + ollamaCandidates = []string{srv.URL} + if got := DetectOllama(client); got != srv.URL { + t.Errorf("DetectOllama = %q, want %q", got, srv.URL) + } + + ollamaCandidates = []string{"http://127.0.0.1:1"} + if got := DetectOllama(client); got != "" { + t.Errorf("DetectOllama with no server = %q, want empty", got) + } +} + +func TestWriteComposeMatchesEmbed(t *testing.T) { + yaml := string(ComposeYAML) + if len(yaml) == 0 || !strings.Contains(yaml, "nadezhda-ollama") || !strings.Contains(yaml, "qwen2.5:7b") { + t.Fatal("embedded compose is empty or missing expected content") + } + + dir := t.TempDir() + path, err := WriteCompose(dir) + if err != nil { + t.Fatalf("WriteCompose: %v", err) + } + if path != filepath.Join(dir, composeFileName) { + t.Errorf("path = %q", path) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != yaml { + t.Error("written compose differs from embedded content") + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/setup/paths.go b/PROJECTS/intermediate/security-news-scraper/internal/setup/paths.go new file mode 100644 index 00000000..ecea50c6 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/setup/paths.go @@ -0,0 +1,33 @@ +// ©AngelaMos | 2026 +// paths.go + +package setup + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + appDir = "nadezhda" + credentialsFile = "credentials" + dirPerm = 0o700 + filePerm = 0o600 +) + +func ConfigDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("setup: locate config dir: %w", err) + } + return filepath.Join(base, appDir), nil +} + +func CredentialsPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, credentialsFile), nil +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/setup/wizard.go b/PROJECTS/intermediate/security-news-scraper/internal/setup/wizard.go new file mode 100644 index 00000000..f964bd67 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/setup/wizard.go @@ -0,0 +1,158 @@ +// ©AngelaMos | 2026 +// wizard.go + +package setup + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/term" +) + +const ( + ProviderClaude = "anthropic" + ProviderOpenAI = "openai" + ProviderGemini = "gemini" + ProviderQwen = "qwen" + + envOpenAIKey = "OPENAI_API_KEY" + envGeminiKey = "GEMINI_API_KEY" + envAnthropicKey = "ANTHROPIC_API_KEY" + envNVDKey = "NVD_API_KEY" + + composeQwenURL = "http://localhost:39847/v1" + nativeQwenURL = "http://localhost:11434/v1" + + detectTimeout = 3 * time.Second + ollamaInstall = "https://ollama.com/download" +) + +type apiChoice struct { + provider string + envKey string + label string +} + +var apiProviders = map[string]apiChoice{ + "1": {ProviderClaude, envAnthropicKey, "Claude (Anthropic)"}, + "2": {ProviderOpenAI, envOpenAIKey, "OpenAI"}, + "3": {ProviderGemini, envGeminiKey, "Gemini (Google)"}, +} + +func Run(in io.Reader, out io.Writer) error { + fmt.Fprintln(out, "nadezhda AI setup") + fmt.Fprintln(out, " 1) Claude (Anthropic) 2) OpenAI 3) Gemini 4) Ollama (local qwen, free, no key)") + choice := prompt(in, out, "choose a provider [1-4]: ") + + switch choice { + case "1", "2", "3": + return setupAPIProvider(in, out, apiProviders[choice]) + case "4": + return setupOllama(out) + default: + return fmt.Errorf("setup: invalid choice %q (want 1-4)", choice) + } +} + +func setupAPIProvider(in io.Reader, out io.Writer, c apiChoice) error { + fmt.Fprintf(out, "\nConfiguring %s.\n", c.label) + key := strings.TrimSpace(readSecret(in, out, fmt.Sprintf("paste your %s API key: ", c.label))) + if key == "" { + return fmt.Errorf("setup: no API key entered") + } + updates := map[string]string{ + EnvProvider: c.provider, + c.envKey: key, + } + if nvd := strings.TrimSpace(readSecret(in, out, "optional NVD token for the CVE booster (enter to skip): ")); nvd != "" { + updates[envNVDKey] = nvd + } + if err := Save(updates); err != nil { + return err + } + path, _ := CredentialsPath() + fmt.Fprintf(out, "\nSaved to %s (0600). %s is ready — run: nadezhda ideate\n", path, c.label) + return nil +} + +func setupOllama(out io.Writer) error { + fmt.Fprintln(out, "\nConfiguring Ollama (local qwen2.5, no API key).") + client := &http.Client{Timeout: detectTimeout} + + if base := DetectOllama(client); base != "" { + fmt.Fprintf(out, "Found a running Ollama at %s — using it.\n", base) + return saveOllama(out, base+"/v1") + } + + fmt.Fprintln(out, "No running Ollama found.") + switch { + case HasBinary("docker"): + dir, _ := os.Getwd() + path, err := WriteCompose(dir) + if err != nil { + return err + } + fmt.Fprintf(out, "Wrote %s. Start it with:\n docker compose -f %s up -d\n", path, path) + fmt.Fprintln(out, "(first run pulls qwen2.5:7b, a few minutes), then re-run `nadezhda ai` and pick 4.") + return saveOllama(out, composeQwenURL) + case HasBinary("ollama"): + fmt.Fprintln(out, "Ollama is installed but not running. Start it and pull the model:") + fmt.Fprintln(out, " ollama serve & then: ollama pull qwen2.5:7b") + return saveOllama(out, nativeQwenURL) + default: + fmt.Fprintf(out, "Install Ollama (%s) or Docker, then re-run `nadezhda ai`.\n", ollamaInstall) + return saveOllama(out, composeQwenURL) + } +} + +func saveOllama(out io.Writer, baseURL string) error { + if err := Save(map[string]string{EnvProvider: ProviderQwen, EnvQwenURL: baseURL}); err != nil { + return err + } + path, _ := CredentialsPath() + fmt.Fprintf(out, "Saved qwen (%s) to %s. Run: nadezhda ideate\n", baseURL, path) + return nil +} + +func prompt(in io.Reader, out io.Writer, label string) string { + fmt.Fprint(out, label) + return readLine(in) +} + +func readSecret(in io.Reader, out io.Writer, label string) string { + fmt.Fprint(out, label) + if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + b, err := term.ReadPassword(int(f.Fd())) + fmt.Fprintln(out) + if err != nil { + return "" + } + return string(b) + } + return readLine(in) +} + +func readLine(in io.Reader) string { + var b strings.Builder + buf := make([]byte, 1) + for { + n, err := in.Read(buf) + if n > 0 { + if buf[0] == '\n' { + break + } + if buf[0] != '\r' { + b.WriteByte(buf[0]) + } + } + if err != nil { + break + } + } + return strings.TrimSpace(b.String()) +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/setup/wizard_test.go b/PROJECTS/intermediate/security-news-scraper/internal/setup/wizard_test.go new file mode 100644 index 00000000..61648c3f --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/setup/wizard_test.go @@ -0,0 +1,82 @@ +// ©AngelaMos | 2026 +// wizard_test.go + +package setup + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWizardAPIProvider(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var out bytes.Buffer + in := strings.NewReader("2\nsk-test-key\n\n") + + if err := Run(in, &out); err != nil { + t.Fatalf("Run: %v", err) + } + path, _ := CredentialsPath() + kv, _ := readCredentials(path) + if kv[EnvProvider] != ProviderOpenAI || kv["OPENAI_API_KEY"] != "sk-test-key" { + t.Errorf("saved = %v, want openai + key", kv) + } + if _, ok := kv["NVD_API_KEY"]; ok { + t.Error("NVD key should be absent when skipped") + } + if !strings.Contains(out.String(), "OpenAI is ready") { + t.Errorf("output missing confirmation:\n%s", out.String()) + } + if strings.Contains(out.String(), "sk-test-key") { + t.Error("wizard echoed the API key to its output") + } +} + +func TestWizardOllamaDetected(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + old := ollamaCandidates + ollamaCandidates = []string{srv.URL} + defer func() { ollamaCandidates = old }() + + var out bytes.Buffer + if err := Run(strings.NewReader("4\n"), &out); err != nil { + t.Fatalf("Run: %v", err) + } + path, _ := CredentialsPath() + kv, _ := readCredentials(path) + if kv[EnvProvider] != ProviderQwen || kv[EnvQwenURL] != srv.URL+"/v1" { + t.Errorf("saved = %v, want qwen + %s/v1", kv, srv.URL) + } + if !strings.Contains(out.String(), "Found a running Ollama") { + t.Errorf("output missing detect line:\n%s", out.String()) + } +} + +func TestWizardInvalidChoice(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := Run(strings.NewReader("9\n"), &bytes.Buffer{}); err == nil { + t.Error("invalid choice should error") + } +} + +func TestReadLine(t *testing.T) { + cases := map[string]string{ + "hello\n": "hello", + "trim \r\n": "trim", + "noeol": "noeol", + "\n": "", + } + for in, want := range cases { + if got := readLine(strings.NewReader(in)); got != want { + t.Errorf("readLine(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/justfile b/PROJECTS/intermediate/security-news-scraper/justfile index 85d7f3c8..fa4b6927 100644 --- a/PROJECTS/intermediate/security-news-scraper/justfile +++ b/PROJECTS/intermediate/security-news-scraper/justfile @@ -29,5 +29,15 @@ lint: vet tidy: go mod tidy +ollama-up: + docker compose -f internal/setup/ollama.compose.yml up -d + @echo "ollama starting on 127.0.0.1:39847 (override with OLLAMA_HOST_PORT) — the first run pulls qwen2.5:7b (a few minutes). watch progress with: just ollama-logs" + +ollama-down: + docker compose -f internal/setup/ollama.compose.yml down + +ollama-logs: + docker compose -f internal/setup/ollama.compose.yml logs -f + clean: rm -f {{binary}} *.db *.db-wal *.db-shm