From 75c8873a661381d11c225400a41ad594c3af90c9 Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:20:17 +0200 Subject: [PATCH 01/13] feat: add country selection for AT/DE support Add a `set-country ` command (at/de) that switches the API endpoint between marktguru.at and marktguru.de. The login command also uses the configured country so the extracted API key matches the correct domain. Defaults to "at" for backward compatibility. --- src/api.ts | 10 +++++++--- src/auth.ts | 28 +++++++++++++++------------- src/cli.ts | 29 ++++++++++++++++++++++++++--- src/commands/login.ts | 5 +++-- src/commands/search.ts | 2 ++ src/config.ts | 7 +++++-- 6 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/api.ts b/src/api.ts index 2836205..5ab0a37 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,8 @@ -import { getConfig, DEFAULT_ZIP_CODE } from "./config.js"; +import { getConfig, DEFAULT_ZIP_CODE, DEFAULT_COUNTRY } from "./config.js"; -const API_BASE = "https://api.marktguru.at/api/v1"; +export function getApiBase(country: string): string { + return `https://api.marktguru.${country}/api/v1`; +} export interface Offer { id: number; @@ -43,6 +45,7 @@ export interface SearchResult { export interface SearchOptions { query: string; zipCode?: string; + country?: string; limit?: number; offset?: number; retailerId?: number; @@ -57,6 +60,7 @@ export async function search(options: SearchOptions): Promise { } const zipCode = options.zipCode || config.zipCode || DEFAULT_ZIP_CODE; + const country = options.country || config.country || DEFAULT_COUNTRY; const params = new URLSearchParams({ as: "web", @@ -70,7 +74,7 @@ export async function search(options: SearchOptions): Promise { params.set("retailerIds", String(options.retailerId)); } - const url = `${API_BASE}/offers/search?${params}`; + const url = `${getApiBase(country)}/offers/search?${params}`; const response = await fetch(url, { headers: { diff --git a/src/auth.ts b/src/auth.ts index 7dc4de7..38787ad 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,9 +1,8 @@ interface ExtractOptions { log?: (message: string) => void; + country?: string; } -const BASE_URL = "https://www.marktguru.at"; -const API_BASE = "https://api.marktguru.at/api/v1"; const DEFAULT_ZIP_CODE = "1010"; const MAX_SCRIPTS = 20; @@ -55,14 +54,14 @@ async function fetchFirstOk(urls: string[], headers: Record) { throw new Error("No URLs to fetch."); } -function extractScriptUrls(html: string): string[] { +function extractScriptUrls(html: string, baseUrl: string): string[] { const urls = new Set(); const regex = /]+src=["']([^"']+)["'][^>]*>/gi; let match: RegExpExecArray | null; while ((match = regex.exec(html))) { let src = match[1]; if (src.startsWith("//")) src = `https:${src}`; - if (src.startsWith("/")) src = `${BASE_URL}${src}`; + if (src.startsWith("/")) src = `${baseUrl}${src}`; if (src.startsWith("http")) urls.add(src); } return [...urls]; @@ -93,8 +92,8 @@ function findCandidates(text: string): string[] { return [...candidates]; } -async function validateKey(apiKey: string): Promise { - const url = `${API_BASE}/offers/search?as=web&q=test&limit=1&zipCode=${DEFAULT_ZIP_CODE}`; +async function validateKey(apiKey: string, apiBase: string): Promise { + const url = `${apiBase}/offers/search?as=web&q=test&limit=1&zipCode=${DEFAULT_ZIP_CODE}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); try { @@ -113,14 +112,17 @@ async function validateKey(apiKey: string): Promise { export async function extractApiKey(options: ExtractOptions = {}): Promise { const log = options.log; + const country = options.country ?? "at"; + const baseUrl = `https://www.marktguru.${country}`; + const apiBase = `https://api.marktguru.${country}/api/v1`; const headers = await maybeGetHeaders(); const entryUrls = [ - `${BASE_URL}/`, - `${BASE_URL}/search`, - `${BASE_URL}/search?q=test`, - `${BASE_URL}/suche`, - `${BASE_URL}/suche?q=test`, + `${baseUrl}/`, + `${baseUrl}/search`, + `${baseUrl}/search?q=test`, + `${baseUrl}/suche`, + `${baseUrl}/suche?q=test`, ]; log?.("→ Fetching entry HTML..."); @@ -129,7 +131,7 @@ export async function extractApiKey(options: ExtractOptions = {}): Promise { program .command("login") - .description("Extract API key from marktguru.at via HTTP") + .description("Extract API key from marktguru.at/de via HTTP") .option("-j, --json", "Output JSON") .action(async (options) => { await login({ ...options, json: getJsonFlag(options) }); @@ -107,6 +107,27 @@ program } }); +const VALID_COUNTRIES = ["at", "de"]; + +program + .command("set-country ") + .description("Set default country for searches (at, de)") + .option("-j, --json", "Output JSON") + .action(async (code: string, options) => { + const normalized = code.toLowerCase(); + if (!VALID_COUNTRIES.includes(normalized)) { + console.error(`Error: Invalid country "${code}". Valid options: ${VALID_COUNTRIES.join(", ")}`); + process.exit(1); + } + await saveConfig({ country: normalized }); + const json = getJsonFlag(options); + if (json) { + console.log(JSON.stringify({ success: true, country: normalized })); + } else { + console.log(`✓ Default country set to: ${normalized}`); + } + }); + program .command("config") .description("Show current configuration") @@ -119,12 +140,14 @@ program apiKey: config.apiKey ? config.apiKey.substring(0, 10) + "..." : null, apiKeySet: !!config.apiKey, zipCode: config.zipCode || DEFAULT_ZIP_CODE, + country: config.country || DEFAULT_COUNTRY, configPath: config.configPath, })); } else { console.log("Configuration:"); console.log(" API Key:", config.apiKey ? config.apiKey.substring(0, 10) + "..." : "(not set)"); console.log(" ZIP Code:", config.zipCode || `(default: ${DEFAULT_ZIP_CODE})`); + console.log(" Country:", config.country || `(default: ${DEFAULT_COUNTRY})`); console.log(" Config file:", config.configPath); } }); diff --git a/src/commands/login.ts b/src/commands/login.ts index a4a6fb9..33218da 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,4 +1,4 @@ -import { saveConfig } from "../config.js"; +import { saveConfig, getConfig } from "../config.js"; import { extractApiKey } from "../auth.js"; interface LoginOptions { @@ -29,7 +29,8 @@ export async function login(options: LoginOptions): Promise { log("Extracting Marktguru API key (HTTP-only)...\n"); try { - const apiKey = await extractApiKey({ log: json ? undefined : log }); + const config = await getConfig(); + const apiKey = await extractApiKey({ log: json ? undefined : log, country: config.country }); await saveConfig({ apiKey }); output({ success: true, apiKey }, json); } catch (e) { diff --git a/src/commands/search.ts b/src/commands/search.ts index f4dc142..d2c3429 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -57,6 +57,7 @@ interface SimpleSearchResult { export interface SearchCommandOptions { zip?: string; + country?: string; limit?: number; retailer?: string; json?: boolean; @@ -174,6 +175,7 @@ async function runSearch(query: string, options: SearchCommandOptions): Promise< const result = await apiSearch({ query, zipCode: options.zip, + country: options.country, limit: fetchLimit, apiKey, }); diff --git a/src/config.ts b/src/config.ts index cf04f5e..04b768d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,10 +5,12 @@ import { readFile, writeFile, mkdir } from "fs/promises"; export interface Config { apiKey?: string; zipCode?: string; + country?: string; configPath: string; } export const DEFAULT_ZIP_CODE = "1010"; // Vienna +export const DEFAULT_COUNTRY = "at"; const CONFIG_DIR = join(homedir(), ".marktguru"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); @@ -16,9 +18,10 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.json"); export async function getConfig(): Promise { try { const data = await readFile(CONFIG_FILE, "utf-8"); - return { ...JSON.parse(data), configPath: CONFIG_FILE }; + const parsed = JSON.parse(data); + return { country: DEFAULT_COUNTRY, ...parsed, configPath: CONFIG_FILE }; } catch { - return { configPath: CONFIG_FILE }; + return { country: DEFAULT_COUNTRY, configPath: CONFIG_FILE }; } } From c9dd26c0ecc6d1dae53f151b1bf35f4461694871 Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:21:57 +0200 Subject: [PATCH 02/13] docs: update README and SKILL.md for AT/DE country support --- README.md | 9 ++++++++- SKILL.md | 31 ++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0a68fe7..3e00bd7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![npm](https://img.shields.io/npm/v/marktguru-cli.svg)](https://www.npmjs.com/package/marktguru-cli) [![license](https://img.shields.io/github/license/manmal/marktguru-cli.svg)](https://github.com/manmal/marktguru-cli/blob/main/LICENSE) -CLI for Austrian Marktguru supermarket deals. +CLI for Marktguru supermarket deals in Austria and Germany. ## AI Agent Skill See [SKILL.md](SKILL.md) for a comprehensive reference designed for AI coding agents. @@ -46,6 +46,11 @@ Set a default ZIP code: marktguru set-zip 1010 ``` +Set a default country (`at` or `de`, default: `at`): +```bash +marktguru set-country de +``` + Show config: ```bash marktguru config @@ -73,6 +78,8 @@ Available for both `search raw` and `search build`: If no API key is configured, `search` will automatically run `login` to extract one. +Note: API keys are country-specific. After switching country with `set-country`, run `login` again to fetch the matching key. + Builder-only: - `--term `: Add a term (repeatable) - `--phrase `: Add an exact phrase (repeatable) diff --git a/SKILL.md b/SKILL.md index 36af9a6..c22ff4b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,11 +1,11 @@ --- name: marktguru-grocery-deals -description: Look up grocery deals and offers via Marktguru CLI/API. Use when user asks about supermarket discounts, product prices, current promotions, or comparing deals across Austrian retailers (Hofer, Billa, Spar, Lidl, etc.). +description: Look up grocery deals and offers via Marktguru CLI/API. Use when user asks about supermarket discounts, product prices, current promotions, or comparing deals across Austrian or German retailers (Hofer, Billa, Spar, Lidl, Penny, Kaufland, etc.). --- # Marktguru Grocery Deals -Query Austrian grocery deals from Marktguru. Supports raw queries, structured search building, retailer filtering, and ZIP-code location targeting. +Query grocery deals from Marktguru in Austria and Germany. Supports raw queries, structured search building, retailer filtering, ZIP-code location targeting, and country selection (AT/DE). ## Quick Reference @@ -15,8 +15,9 @@ Query Austrian grocery deals from Marktguru. Supports raw queries, structured se | `search build` | Build query from structured flags | | `search syntax` | Show supported query syntax | | `set-zip ` | Set default ZIP code | +| `set-country ` | Set default country (`at` or `de`, default: `at`) | | `config` | Show current configuration | -| `login` | Extract API key from marktguru.at | +| `login` | Extract API key from marktguru.at/de | --- @@ -32,8 +33,16 @@ Scans site HTML and boot scripts for embedded API keys. No browser automation re ```bash npx marktguru-cli set-zip 1010 npx marktguru-cli set-zip 8010 # Graz +npx marktguru-cli set-zip 10115 # Berlin (DE) ``` +### Set Default Country +```bash +npx marktguru-cli set-country at # Austria (default) +npx marktguru-cli set-country de # Germany +``` +After switching country, re-run `login` — API keys are country-specific. + ### Check Config ```bash npx marktguru-cli config @@ -118,6 +127,8 @@ npx marktguru-cli search raw '"Coca Cola"' ## Known Retailers +**Austria (at):** + | Retailer | Notes | |----------|-------| | SPAR | | @@ -131,6 +142,18 @@ npx marktguru-cli search raw '"Coca Cola"' | dm drogerie markt | Drugstore (some food items) | | BIPA | Drugstore | +**Germany (de):** + +| Retailer | Notes | +|----------|-------| +| Lidl | | +| PENNY | | +| Kaufland | | +| REWE | | +| Netto Marken-Discount | | +| ALDI | | +| dm drogerie markt | Drugstore (some food items) | + --- ## JSON Output @@ -209,6 +232,7 @@ npx marktguru-cli config --json "apiKey": "pCcm1AVCYa...", "apiKeySet": true, "zipCode": "1010", + "country": "at", "configPath": "/Users/.../.marktguru/config.json" } ``` @@ -223,6 +247,7 @@ npx marktguru-cli config --json | No results | Try broader terms, wildcards (`*`), or alternative spellings. | | Wrong location | Set ZIP code with `set-zip` or use `--zip` flag. | | API key expired | Re-run `npx marktguru-cli login` to refresh. | +| Wrong country results | Run `set-country de` (or `at`), then `login` again — keys are country-specific. | --- From 8954ba4ce40668527cf49414f9ba1ee000b6e2ce Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:26:35 +0200 Subject: [PATCH 03/13] docs: combine retailer table with AT/DE columns in SKILL.md --- SKILL.md | 42 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/SKILL.md b/SKILL.md index c22ff4b..6dc97e2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -127,32 +127,22 @@ npx marktguru-cli search raw '"Coca Cola"' ## Known Retailers -**Austria (at):** - -| Retailer | Notes | -|----------|-------| -| SPAR | | -| INTERSPAR | Larger SPAR format | -| SPAR-Gourmet | Premium SPAR | -| BILLA | | -| BILLA PLUS | Larger BILLA format | -| HOFER | Austrian Aldi | -| Lidl | | -| PENNY | | -| dm drogerie markt | Drugstore (some food items) | -| BIPA | Drugstore | - -**Germany (de):** - -| Retailer | Notes | -|----------|-------| -| Lidl | | -| PENNY | | -| Kaufland | | -| REWE | | -| Netto Marken-Discount | | -| ALDI | | -| dm drogerie markt | Drugstore (some food items) | +| Retailer | AT | DE | Notes | +|----------|----|-----|-------| +| Lidl | ✓ | ✓ | | +| PENNY | ✓ | ✓ | | +| dm drogerie markt | ✓ | ✓ | Drugstore (some food items) | +| SPAR | ✓ | | | +| INTERSPAR | ✓ | | Larger SPAR format | +| SPAR-Gourmet | ✓ | | Premium SPAR | +| BILLA | ✓ | | | +| BILLA PLUS | ✓ | | Larger BILLA format | +| HOFER | ✓ | | Austrian Aldi | +| BIPA | ✓ | | Drugstore | +| Kaufland | | ✓ | | +| REWE | | ✓ | | +| Netto Marken-Discount | | ✓ | | +| ALDI | | ✓ | | --- From 8dd2d2e922c857762f61a6250d3678be5b2e08ab Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:28:00 +0200 Subject: [PATCH 04/13] docs: align retailer table columns in SKILL.md --- SKILL.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/SKILL.md b/SKILL.md index 6dc97e2..ace3e21 100644 --- a/SKILL.md +++ b/SKILL.md @@ -127,22 +127,22 @@ npx marktguru-cli search raw '"Coca Cola"' ## Known Retailers -| Retailer | AT | DE | Notes | -|----------|----|-----|-------| -| Lidl | ✓ | ✓ | | -| PENNY | ✓ | ✓ | | -| dm drogerie markt | ✓ | ✓ | Drugstore (some food items) | -| SPAR | ✓ | | | -| INTERSPAR | ✓ | | Larger SPAR format | -| SPAR-Gourmet | ✓ | | Premium SPAR | -| BILLA | ✓ | | | -| BILLA PLUS | ✓ | | Larger BILLA format | -| HOFER | ✓ | | Austrian Aldi | -| BIPA | ✓ | | Drugstore | -| Kaufland | | ✓ | | -| REWE | | ✓ | | -| Netto Marken-Discount | | ✓ | | -| ALDI | | ✓ | | +| Retailer | AT | DE | Notes | +|-----------------------|----|----|-----------------------------| +| Lidl | ✓ | ✓ | | +| PENNY | ✓ | ✓ | | +| dm drogerie markt | ✓ | ✓ | Drugstore (some food items) | +| SPAR | ✓ | | | +| INTERSPAR | ✓ | | Larger SPAR format | +| SPAR-Gourmet | ✓ | | Premium SPAR | +| BILLA | ✓ | | | +| BILLA PLUS | ✓ | | Larger BILLA format | +| HOFER | ✓ | | Austrian Aldi | +| BIPA | ✓ | | Drugstore | +| Kaufland | | ✓ | | +| REWE | | ✓ | | +| Netto Marken-Discount | | ✓ | | +| ALDI | | ✓ | | --- From f64c91cc0106f9e4aaebe607d11a19b9143014a9 Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:30:19 +0200 Subject: [PATCH 05/13] feat: add tests for getApiBase --- tests/api.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/api.test.js diff --git a/tests/api.test.js b/tests/api.test.js new file mode 100644 index 0000000..08122b9 --- /dev/null +++ b/tests/api.test.js @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getApiBase } from "../dist/api.js"; + +test("getApiBase returns correct URL for AT", () => { + assert.equal(getApiBase("at"), "https://api.marktguru.at/api/v1"); +}); + +test("getApiBase returns correct URL for DE", () => { + assert.equal(getApiBase("de"), "https://api.marktguru.de/api/v1"); +}); From b45ef9c052e5b69d5bf4a87024c773b7eba8cd57 Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:31:30 +0200 Subject: [PATCH 06/13] chore: update package description for AT/DE support --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c55f5e..de5382f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "marktguru-cli", "version": "0.1.0", - "description": "CLI for Austrian Marktguru supermarket deals", + "description": "CLI for Marktguru supermarket deals in Austria and Germany", "type": "module", "bin": { "marktguru": "dist/cli.js" From 8d021cfe98007e7593fffce7d16f48f5b9c30d84 Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:37:39 +0200 Subject: [PATCH 07/13] =?UTF-8?q?chore:=20drop=20CH=20=E2=80=94=20api.mark?= =?UTF-8?q?tguru.ch=20does=20not=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SKILL.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index ace3e21..bfc77dd 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: marktguru-grocery-deals -description: Look up grocery deals and offers via Marktguru CLI/API. Use when user asks about supermarket discounts, product prices, current promotions, or comparing deals across Austrian or German retailers (Hofer, Billa, Spar, Lidl, Penny, Kaufland, etc.). +description: Look up grocery deals and offers via Marktguru CLI/API. Use when user asks about supermarket discounts, product prices, current promotions, or comparing deals across Austrian or German retailers (Hofer, Billa, Spar, Lidl, Penny, REWE, Kaufland, etc.). --- # Marktguru Grocery Deals diff --git a/package.json b/package.json index de5382f..59efac7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "marktguru-cli", "version": "0.1.0", - "description": "CLI for Marktguru supermarket deals in Austria and Germany", + "description": "CLI for Marktguru supermarket deals in Austria, Germany and Switzerland", "type": "module", "bin": { "marktguru": "dist/cli.js" From bbda8866a7f2584fbecd772f4fa6ca22e1f3bafa Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:41:10 +0200 Subject: [PATCH 08/13] chore: remove Switzerland from package description --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 59efac7..de5382f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "marktguru-cli", "version": "0.1.0", - "description": "CLI for Marktguru supermarket deals in Austria, Germany and Switzerland", + "description": "CLI for Marktguru supermarket deals in Austria and Germany", "type": "module", "bin": { "marktguru": "dist/cli.js" From d52f2cf28059922826958fedd8ea0e23c16db73f Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:42:33 +0200 Subject: [PATCH 09/13] fix: pass country to extractApiKey during auto-login in search --- src/commands/search.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/search.ts b/src/commands/search.ts index d2c3429..21f3005 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -154,20 +154,20 @@ function emitWarnings(warnings: string[]): void { } } -async function ensureApiKey(json?: boolean): Promise { +async function ensureApiKey(json?: boolean, country?: string): Promise { const config = await getConfig(); if (config.apiKey) return config.apiKey; const log = json ? (msg: string) => console.error(msg) : (msg: string) => console.log(msg); log("No API key configured. Running login..."); - const apiKey = await extractApiKey({ log }); + const apiKey = await extractApiKey({ log, country: country ?? config.country }); await saveConfig({ apiKey }); return apiKey; } async function runSearch(query: string, options: SearchCommandOptions): Promise { - const apiKey = await ensureApiKey(options.json); + const apiKey = await ensureApiKey(options.json, options.country); // Fetch more results if filtering by retailer (we'll filter client-side) const limit = normalizeLimit(options.limit); const fetchLimit = options.retailer ? Math.max(limit, 100) : limit; From 8277f3f28f0177b2617564b1e3d035496eef3d5d Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:45:39 +0200 Subject: [PATCH 10/13] fix: clear API key when country changes in set-country --- src/cli.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f57a342..5383db3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -119,12 +119,17 @@ program console.error(`Error: Invalid country "${code}". Valid options: ${VALID_COUNTRIES.join(", ")}`); process.exit(1); } - await saveConfig({ country: normalized }); + const existing = await getConfig(); + const countryChanged = existing.country !== normalized; + await saveConfig({ country: normalized, ...(countryChanged && { apiKey: undefined }) }); const json = getJsonFlag(options); if (json) { - console.log(JSON.stringify({ success: true, country: normalized })); + console.log(JSON.stringify({ success: true, country: normalized, apiKeyCleared: countryChanged })); } else { console.log(`✓ Default country set to: ${normalized}`); + if (countryChanged && existing.apiKey) { + console.log(" API key cleared — run 'marktguru login' to fetch a matching key."); + } } }); From beb63ac1584e3b28bc05936c1969b7d8c7c6ca59 Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:50:23 +0200 Subject: [PATCH 11/13] fix: only report apiKeyCleared:true in JSON when a key was actually cleared --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 5383db3..f6e0b12 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -124,7 +124,7 @@ program await saveConfig({ country: normalized, ...(countryChanged && { apiKey: undefined }) }); const json = getJsonFlag(options); if (json) { - console.log(JSON.stringify({ success: true, country: normalized, apiKeyCleared: countryChanged })); + console.log(JSON.stringify({ success: true, country: normalized, apiKeyCleared: countryChanged && !!existing.apiKey })); } else { console.log(`✓ Default country set to: ${normalized}`); if (countryChanged && existing.apiKey) { From 4ac9f76a7f4fcad0a5f9da829ffdfd5c856a1ca1 Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:56:44 +0200 Subject: [PATCH 12/13] fix: validate country code before interpolating into URLs --- src/api.ts | 5 ++++- src/auth.ts | 5 +++++ src/cli.ts | 6 ++---- src/config.ts | 2 ++ tests/api.test.js | 4 ++++ 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/api.ts b/src/api.ts index 5ab0a37..05da5e0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,9 @@ -import { getConfig, DEFAULT_ZIP_CODE, DEFAULT_COUNTRY } from "./config.js"; +import { getConfig, DEFAULT_ZIP_CODE, DEFAULT_COUNTRY, VALID_COUNTRIES } from "./config.js"; export function getApiBase(country: string): string { + if (!(VALID_COUNTRIES as readonly string[]).includes(country)) { + throw new Error(`Unsupported country "${country}". Valid options: ${VALID_COUNTRIES.join(", ")}`); + } return `https://api.marktguru.${country}/api/v1`; } diff --git a/src/auth.ts b/src/auth.ts index 38787ad..f3aceff 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,3 +1,5 @@ +import { VALID_COUNTRIES } from "./config.js"; + interface ExtractOptions { log?: (message: string) => void; country?: string; @@ -113,6 +115,9 @@ async function validateKey(apiKey: string, apiBase: string): Promise { export async function extractApiKey(options: ExtractOptions = {}): Promise { const log = options.log; const country = options.country ?? "at"; + if (!(VALID_COUNTRIES as readonly string[]).includes(country)) { + throw new Error(`Unsupported country "${country}". Valid options: ${VALID_COUNTRIES.join(", ")}`); + } const baseUrl = `https://www.marktguru.${country}`; const apiBase = `https://api.marktguru.${country}/api/v1`; const headers = await maybeGetHeaders(); diff --git a/src/cli.ts b/src/cli.ts index f6e0b12..1413d80 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ import { Command, InvalidArgumentError } from "commander"; import { login } from "./commands/login.js"; import { searchBuildCommand, searchRawCommand } from "./commands/search.js"; -import { getConfig, saveConfig, DEFAULT_ZIP_CODE, DEFAULT_COUNTRY } from "./config.js"; +import { getConfig, saveConfig, DEFAULT_ZIP_CODE, DEFAULT_COUNTRY, VALID_COUNTRIES } from "./config.js"; import { QUERY_SYNTAX_HELP } from "./query.js"; const program = new Command(); @@ -107,15 +107,13 @@ program } }); -const VALID_COUNTRIES = ["at", "de"]; - program .command("set-country ") .description("Set default country for searches (at, de)") .option("-j, --json", "Output JSON") .action(async (code: string, options) => { const normalized = code.toLowerCase(); - if (!VALID_COUNTRIES.includes(normalized)) { + if (!(VALID_COUNTRIES as readonly string[]).includes(normalized)) { console.error(`Error: Invalid country "${code}". Valid options: ${VALID_COUNTRIES.join(", ")}`); process.exit(1); } diff --git a/src/config.ts b/src/config.ts index 04b768d..42a1fb7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,8 @@ export interface Config { export const DEFAULT_ZIP_CODE = "1010"; // Vienna export const DEFAULT_COUNTRY = "at"; +export const VALID_COUNTRIES = ["at", "de"] as const; +export type Country = (typeof VALID_COUNTRIES)[number]; const CONFIG_DIR = join(homedir(), ".marktguru"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); diff --git a/tests/api.test.js b/tests/api.test.js index 08122b9..24df03a 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -9,3 +9,7 @@ test("getApiBase returns correct URL for AT", () => { test("getApiBase returns correct URL for DE", () => { assert.equal(getApiBase("de"), "https://api.marktguru.de/api/v1"); }); + +test("getApiBase throws on unsupported country", () => { + assert.throws(() => getApiBase("de.evil.com"), /Unsupported country/); +}); From 37840bd4384406030ffdcff1f1e0fc5996031acf Mon Sep 17 00:00:00 2001 From: udondan Date: Sat, 30 May 2026 10:57:52 +0200 Subject: [PATCH 13/13] chore: use fr as unsupported country test case instead of de.evil.com --- tests/api.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api.test.js b/tests/api.test.js index 24df03a..6f25342 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -11,5 +11,5 @@ test("getApiBase returns correct URL for DE", () => { }); test("getApiBase throws on unsupported country", () => { - assert.throws(() => getApiBase("de.evil.com"), /Unsupported country/); + assert.throws(() => getApiBase("fr"), /Unsupported country/); });