diff --git a/CLAUDE.md b/CLAUDE.md index a07e299..a10d7ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,16 @@ # kronan-cli -CLI tool for Kronan.is grocery store. Uses Bun runtime. +CLI tool for Krónan.is grocery store. Uses Bun runtime. + +## Authentication + +The Krónan Public API uses AccessToken authentication. You need to create an access token at: +https://kronan.is/adgangur/adgangslyklar + +Then save it using the CLI: +```bash +kronan token +``` ## Development @@ -15,26 +25,54 @@ CLI tool for Kronan.is grocery store. Uses Bun runtime. ## Architecture - `src/index.ts` — CLI entry point, command routing, flag parsing -- `src/auth.ts` — AWS Cognito custom auth with Rafraen skilriki (Icelandic e-ID) -- `src/api.ts` — Kronan backend API client (`https://backend.kronan.is/api/`) +- `src/auth.ts` — AccessToken storage and management +- `src/api.ts` — Krónan Public API client (`https://api.kronan.is/api/v1`) - `src/commands/` — Individual command implementations ## Key conventions - Use Bun APIs: `Bun.file`, `Bun.write`, `bun:test` - All commands must support `--json` flag for structured AI-agent output -- Auth tokens stored in `~/.kronan/tokens.json` -- Cart endpoints require `Customer-Group-Id` header (fetched dynamically) -- API auth header format: `Authorization: CognitoJWT {idToken}` +- Auth tokens stored in `~/.kronan/token` +- API auth header format: `Authorization: AccessToken {token}` ## API endpoints -- Product search: `POST /api/products/raw-search/?with_detail=true` -- Cart view: `GET /api/smart-checkouts/default/` -- Cart add: `POST /api/smart-checkouts/default/lines/` -- Cart update: `PATCH /api/smart-checkouts/default/lines/{id}/` -- Cart remove: `DELETE /api/smart-checkouts/default/lines/{id}/` -- Orders: `GET /api/orders/` -- Order detail: `GET /api/orders/{id}/` -- User profile: `GET /api/users/me/` -- Customer groups: `GET /api/customer_groups/` +### Me +- `GET /api/v1/me/` — Current identity (user or customer group) + +### Products +- `POST /api/v1/products/search/` — Search products +- `GET /api/v1/products/{sku}/` — Get product details + +### Categories +- `GET /api/v1/categories/` — List category tree +- `GET /api/v1/categories/{slug}/products/` — Get category products + +### Orders +- `GET /api/v1/orders/` — List orders +- `GET /api/v1/orders/{token}/` — Get order details +- `POST /api/v1/orders/{token}/delete-lines/` — Delete order lines +- `POST /api/v1/orders/{token}/lower-quantity-lines/` — Lower line quantities +- `POST /api/v1/orders/{token}/lines-toggle-substitution/` — Toggle substitution + +### Checkout +- `GET /api/v1/checkout/` — Get active checkout +- `POST /api/v1/checkout/lines/` — Add or replace checkout lines + +### Product Lists +- `GET /api/v1/product-lists/` — List product lists +- `POST /api/v1/product-lists/` — Create product list +- `GET /api/v1/product-lists/{token}/` — Get product list details +- `PATCH /api/v1/product-lists/{token}/` — Update product list +- `DELETE /api/v1/product-lists/{token}/` — Delete product list +- `POST /api/v1/product-lists/{token}/update-item/` — Add/update item + +### Shopping Notes +- `GET /api/v1/shopping-notes/` — Get shopping note +- `POST /api/v1/shopping-notes/add-line/` — Add note line +- `PATCH /api/v1/shopping-notes/change-line/` — Update note line +- `DELETE /api/v1/shopping-notes/delete-line/` — Delete note line + +### Purchase Stats +- `GET /api/v1/product-purchase-stats/` — List purchase history diff --git a/README.md b/README.md index 1dab957..e57e792 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # kronan-cli -CLI for [Kronan.is](https://www.kronan.is) -- Iceland's grocery store. Search products, manage your cart, and view order history from the terminal. Built with [Bun](https://bun.sh) and designed to be used by AI agents. +CLI for [Krónan.is](https://www.kronan.is) -- Iceland's grocery store. Search products, manage your cart, and view order history from the terminal. Built with [Bun](https://bun.sh) and designed to be used by AI agents. + +Uses the new Krónan Public API (https://api.kronan.is/api/v1/). ## Install @@ -29,31 +31,41 @@ bun run src/index.ts help ## Authentication -Kronan uses **Rafraen skilriki** (Iceland's SIM-based electronic ID) for login. You need an Icelandic phone number with Rafraen skilriki enabled. +Krónan's Public API uses **AccessToken** authentication. You need to create an access token from your Krónan account settings. + +1. Go to https://kronan.is/adgangur/adgangslyklar +2. Create a new access token +3. Save it with the CLI: ```bash -kronan login XXXXXX # Sends auth request to your phone -kronan status # Check if you're logged in -kronan logout # Clear tokens +kronan token +kronan status # Check if token is valid +kronan logout # Clear token ``` -Tokens are stored in `~/.kronan/tokens.json` and auto-refresh when expired. +Tokens are stored in `~/.kronan/token`. + +**Note:** You must have a Krónan account with Auðkenni (Icelandic e-ID) login to create access tokens. ## Usage ``` +kronan token Save access token +kronan status Check authentication status +kronan logout Clear stored token + kronan search Search for products kronan product Product details by SKU kronan cart View cart kronan cart add [qty] Add item to cart -kronan cart update Update line quantity -kronan cart remove Remove line from cart +kronan cart clear Clear cart kronan orders Order history -kronan order Specific order details +kronan order Specific order details -kronan me User profile (⚠️ outputs PII, see below) +kronan lists View product lists +kronan me Show current identity ``` All commands support `--json` for structured output. @@ -85,22 +97,29 @@ kronan cart --json ``` src/ index.ts CLI entry point and command routing - auth.ts AWS Cognito auth (Rafraen skilriki flow) - api.ts Kronan backend API client + auth.ts AccessToken storage and management + api.ts Krónan Public API client commands/ - login.ts login, logout, status + login.ts token, logout, status search.ts product search and detail orders.ts order history cart.ts cart management ``` +## API Documentation + +The Krónan Public API is documented at: +- Swagger UI: https://api.kronan.is/api/v1/schema/swagger-ui/ +- ReDoc: https://api.kronan.is/api/v1/schema/redoc/ + ## Privacy -`kronan me` outputs your full user profile including **personally identifiable information (PII)** such as your name, phone number, and Icelandic national ID number (kennitala/SSN). Be careful when sharing this output — avoid pasting it in public channels, issue trackers, or LLM conversations that may be logged. +`kronan me` outputs your identity information. Be careful when sharing this output — avoid pasting it in public channels, issue trackers, or LLM conversations that may be logged. ## Requirements -- Icelandic phone number with Rafraen skilriki for authentication +- Krónan account with Auðkenni (Icelandic e-ID) login +- Access token from https://kronan.is/adgangur/adgangslyklar - [GitHub CLI](https://cli.github.com) for installation (or [Bun](https://bun.sh) if running from source) ## License diff --git a/SKILL.md b/SKILL.md index dbff1b3..ce15f9a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,16 +2,16 @@ name: kronan-cli description: > Search Krónan for groceries and prices, get SKU numbers, add to or change - shopping cart, and view past order history. Leita að verði á matvörum í - Krónunni, vinna með innkaupakörfu (bæta í eða breyta), og skoða gamlar - pantanir. -version: 0.1.0 + shopping cart, and view past order history using the official Krónan Public API. + Leita að verði á matvörum í Krónunni, vinna með innkaupakörfu (bæta í eða breyta), + og skoða gamlar pantanir. +version: 0.2.0 requires: binaries: - gh # GitHub CLI — required for install - bun # Bun runtime — only needed if building from source paths: - - ~/.kronan/tokens.json # Cognito JWT tokens (created at login) + - ~/.kronan/token # AccessToken for Public API authentication metadata: openclaw: homepage: https://github.com/arnif/kronan-cli @@ -20,12 +20,13 @@ metadata: # kronan-cli -CLI tool for shopping at [Kronan.is](https://www.kronan.is), Iceland's grocery store chain. Designed for both humans and AI agents. +CLI tool for shopping at [Krónan.is](https://www.kronan.is), Iceland's grocery store chain. Uses the official Krónan Public API. Designed for both humans and AI agents. ## Prerequisites - [GitHub CLI](https://cli.github.com) (`gh`) — required for the install command -- An Icelandic phone number with **Rafraen skilriki** (SIM-based electronic ID) enabled — required for authentication +- A Krónan account with **Auðkenni** (Icelandic e-ID) login +- An Access Token from https://kronan.is/adgangur/adgangslyklar ## Install @@ -46,21 +47,28 @@ mv kronan ~/.local/bin/ ## Security and privacy - **Install script**: `install.sh` executes on your machine and downloads a binary. Audit the [repository](https://github.com/arnif/kronan-cli) and the script before running. -- **Token storage**: Auth tokens (Cognito JWTs) are stored at `~/.kronan/tokens.json`. These contain session credentials tied to your identity. Ensure the file is only readable by your user (`chmod 600 ~/.kronan/tokens.json`). -- **PII**: `kronan me` outputs your full user profile including name, phone number, and Icelandic national ID number (kennitala). Do not share this output in public channels or logged LLM conversations. -- **Credentials**: The login flow sends an auth request to your phone via Rafraen skilriki. No passwords are transmitted or stored — authentication is SIM-based. +- **Token storage**: Access tokens are stored at `~/.kronan/token`. These are credentials for the Krónan Public API. Ensure the file is only readable by your user (`chmod 600 ~/.kronan/token`). +- **PII**: `kronan me` outputs your identity information (name and type - user or customer group). Be careful when sharing this output. +- **API Access**: Tokens are created in your Krónan account settings and can be revoked at any time at https://kronan.is/adgangur/adgangslyklar ## Authentication +First, create an access token: +1. Go to https://kronan.is/adgangur/adgangslyklar +2. Log in with Auðkenni (Icelandic e-ID) +3. Create a new access token + +Then save it with the CLI: + ```bash -kronan login +kronan token ``` -You will be prompted to confirm on your phone. Tokens are stored locally in `~/.kronan/tokens.json` and refresh automatically. +The token will be validated and saved locally. ```bash -kronan logout # Clear stored tokens -kronan status # Check login status +kronan logout # Clear stored token +kronan status # Check authentication status ``` ## Commands @@ -84,9 +92,8 @@ kronan product 02500188 --json ```bash kronan cart # View cart -kronan cart add [quantity] # Add item -kronan cart update # Update quantity -kronan cart remove # Remove item +kronan cart add [quantity] # Add item to cart +kronan cart clear # Clear all items from cart ``` ### Order history @@ -94,13 +101,20 @@ kronan cart remove # Remove item ```bash kronan orders # Recent orders kronan orders --json # JSON output for parsing -kronan order # Specific order details +kronan order # Specific order details (use order token, not ID) +``` + +### Product lists + +```bash +kronan lists # View saved product lists +kronan lists --json ``` -### User profile +### User identity ```bash -kronan me # ⚠️ Outputs PII (name, phone, kennitala) +kronan me # Show current identity (user or customer group) kronan me --json ``` @@ -108,9 +122,9 @@ kronan me --json All commands support `--json` for structured output. This makes kronan-cli suitable as a tool for AI agents managing grocery shopping. -**Important:** Commands that change state (`cart add`, `cart update`, `cart remove`, `login`) can modify the user's real shopping cart or initiate authentication. Agents **must ask for explicit user confirmation** before running any state-changing command. +**Important:** Commands that change state (`cart add`, `cart clear`) can modify the user's real shopping cart. Agents **must ask for explicit user confirmation** before running any state-changing command. -Read-only commands (`search`, `product`, `orders`, `order`, `cart` (view), `me`, `status`) are safe to run without confirmation. +Read-only commands (`search`, `product`, `orders`, `order`, `cart` (view), `lists`, `me`, `status`) are safe to run without confirmation. Example agent workflow: @@ -146,24 +160,38 @@ An agent can analyze order history to find frequently purchased items and auto-p | `--page ` | Page number (search) | | `--limit ` | Results per page | | `--offset ` | Offset for pagination (orders) | -| `--store ` | Store external ID (search, default: 159) | ## API Reference -The CLI wraps the Kronan backend API at `https://backend.kronan.is/api/`. Key endpoints: +The CLI uses the official Krónan Public API at `https://api.kronan.is/api/v1/`. + +API Documentation: +- Swagger UI: https://api.kronan.is/api/v1/schema/swagger-ui/ +- ReDoc: https://api.kronan.is/api/v1/schema/redoc/ + +Key endpoints: | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| -| `/products/raw-search/` | POST | No | Product search | -| `/products/{sku}/` | GET | No | Product detail | -| `/smart-checkouts/default/` | GET | Yes | View cart | -| `/smart-checkouts/default/lines/` | POST | Yes | Add to cart | -| `/smart-checkouts/default/lines/{id}/` | PATCH | Yes | Update cart line | -| `/smart-checkouts/default/lines/{id}/` | DELETE | Yes | Remove from cart | +| `/products/search/` | POST | Yes | Product search | +| `/products/{sku}/` | GET | Yes | Product detail | +| `/checkout/` | GET | Yes | View checkout/cart | +| `/checkout/lines/` | POST | Yes | Add/replace checkout lines | | `/orders/` | GET | Yes | Order history | -| `/users/me/` | GET | Yes | User profile | -| `/customer_groups/` | GET | Yes | Customer groups | +| `/me/` | GET | Yes | Current identity | +| `/product-lists/` | GET | Yes | Product lists | +| `/shopping-notes/` | GET | Yes | Shopping notes | +| `/product-purchase-stats/` | GET | Yes | Purchase statistics | + +Auth header format: `Authorization: AccessToken {token}` + +## Migration from v0.1.x + +If you were using the previous version with Cognito authentication: -Auth header format: `Authorization: CognitoJWT {idToken}` +1. Remove old tokens: `rm ~/.kronan/tokens.json` +2. Get a new access token from https://kronan.is/adgangur/adgangslyklar +3. Run `kronan token ` +4. Update any scripts using `kronan login` to use `kronan token` instead -Cart endpoints also require: `Customer-Group-Id: {groupId}` +Note: Order IDs in the new API are tokens (UUIDs), not numeric IDs. diff --git a/src/api.test.ts b/src/api.test.ts index f95d71a..cb9682a 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,5 +1,7 @@ import { describe, expect, mock, test } from "bun:test"; -import { getCustomerGroupId, getOrder, searchProducts } from "./api.ts"; +import { getMe, getOrder, searchProducts } from "./api.ts"; + +const fakeToken = { token: "test-access-token" }; // --- searchProducts --- @@ -13,23 +15,22 @@ describe("searchProducts", () => { page: 1, pageCount: 1, hasNextPage: false, - results: { - hits: [ - { - name: "Nýmjólk", - sku: "100224198", - categoryId: 1, - thumbnail: "", - price: 199, - isPublished: true, - inProductSelection: true, - temporaryShortage: false, - priceInfo: "199 kr.", - chargedByWeight: false, - baseComparisonUnit: "l", - }, - ], - }, + hits: [ + { + name: "Nýmjólk", + sku: "100224198", + thumbnail: "", + price: 199, + discountedPrice: 199, + discountPercent: 0, + onSale: false, + priceInfo: "199 kr.", + chargedByWeight: false, + pricePerKilo: null, + baseComparisonUnit: "l", + temporaryShortage: false, + }, + ], }), { status: 200 }, ), @@ -37,32 +38,32 @@ describe("searchProducts", () => { ); const originalFetch = globalThis.fetch; - globalThis.fetch = mockFetch as unknown as unknown as typeof fetch; + globalThis.fetch = mockFetch as unknown as typeof fetch; try { - const result = await searchProducts("mjolk"); + const result = await searchProducts(fakeToken, "mjolk"); expect(mockFetch).toHaveBeenCalledTimes(1); const call = mockFetch.mock.calls[0]!; const [url, options] = call as unknown as [string, RequestInit]; - expect(url).toBe( - "https://backend.kronan.is/api/products/raw-search/?with_detail=true", - ); + expect(url).toBe("https://api.kronan.is/api/v1/products/search/"); expect(options.method).toBe("POST"); + expect(options.headers).toMatchObject({ + "Content-Type": "application/json", + Authorization: "AccessToken test-access-token", + }); const body = JSON.parse(options.body as string); expect(body.query).toBe("mjolk"); expect(body.page).toBe(1); - expect(body.pageSize).toBe(20); - expect(body.storeExtIds).toEqual(["159"]); expect(result.count).toBe(1); - expect(result.results.hits[0]!.name).toBe("Nýmjólk"); + expect(result.hits[0]!.name).toBe("Nýmjólk"); } finally { globalThis.fetch = originalFetch; } }); - test("passes custom page and pageSize", async () => { + test("passes custom page", async () => { const mockFetch = mock(() => Promise.resolve( new Response( @@ -71,7 +72,7 @@ describe("searchProducts", () => { page: 3, pageCount: 5, hasNextPage: true, - results: { hits: [] }, + hits: [], }), { status: 200 }, ), @@ -79,16 +80,15 @@ describe("searchProducts", () => { ); const originalFetch = globalThis.fetch; - globalThis.fetch = mockFetch as unknown as unknown as typeof fetch; + globalThis.fetch = mockFetch as unknown as typeof fetch; try { - await searchProducts("test", { page: 3, pageSize: 5 }); + await searchProducts(fakeToken, "test", { page: 3 }); const call = mockFetch.mock.calls[0]!; const [, options] = call as unknown as [string, RequestInit]; const body = JSON.parse(options.body as string); expect(body.page).toBe(3); - expect(body.pageSize).toBe(5); } finally { globalThis.fetch = originalFetch; } @@ -107,30 +107,24 @@ describe("searchProducts", () => { globalThis.fetch = mockFetch as unknown as typeof fetch; try { - await expect(searchProducts("test")).rejects.toThrow("API error 400"); + await expect(searchProducts(fakeToken, "test")).rejects.toThrow( + "API error 400", + ); } finally { globalThis.fetch = originalFetch; } }); }); -// --- getCustomerGroupId --- - -describe("getCustomerGroupId", () => { - const fakeTokens = { - accessToken: "access", - idToken: "id", - refreshToken: "refresh", - expiresAt: Date.now() + 3600000, - }; +// --- getMe --- - test("returns first group id", async () => { +describe("getMe", () => { + test("returns user identity", async () => { const mockFetch = mock(() => Promise.resolve( - new Response( - JSON.stringify([{ id: 12345, name: "test-group", members: [] }]), - { status: 200 }, - ), + new Response(JSON.stringify({ type: "user", name: "Test User" }), { + status: 200, + }), ), ); @@ -138,42 +132,56 @@ describe("getCustomerGroupId", () => { globalThis.fetch = mockFetch as unknown as typeof fetch; try { - const id = await getCustomerGroupId(fakeTokens); - expect(id).toBe(12345); + const me = await getMe(fakeToken); + expect(me.type).toBe("user"); + expect(me.name).toBe("Test User"); + + const call = mockFetch.mock.calls[0]!; + const [url] = call as unknown as [string, RequestInit]; + expect(url).toBe("https://api.kronan.is/api/v1/me/"); } finally { globalThis.fetch = originalFetch; } }); - test("throws when no groups found", async () => { + test("returns customer group identity", async () => { const mockFetch = mock(() => - Promise.resolve(new Response(JSON.stringify([]), { status: 200 })), + Promise.resolve( + new Response( + JSON.stringify({ type: "customer_group", name: "Test Group" }), + { status: 200 }, + ), + ), ); const originalFetch = globalThis.fetch; globalThis.fetch = mockFetch as unknown as typeof fetch; try { - await expect(getCustomerGroupId(fakeTokens)).rejects.toThrow( - "No customer groups found", - ); + const me = await getMe(fakeToken); + expect(me.type).toBe("customer_group"); + expect(me.name).toBe("Test Group"); } finally { globalThis.fetch = originalFetch; } }); - test("propagates API errors", async () => { + test("handles array response (backward compatibility)", async () => { const mockFetch = mock(() => - Promise.resolve(new Response("Unauthorized", { status: 401 })), + Promise.resolve( + new Response(JSON.stringify([{ type: "user", name: "Test User" }]), { + status: 200, + }), + ), ); const originalFetch = globalThis.fetch; globalThis.fetch = mockFetch as unknown as typeof fetch; try { - await expect(getCustomerGroupId(fakeTokens)).rejects.toThrow( - "API error 401", - ); + const me = await getMe(fakeToken); + expect(me.type).toBe("user"); + expect(me.name).toBe("Test User"); } finally { globalThis.fetch = originalFetch; } @@ -183,23 +191,16 @@ describe("getCustomerGroupId", () => { // --- getOrder --- describe("getOrder", () => { - const fakeTokens = { - accessToken: "access", - idToken: "id", - refreshToken: "refresh", - expiresAt: Date.now() + 3600000, - }; - - test("fetches order by ID directly", async () => { + test("fetches order by token", async () => { const fakeOrder = { - id: 727555, - orderId: "ORD-727555", - token: "abc123", + token: "abc123-def456", created: "2025-01-01T00:00:00Z", - displayDate: "2025-01-01T00:00:00Z", - status: "delivered", - totalNetAmount: "5000", - discountAmount: "0", + status: "fulfilled", + type: "delivery", + total: 5000, + discount: 0, + deliveryDate: "2025-01-02", + allowAlterOrderLines: false, lines: [], }; @@ -211,14 +212,14 @@ describe("getOrder", () => { globalThis.fetch = mockFetch as unknown as typeof fetch; try { - const order = await getOrder(fakeTokens, "727555"); + const order = await getOrder(fakeToken, "abc123-def456"); expect(mockFetch).toHaveBeenCalledTimes(1); const call = mockFetch.mock.calls[0]!; const [url] = call as unknown as [string, RequestInit]; - expect(url).toBe("https://backend.kronan.is/api/orders/727555/"); - expect(order.id).toBe(727555); - expect(order.status).toBe("delivered"); + expect(url).toBe("https://api.kronan.is/api/v1/orders/abc123-def456/"); + expect(order.token).toBe("abc123-def456"); + expect(order.status).toBe("fulfilled"); } finally { globalThis.fetch = originalFetch; } @@ -235,7 +236,7 @@ describe("getOrder", () => { globalThis.fetch = mockFetch as unknown as typeof fetch; try { - await expect(getOrder(fakeTokens, "999999")).rejects.toThrow( + await expect(getOrder(fakeToken, "notfound")).rejects.toThrow( "API error 404", ); } finally { diff --git a/src/api.ts b/src/api.ts index 0b9205e..e5631ab 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,144 +1,271 @@ /** - * Krónan Backend API Client + * Krónan Public API Client * - * Base URL: https://backend.kronan.is/api/ - * Auth: CognitoJWT {idToken} + * Base URL: https://api.kronan.is/api/v1 + * Auth: AccessToken {token} * * Key endpoints: - * - POST /api/products/raw-search/?with_detail=true (product search) - * - GET /api/products/{sku}/ (product detail by SKU) - * - GET /api/orders/ (order history) - * - GET /api/users/me/ (user profile) - * - GET /api/stores/ (store listing) - * - GET /api/store-categories/ (category tree) - * - GET /api/smart-checkouts/default/ (view cart) - * - POST /api/smart-checkouts/default/lines/ (add to cart) - * - PATCH /api/smart-checkouts/default/lines/{lineId}/ (update cart line) - * - DELETE /api/smart-checkouts/default/lines/{lineId}/ (remove from cart) - * - GET /api/customer_groups/ (customer groups) - * - GET /api/product_list/ (shopping lists) - * - POST /api/product_list/ (create shopping list) + * - GET /api/v1/me/ (current identity) + * - POST /api/v1/products/search/ (product search) + * - GET /api/v1/products/{sku}/ (product detail) + * - GET /api/v1/categories/ (category tree) + * - GET /api/v1/categories/{slug}/products/ (category products) + * - GET /api/v1/orders/ (order history) + * - GET /api/v1/orders/{token}/ (order detail) + * - POST /api/v1/orders/{token}/delete-lines/ (delete order lines) + * - POST /api/v1/orders/{token}/lower-quantity-lines/ (lower line quantity) + * - POST /api/v1/orders/{token}/lines-toggle-substitution/ (toggle substitution) + * - GET /api/v1/checkout/ (view checkout) + * - POST /api/v1/checkout/lines/ (add/replace checkout lines) + * - GET /api/v1/product-lists/ (product lists) + * - POST /api/v1/product-lists/ (create product list) + * - GET /api/v1/product-lists/{token}/ (product list detail) + * - PATCH /api/v1/product-lists/{token}/ (update product list) + * - DELETE /api/v1/product-lists/{token}/ (delete product list) + * - POST /api/v1/product-lists/{token}/update-item/ (add/update item) + * - GET /api/v1/shopping-notes/ (shopping notes) + * - POST /api/v1/shopping-notes/add-line/ (add note line) + * - PATCH /api/v1/shopping-notes/change-line/ (update note line) + * - DELETE /api/v1/shopping-notes/delete-line/ (delete note line) + * - GET /api/v1/product-purchase-stats/ (purchase history) */ -import type { AuthTokens } from "./auth.ts"; +import type { AuthToken } from "./auth.ts"; -const BASE_URL = "https://backend.kronan.is/api"; - -// Default store extId - Krónan Granda (Reykjavik, supports picking) -const DEFAULT_STORE_EXT_ID = "159"; +const BASE_URL = "https://api.kronan.is/api/v1"; // --- Types --- -export interface Product { +export interface PublicMe { + type: "user" | "customer_group"; name: string; +} + +export interface PublicProduct { sku: string; - categoryId: number; + name: string; thumbnail: string; price: number; - isPublished: boolean; - inProductSelection: boolean; - temporaryShortage: boolean; - priceInfo: string; + discountedPrice: number; + discountPercent: number; + onSale: boolean; + priceInfo: string | null; chargedByWeight: boolean; - baseComparisonUnit: string; - detail?: { - discountedPrice: number; - discountPercent: number; - onSale: boolean; - tags: Array<{ - slug: string; - name: string; - image: string; - showOnProductCard: boolean; - }>; - }; + pricePerKilo: number | null; + baseComparisonUnit: string | null; + temporaryShortage: boolean; +} + +export interface PublicProductDetail extends PublicProduct { + description: string; + image: string; + qtyPerBaseCompUnit: number | null; + countryOfOrigin: string | null; + tags: PublicProductTag[]; +} + +export interface PublicProductTag { + slug: string; + name: string; + image: string; + showOnProductCard: boolean; +} + +export interface PublicSearchHit extends PublicProduct { + // Same as PublicProduct } -export interface SearchResult { +export interface PublicPaginatedSearchResult { count: number; page: number; pageCount: number; hasNextPage: boolean; - results: { - hits: Product[]; - }; + hits: PublicSearchHit[]; } -export interface OrderLine { - id: number; - productName: string; - productSku: string; - quantity: number; - quantityFulfilled: number; - unitPriceNetAmount: string; - product: { - id: number; - name: string; - sku: string; - price: number; - thumbnail: string; - category: { - id: number; - name: string; - slug: string; - }; - }; +export interface PublicCategory { + slug: string; + name: string; + backgroundImage: string | null; + icon: string | null; + children: PublicCategoryLevel1[]; } -export interface Order { - id: number; - orderId: string; +export interface PublicCategoryLevel1 { + slug: string; + name: string; + children: PublicCategoryLevel2[]; +} + +export interface PublicCategoryLevel2 { + slug: string; + name: string; +} + +export interface PublicCategoryProductList { + name: string; + count: number; + page: number; + pageCount: number; + hasNextPage: boolean; + products: PublicProduct[]; +} + +export interface PublicOrderSummary { token: string; created: string; - displayDate: string; - status: string; - totalNetAmount: string; - discountAmount: string; - lines: OrderLine[]; + status: OrderStatus; + type: OrderType | null; + total: number; + discount: number; + deliveryDate: string | null; + allowAlterOrderLines: boolean; } -export interface OrdersResponse { +export type OrderStatus = + | "draft" + | "unfulfilled" + | "partially fulfilled" + | "fulfilled" + | "canceled" + | "cloned"; + +export type OrderType = + | "delivery" + | "pickup" + | "scan_n_go" + | "digital" + | "digital_card_batch" + | "dropp" + | "navision"; + +export interface PublicOrder extends PublicOrderSummary { + lines: PublicOrderLine[]; +} + +export interface PublicOrderLine { + id: number; + productName: string; + sku: string; + quantity: number; + quantityOrdered: number; + unitPrice: number; + substitution: boolean; + substitutionForLineId: number | null; + isMutable: boolean; + isLastChance: boolean; + thumbnail: string; + total: number; +} + +export interface PaginatedPublicOrderListResponse { count: number; next: string | null; previous: string | null; - results: Order[]; + results: PublicOrderSummary[]; } -export interface UserProfile { +export interface PublicCheckout { + token: string; + lines: PublicCheckoutLine[]; + total: number; + subtotal: number; + baggingFee: number; + serviceFee: number; + shippingFee: number; + shippingFeeCutoff: number; +} + +export interface PublicCheckoutLine { id: number; - email: string; - phoneNumber: string; - name: string; - ssn: string; - defaultShippingAddress: number; - defaultBillingAddress: number; - mostLikelyPickingStoreId: number; - mostLikelyPickingStoreIds: number[]; - bagless: boolean; + quantity: number; + product: PublicProduct; + total: number; + price: number; substitution: boolean; } -export interface Store { +export interface PublicProductList { id: number; - extId: string; name: string; - displayName: string; - address: string; - city: string; - postalCode: string; - hasPicking: boolean; - hasPickup: boolean; - isScanNGo: boolean; - isWholesale: boolean; + token: string; + description: string; +} + +export interface PublicProductListWithCount extends PublicProductList { + hasProducts: boolean; } -export interface Category { +export interface PublicProductListDetail extends PublicProductList { + items: PublicProductListItem[]; +} + +export interface PublicProductListItem { id: number; - slug: string; + quantity: number; + product: PublicProduct; +} + +export interface PaginatedPublicProductListListResponse { + count: number; + next: string | null; + previous: string | null; + results: PublicProductListWithCount[]; +} + +export interface PublicShoppingNote { + token: string; name: string; - backgroundImage?: string; - icon?: string; - children?: Category[]; + lines: PublicShoppingNoteLine[]; +} + +export interface PublicShoppingNoteLine { + token: string; + text: string | null; + sku: string | null; + quantity: number; + completed: boolean; +} + +export interface PublicShoppingNoteLineArchived { + token: string; + text: string | null; + sku: string | null; + quantity: number; + completedAt: string; +} + +export interface PublicProductPurchaseStats { + id: number; + product: PublicProduct; + purchaseCount: number; + quantityPurchased: number; + averagePurchaseQuantity: number | null; + lastPurchaseQuantity: number; + averagePurchaseIntervalDays: number | null; + firstPurchaseDate: string | null; + lastPurchaseDate: string | null; + isIgnored: boolean; +} + +export interface PublicProductPurchaseStatsListResponse { + id: number; + product: PublicProduct; + purchaseCount: number; + quantityPurchased: number; + averagePurchaseQuantity: number | null; + lastPurchaseQuantity: number; + averagePurchaseIntervalDays: number | null; + firstPurchaseDate: string | null; + lastPurchaseDate: string | null; + isIgnored: boolean; +} + +export interface PaginatedPublicProductPurchaseStatsListResponse { + count: number; + next: string | null; + previous: string | null; + results: PublicProductPurchaseStatsListResponse[]; } // --- API Client --- @@ -148,30 +275,32 @@ async function apiRequest( options: { method?: string; body?: object; - tokens?: AuthTokens; + token?: AuthToken; params?: Record; - extraHeaders?: Record; } = {}, ): Promise { - const { method = "GET", body, tokens, params, extraHeaders } = options; + const { method = "GET", body, token, params } = options; let url = `${BASE_URL}${path}`; if (params) { - const searchParams = new URLSearchParams(params); - url += `?${searchParams.toString()}`; + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + searchParams.append(key, value); + } + } + const queryString = searchParams.toString(); + if (queryString) { + url += `?${queryString}`; + } } const headers: Record = { "Content-Type": "application/json", - Origin: "https://www.kronan.is", }; - if (tokens) { - headers.Authorization = `CognitoJWT ${tokens.idToken}`; - } - - if (extraHeaders) { - Object.assign(headers, extraHeaders); + if (token) { + headers.Authorization = `AccessToken ${token.token}`; } const response = await fetch(url, { @@ -185,48 +314,58 @@ async function apiRequest( let detail = text; try { const json = JSON.parse(text); - detail = json.detail || json.message || text; + detail = json.detail || json.message || JSON.stringify(json); } catch {} throw new Error(`API error ${response.status}: ${detail}`); } + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; } // --- Public API Functions --- +/** + * Get current identity (user or customer group). + */ +export async function getMe(token: AuthToken): Promise { + const result = await apiRequest("/me/", { token }); + // API can return either a single object or an array with one object + if (Array.isArray(result)) { + if (result.length === 0) { + throw new Error("Invalid or expired token"); + } + return result[0]!; + } + return result; +} + /** * Search for products. */ export async function searchProducts( + token: AuthToken, query: string, options: { page?: number; - pageSize?: number; - storeExtId?: string; - onlyInSelection?: boolean; sortBy?: string; + withDetail?: boolean; } = {}, -): Promise { - const { - page = 1, - pageSize = 20, - storeExtId = DEFAULT_STORE_EXT_ID, - onlyInSelection = false, - sortBy = "default", - } = options; - - return apiRequest("/products/raw-search/?with_detail=true", { +): Promise { + const { page = 1, sortBy = "default", withDetail = true } = options; + + return apiRequest("/products/search/", { method: "POST", + token, body: { query, - onlyInSelection, - onlyInSpecializedSelection: false, page, - pageSize, - storeExtIds: [storeExtId], - includeWholesale: false, sortBy, + withDetail, }, }); } @@ -235,226 +374,428 @@ export async function searchProducts( * Get product detail by SKU. */ export async function getProduct( + token: AuthToken, sku: string, - tokens?: AuthTokens, -): Promise { - return apiRequest(`/products/${sku}/`, { tokens }); +): Promise { + return apiRequest(`/products/${sku}/`, { token }); +} + +/** + * Get category tree. + */ +export async function getCategories( + token: AuthToken, +): Promise { + return apiRequest("/categories/", { token }); +} + +/** + * Get products in a category. + */ +export async function getCategoryProducts( + token: AuthToken, + slug: string, + page?: number, +): Promise { + const params: Record = {}; + if (page) { + params.page = String(page); + } + return apiRequest( + `/categories/${slug}/products/`, + { + token, + params, + }, + ); } /** * Get order history. */ export async function getOrders( - tokens: AuthTokens, - options: { limit?: number; offset?: number } = {}, -): Promise { - const { limit = 15, offset = 0 } = options; - return apiRequest("/orders/", { - tokens, - params: { - limit: String(limit), - offset: String(offset), - }, + token: AuthToken, + options: { + limit?: number; + offset?: number; + type?: OrderType; + } = {}, +): Promise { + const { limit = 15, offset = 0, type } = options; + const params: Record = { + limit: String(limit), + offset: String(offset), + }; + if (type) { + params.type = type; + } + return apiRequest("/orders/", { + token, + params, }); } /** - * Get a single order by ID. + * Get a single order by token. */ export async function getOrder( - tokens: AuthTokens, - orderId: string, -): Promise { - return apiRequest(`/orders/${orderId}/`, { tokens }); + token: AuthToken, + orderToken: string, +): Promise { + return apiRequest(`/orders/${orderToken}/`, { token }); } /** - * Get user profile. + * Delete lines from an order. */ -export async function getUserProfile(tokens: AuthTokens): Promise { - return apiRequest("/users/me/", { tokens }); +export async function deleteOrderLines( + token: AuthToken, + orderToken: string, + lineIds: number[], +): Promise { + return apiRequest(`/orders/${orderToken}/delete-lines/`, { + method: "POST", + token, + body: { lineIds }, + }); } /** - * Get all stores. + * Lower quantity of order lines. */ -export async function getStores(): Promise { - return apiRequest("/stores/"); +export async function lowerOrderLineQuantities( + token: AuthToken, + orderToken: string, + lineIds: number[], + quantity: number, +): Promise { + return apiRequest( + `/orders/${orderToken}/lower-quantity-lines/`, + { + method: "POST", + token, + body: { lineIds, quantity }, + }, + ); } /** - * Get category tree (for online store). + * Toggle substitution on order lines. */ -export async function getCategories(): Promise { - return apiRequest("/store-categories/"); +export async function toggleOrderLineSubstitution( + token: AuthToken, + orderToken: string, + lineIds: number[], +): Promise { + return apiRequest( + `/orders/${orderToken}/lines-toggle-substitution/`, + { + method: "POST", + token, + body: { lineIds }, + }, + ); } -// --- Cart Types --- +// --- Checkout API Functions --- -export interface CustomerGroup { - id: number; - name: string; - members: Array<{ - id: number; - name: string; - phoneNumber: string; - ssn: string; - isAdmin: boolean; - }>; +/** + * View the current checkout. + */ +export async function getCheckout( + token: AuthToken, +): Promise { + const result = await apiRequest( + "/checkout/", + { token }, + ); + // Handle both array and object responses + if (Array.isArray(result)) { + return result[0] ?? null; + } + return result ?? null; } -export interface CartLine { - id: number; - quantity: number; - sku: string; - product: { - id: number; - name: string; +/** + * Add or replace checkout lines. + */ +export async function addCheckoutLines( + token: AuthToken, + lines: Array<{ sku: string; - price: number; - thumbnail: string; - chargedByWeight: boolean; - category?: { - id: number; - name: string; - slug: string; - }; - }; + quantity: number; + substitution?: boolean; + }>, + replace = false, +): Promise { + return apiRequest("/checkout/lines/", { + method: "POST", + token, + body: { + lines: lines.map((l) => ({ + sku: l.sku, + quantity: l.quantity, + substitution: l.substitution ?? true, + })), + replace, + }, + }); } -export interface Cart { - id: number; - lines: CartLine[]; - totalPrice?: number; - [key: string]: any; +/** + * Replace all checkout lines. + */ +export async function replaceCheckoutLines( + token: AuthToken, + lines: Array<{ + sku: string; + quantity: number; + substitution?: boolean; + }>, +): Promise { + return addCheckoutLines(token, lines, true); } -// --- Cart API Functions --- +// --- Product Lists API Functions --- /** - * Get customer groups (needed for Customer-Group-Id header). + * Get product lists. */ -export async function getCustomerGroups( - tokens: AuthTokens, -): Promise { - return apiRequest("/customer_groups/", { tokens }); +export async function getProductLists( + token: AuthToken, + options: { limit?: number; offset?: number } = {}, +): Promise { + const { limit = 15, offset = 0 } = options; + return apiRequest("/product-lists/", { + token, + params: { + limit: String(limit), + offset: String(offset), + }, + }); } /** - * Get the customer group ID (first group). - * Throws if no customer groups are found. + * Create a product list. */ -export async function getCustomerGroupId(tokens: AuthTokens): Promise { - const groups = await getCustomerGroups(tokens); - if (groups.length === 0) { - throw new Error( - "No customer groups found. Your account may not be part of a group.", - ); - } - return groups[0]!.id; +export async function createProductList( + token: AuthToken, + name: string, + description?: string, +): Promise { + return apiRequest("/product-lists/", { + method: "POST", + token, + body: { name, description }, + }); } -function cartHeaders(customerGroupId: number): Record { - return { "Customer-Group-Id": String(customerGroupId) }; +/** + * Get product list details. + */ +export async function getProductList( + token: AuthToken, + listToken: string, +): Promise { + return apiRequest(`/product-lists/${listToken}/`, { + token, + }); } /** - * View the current cart (smart checkout). + * Update a product list. */ -export async function getCart( - tokens: AuthTokens, - customerGroupId: number, -): Promise { - return apiRequest("/smart-checkouts/default/", { - tokens, - extraHeaders: cartHeaders(customerGroupId), +export async function updateProductList( + token: AuthToken, + listToken: string, + updates: { name?: string; description?: string }, +): Promise { + return apiRequest(`/product-lists/${listToken}/`, { + method: "PATCH", + token, + body: updates, }); } /** - * Add items to cart. + * Delete a product list. */ -export async function addToCart( - tokens: AuthTokens, - customerGroupId: number, - lines: Array<{ sku: string; quantity: number; source?: string }>, -): Promise { - return apiRequest("/smart-checkouts/default/lines/", { - method: "POST", - tokens, - extraHeaders: cartHeaders(customerGroupId), - body: { - lines: lines.map((l) => ({ - quantity: l.quantity, - sku: l.sku, - source: l.source || "search", - })), - force: false, - }, +export async function deleteProductList( + token: AuthToken, + listToken: string, +): Promise { + return apiRequest(`/product-lists/${listToken}/`, { + method: "DELETE", + token, }); } /** - * Update a cart line quantity. + * Add or update item in product list. */ -export async function updateCartLine( - tokens: AuthTokens, - customerGroupId: number, - lineId: number, +export async function updateProductListItem( + token: AuthToken, + listToken: string, + sku: string, quantity: number, -): Promise { - return apiRequest(`/smart-checkouts/default/lines/${lineId}/`, { +): Promise { + return apiRequest( + `/product-lists/${listToken}/update-item/`, + { + method: "POST", + token, + body: { sku, quantity }, + }, + ); +} + +/** + * Delete all items from product list. + */ +export async function deleteAllProductListItems( + token: AuthToken, + listToken: string, +): Promise { + return apiRequest(`/product-lists/${listToken}/delete-all-items/`, { + method: "DELETE", + token, + }); +} + +// --- Shopping Notes API Functions --- + +/** + * Get shopping note. + */ +export async function getShoppingNote( + token: AuthToken, +): Promise { + const result = await apiRequest("/shopping-notes/", { + token, + }); + return result[0]!; +} + +/** + * Add line to shopping note. + */ +export async function addShoppingNoteLine( + token: AuthToken, + options: { text?: string; sku?: string; quantity?: number }, +): Promise { + return apiRequest("/shopping-notes/add-line/", { + method: "POST", + token, + body: options, + }); +} + +/** + * Update shopping note line. + */ +export async function updateShoppingNoteLine( + token: AuthToken, + lineToken: string, + updates: { text?: string; quantity?: number }, +): Promise { + return apiRequest("/shopping-notes/change-line/", { method: "PATCH", - tokens, - extraHeaders: cartHeaders(customerGroupId), - body: { quantity }, + token, + body: { token: lineToken, ...updates }, }); } /** - * Remove a line from the cart. + * Delete shopping note line. */ -export async function removeCartLine( - tokens: AuthTokens, - customerGroupId: number, - lineId: number, +export async function deleteShoppingNoteLine( + token: AuthToken, + lineToken: string, ): Promise { - const url = `${BASE_URL}/smart-checkouts/default/lines/${lineId}/`; - const headers: Record = { - "Content-Type": "application/json", - Origin: "https://www.kronan.is", - Authorization: `CognitoJWT ${tokens.idToken}`, - "Customer-Group-Id": String(customerGroupId), - }; - - const response = await fetch(url, { method: "DELETE", headers }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`API error ${response.status}: ${text}`); - } - // DELETE may return 204 No Content + return apiRequest("/shopping-notes/delete-line/", { + method: "DELETE", + token, + params: { token: lineToken }, + }); } -// --- Shopping Lists --- +/** + * Toggle line completion. + */ +export async function toggleShoppingNoteLineComplete( + token: AuthToken, + lineToken: string, +): Promise { + return apiRequest( + "/shopping-notes/toggle-complete-on-line/", + { + method: "PATCH", + token, + body: { token: lineToken }, + }, + ); +} /** - * Get shopping lists. + * Get archived shopping note lines. */ -export async function getShoppingLists(tokens: AuthTokens): Promise { - return apiRequest("/product_list/", { tokens }); +export async function getArchivedShoppingNoteLines( + token: AuthToken, +): Promise { + return apiRequest( + "/shopping-notes/lines-archived/", + { token }, + ); } /** - * Create a shopping list. + * Clear shopping note (delete all lines). */ -export async function createShoppingList( - tokens: AuthTokens, - name: string, - items: Array<{ sku: string; quantity: number }>, -): Promise { - return apiRequest("/product_list/", { - method: "POST", - tokens, - body: { name, items }, +export async function clearShoppingNote(token: AuthToken): Promise { + return apiRequest("/shopping-notes/delete-shopping-note/", { + method: "DELETE", + token, }); } + +// --- Purchase Stats API Functions --- + +/** + * Get purchase history/stats. + */ +export async function getPurchaseStats( + token: AuthToken, + options: { limit?: number; offset?: number; includeIgnored?: boolean } = {}, +): Promise { + const { limit = 15, offset = 0, includeIgnored = false } = options; + const params: Record = { + limit: String(limit), + offset: String(offset), + include_ignored: String(includeIgnored), + }; + return apiRequest( + "/product-purchase-stats/", + { + token, + params, + }, + ); +} + +/** + * Set ignored status on purchase stat. + */ +export async function setPurchaseStatIgnored( + token: AuthToken, + id: number, + isIgnored: boolean, +): Promise { + return apiRequest( + `/product-purchase-stats/${id}/set-ignored/`, + { + method: "PATCH", + token, + body: { isIgnored }, + }, + ); +} diff --git a/src/auth.ts b/src/auth.ts index 27e142e..c72699e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,196 +1,38 @@ /** - * Krónan Authentication Module + * Krónan Public API Authentication Module * - * Handles AWS Cognito custom auth flow using Rafræn skilríki (Icelandic electronic ID). - * Stores tokens in ~/.kronan/tokens.json for persistence between CLI invocations. + * The new Public API uses AccessToken authentication. + * Tokens are created at https://kronan.is/adgangur/adgangslyklar + * and must be provided by the user. + * + * Stores tokens in ~/.kronan/token for persistence between CLI invocations. */ import { homedir } from "node:os"; import { join } from "node:path"; -// Cognito configuration -const COGNITO_ENDPOINT = "https://cognito-idp.eu-west-1.amazonaws.com/"; -// Public Cognito app client ID — this is NOT a secret. It's embedded in -// the Kronan.is frontend and is required for the CUSTOM_AUTH flow. -const CLIENT_ID = "26cfceo8iffeoulsfnkopgfnbv"; -const POLL_INTERVAL_MS = 3000; -const MAX_POLL_ATTEMPTS = 40; // ~2 minutes - // Token storage path const TOKEN_DIR = join(homedir(), ".kronan"); -const TOKEN_FILE = join(TOKEN_DIR, "tokens.json"); - -export interface AuthTokens { - accessToken: string; - idToken: string; - refreshToken: string; - expiresAt: number; // unix timestamp in ms -} - -interface CognitoInitResponse { - ChallengeName: string; - ChallengeParameters: { - USERNAME: string; - code: string; - status: string; - }; - Session: string; -} - -interface CognitoAuthResult { - AuthenticationResult: { - AccessToken: string; - IdToken: string; - RefreshToken: string; - ExpiresIn: number; - TokenType: string; - }; -} - -interface CognitoChallengeResponse { - ChallengeName?: string; - ChallengeParameters?: { - USERNAME: string; - code?: string; - status: string; - }; - Session?: string; - AuthenticationResult?: CognitoAuthResult["AuthenticationResult"]; -} - -async function cognitoRequest(target: string, payload: object): Promise { - const response = await fetch(COGNITO_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/x-amz-json-1.1", - "X-Amz-Target": `AWSCognitoIdentityProviderService.${target}`, - }, - body: JSON.stringify(payload), - }); - - const data = (await response.json()) as Record; +const TOKEN_FILE = join(TOKEN_DIR, "token"); - if (data.__type) { - throw new Error(`Cognito error: ${data.__type} - ${data.message || ""}`); - } - - return data; -} - -/** - * Initiate a login flow with Rafræn skilríki. - * Returns a code the user must confirm on their phone. - */ -export async function initiateAuth(phoneNumber: string): Promise<{ - code: string; - session: string; -}> { - const data: CognitoInitResponse = await cognitoRequest("InitiateAuth", { - AuthFlow: "CUSTOM_AUTH", - ClientId: CLIENT_ID, - AuthParameters: { - USERNAME: phoneNumber, - }, - }); - - return { - code: data.ChallengeParameters.code, - session: data.Session, - }; +export interface AuthToken { + token: string; } /** - * Poll for auth completion after user approves on their phone. - * Calls the provided callback with status updates. + * Save access token to disk. */ -export async function pollAuth( - phoneNumber: string, - session: string, - onStatus?: (status: string, attempt: number) => void, -): Promise { - let currentSession = session; - - for (let attempt = 1; attempt <= MAX_POLL_ATTEMPTS; attempt++) { - const data: CognitoChallengeResponse = await cognitoRequest( - "RespondToAuthChallenge", - { - ChallengeName: "CUSTOM_CHALLENGE", - ClientId: CLIENT_ID, - ChallengeResponses: { - USERNAME: phoneNumber, - ANSWER: "answer", - }, - Session: currentSession, - }, - ); - - // Check for successful auth - if (data.AuthenticationResult) { - const tokens: AuthTokens = { - accessToken: data.AuthenticationResult.AccessToken, - idToken: data.AuthenticationResult.IdToken, - refreshToken: data.AuthenticationResult.RefreshToken, - expiresAt: Date.now() + data.AuthenticationResult.ExpiresIn * 1000, - }; - - await saveTokens(tokens); - return tokens; - } - - const status = data.ChallengeParameters?.status || "unknown"; - onStatus?.(status, attempt); - - if (data.Session) { - currentSession = data.Session; - } - - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - } - - throw new Error("Authentication timed out. Please try again."); -} - -/** - * Refresh tokens using the refresh token. - */ -export async function refreshAuth(refreshToken: string): Promise { - const data = await cognitoRequest("InitiateAuth", { - AuthFlow: "REFRESH_TOKEN_AUTH", - ClientId: CLIENT_ID, - AuthParameters: { - REFRESH_TOKEN: refreshToken, - }, - }); - - if (!data.AuthenticationResult) { - throw new Error("Token refresh failed"); - } - - const tokens: AuthTokens = { - accessToken: data.AuthenticationResult.AccessToken, - idToken: data.AuthenticationResult.IdToken, - refreshToken: refreshToken, // refresh token stays the same - expiresAt: Date.now() + data.AuthenticationResult.ExpiresIn * 1000, - }; - - await saveTokens(tokens); - return tokens; -} - -/** - * Save tokens to disk. - */ -async function saveTokens(tokens: AuthTokens): Promise { +export async function saveToken(token: string): Promise { const { mkdir } = await import("node:fs/promises"); await mkdir(TOKEN_DIR, { recursive: true }); - await Bun.write(TOKEN_FILE, JSON.stringify(tokens, null, 2)); + await Bun.write(TOKEN_FILE, token); } /** - * Load tokens from disk, refreshing if expired. - * Returns null if no tokens are stored. + * Load access token from disk. + * Returns null if no token is stored. */ -export async function loadTokens(): Promise { +export async function loadToken(): Promise { const file = Bun.file(TOKEN_FILE); if (!(await file.exists())) { @@ -198,43 +40,41 @@ export async function loadTokens(): Promise { } try { - const tokens: AuthTokens = await file.json(); - - // Check if tokens are expired (with 5 min buffer) - if (Date.now() > tokens.expiresAt - 5 * 60 * 1000) { - try { - return await refreshAuth(tokens.refreshToken); - } catch { - // Refresh failed, tokens are invalid - return null; - } - } - - return tokens; + const token = await file.text(); + return { token: token.trim() }; } catch { return null; } } /** - * Ensure we have valid tokens, prompting login if needed. - * Throws if no tokens available. + * Ensure we have a valid token. + * Throws if no token available. */ -export async function requireAuth(): Promise { - const tokens = await loadTokens(); - if (!tokens) { - throw new Error("Not logged in. Run 'kronan login' first."); +export async function requireAuth(): Promise { + const token = await loadToken(); + if (!token) { + throw new Error( + "No access token found. Create one at https://kronan.is/adgangur/adgangslyklar and run 'kronan token '", + ); } - return tokens; + return token; } /** - * Clear stored tokens (logout). + * Clear stored token (logout). */ -export async function clearTokens(): Promise { +export async function clearToken(): Promise { const file = Bun.file(TOKEN_FILE); if (await file.exists()) { const { unlink } = await import("node:fs/promises"); await unlink(TOKEN_FILE); } } + +/** + * Get auth header value for API requests. + */ +export function getAuthHeader(token: AuthToken): string { + return `AccessToken ${token.token}`; +} diff --git a/src/cli.test.ts b/src/cli.test.ts index 50828c6..cfe3eeb 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -9,6 +9,7 @@ describe("CLI", () => { expect(result).toContain("cart"); expect(result).toContain("orders"); expect(result).toContain("--json"); + expect(result).toContain("token"); }); test("no arguments shows help", async () => { @@ -29,4 +30,12 @@ describe("CLI", () => { // Ensure no real phone numbers leak into help text expect(result).not.toContain("8430822"); }); + + test("token command without argument shows error", async () => { + const proc = Bun.spawn(["bun", "src/index.ts", "token"], { + stderr: "pipe", + }); + const stderr = await new Response(proc.stderr).text(); + expect(stderr).toContain("Usage: kronan token"); + }); }); diff --git a/src/commands/cart.ts b/src/commands/cart.ts index 60be248..8774d2d 100644 --- a/src/commands/cart.ts +++ b/src/commands/cart.ts @@ -1,67 +1,69 @@ /** - * Cart commands — uses the smart-checkouts API + * Cart/checkout commands */ import { - addToCart, - type CartLine, - getCart, - getCustomerGroupId, - getShoppingLists, - removeCartLine, - updateCartLine, + addCheckoutLines, + getCheckout, + getProductLists, + type PublicCheckoutLine, + replaceCheckoutLines, } from "../api.ts"; import { requireAuth } from "../auth.ts"; /** - * View current cart contents. + * View current checkout contents. */ export async function cartViewCommand( options: { json?: boolean } = {}, ): Promise { - const tokens = await requireAuth(); - const groupId = await getCustomerGroupId(tokens); - const cart = await getCart(tokens, groupId); + const token = await requireAuth(); + const checkout = await getCheckout(token); if (options.json) { - console.log(JSON.stringify(cart, null, 2)); + console.log(JSON.stringify(checkout, null, 2)); return; } - const lines: CartLine[] = cart.lines || []; + if (!checkout) { + console.log("Cart is empty."); + return; + } + + const lines: PublicCheckoutLine[] = checkout.lines || []; if (lines.length === 0) { console.log("Cart is empty."); return; } console.log(`Cart (${lines.length} items):\n`); - let total = 0; for (const line of lines) { const name = line.product?.name || "Unknown"; const sku = line.product?.sku || "?"; - const price = (line as any).price || line.product?.price || 0; - const lineTotal = (line as any).total || price * line.quantity; - total += lineTotal; + const price = line.price || line.product?.price || 0; console.log( - ` [${line.id}] ${name} x${line.quantity} ${price} kr = ${lineTotal} kr (SKU: ${sku})`, + ` [${line.id}] ${name} x${line.quantity} ${price} kr = ${line.total} kr (SKU: ${sku})`, ); } - console.log(`\n Total: ${total} kr`); + console.log(`\n Subtotal: ${checkout.subtotal} kr`); + console.log(` Bagging: ${checkout.baggingFee} kr`); + console.log(` Service: ${checkout.serviceFee} kr`); + console.log(` Shipping: ${checkout.shippingFee} kr`); + console.log(` Total: ${checkout.total} kr`); } /** - * Add item(s) to cart. + * Add item(s) to checkout. */ export async function cartAddCommand( sku: string, quantity: number = 1, options: { json?: boolean } = {}, ): Promise { - const tokens = await requireAuth(); - const groupId = await getCustomerGroupId(tokens); + const token = await requireAuth(); - const result = await addToCart(tokens, groupId, [ - { sku, quantity, source: "search" }, + const result = await addCheckoutLines(token, [ + { sku, quantity, substitution: true }, ]); if (options.json) { @@ -72,73 +74,66 @@ export async function cartAddCommand( } /** - * Update quantity of a cart line. + * Set cart lines (replaces all existing lines). */ -export async function cartUpdateCommand( - lineId: number, - quantity: number, +export async function cartSetCommand( + lines: Array<{ sku: string; quantity: number }>, options: { json?: boolean } = {}, ): Promise { - const tokens = await requireAuth(); - const groupId = await getCustomerGroupId(tokens); + const token = await requireAuth(); - const result = await updateCartLine(tokens, groupId, lineId, quantity); + const result = await replaceCheckoutLines( + token, + lines.map((l) => ({ ...l, substitution: true })), + ); if (options.json) { console.log(JSON.stringify(result, null, 2)); } else { - console.log(`Updated line ${lineId} to quantity ${quantity}.`); + console.log(`Cart updated with ${lines.length} items.`); } } /** - * Remove a line from the cart. + * Clear the cart (remove all items). */ -export async function cartRemoveCommand( - lineId: number, +export async function cartClearCommand( options: { json?: boolean } = {}, ): Promise { - const tokens = await requireAuth(); - const groupId = await getCustomerGroupId(tokens); + const token = await requireAuth(); - await removeCartLine(tokens, groupId, lineId); + const result = await replaceCheckoutLines(token, []); if (options.json) { - console.log(JSON.stringify({ success: true, lineId }, null, 2)); + console.log(JSON.stringify(result, null, 2)); } else { - console.log(`Removed line ${lineId} from cart.`); + console.log("Cart cleared."); } } /** - * View shopping lists. + * View product lists. */ export async function listCommand( options: { json?: boolean } = {}, ): Promise { - const tokens = await requireAuth(); - const lists = await getShoppingLists(tokens); + const token = await requireAuth(); + const lists = await getProductLists(token); if (options.json) { console.log(JSON.stringify(lists, null, 2)); return; } - if (lists.length === 0) { - console.log("No shopping lists found."); + if (lists.results.length === 0) { + console.log("No product lists found."); return; } - for (const list of lists) { - console.log(` ${list.name} (${list.items?.length || 0} items)`); - if (list.items) { - for (const item of list.items.slice(0, 5)) { - console.log(` - ${item.sku}: qty ${item.quantity}`); - } - if (list.items.length > 5) { - console.log(` ... and ${list.items.length - 5} more`); - } - } - console.log(""); + for (const list of lists.results) { + console.log( + ` ${list.name} ${list.hasProducts ? "(has items)" : "(empty)"}`, + ); + console.log(` Token: ${list.token}`); } } diff --git a/src/commands/login.ts b/src/commands/login.ts index 83092ac..506f08b 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,64 +1,65 @@ /** - * Login command - authenticates via Rafræn skilríki + * Token management commands + * + * The new Public API uses AccessToken authentication. + * Tokens are created at https://kronan.is/adgangur/adgangslyklar */ -import { clearTokens, initiateAuth, loadTokens, pollAuth } from "../auth.ts"; +import { getMe } from "../api.ts"; +import { clearToken, loadToken, saveToken } from "../auth.ts"; -export async function loginCommand(phoneNumber: string): Promise { - console.log(`Initiating login for phone number: ${phoneNumber}`); +export async function tokenCommand(tokenValue: string): Promise { + // First validate the token before saving + const token = { token: tokenValue }; - const { code, session } = await initiateAuth(phoneNumber); - - console.log(""); - console.log(` Confirm code on your phone: ${code}`); - console.log(" Waiting for approval..."); - console.log(""); - - const tokens = await pollAuth(phoneNumber, session, (status, attempt) => { - process.stdout.write(`\r Status: ${status} (attempt ${attempt}) `); - }); - - console.log("\r Login successful! "); - console.log(""); - - // Decode IdToken to get user name try { - const payload = JSON.parse( - Buffer.from(tokens.idToken.split(".")[1]!, "base64").toString(), - ); - console.log(` Welcome, ${payload.name}`); - } catch {} + const me = await getMe(token); + // Token is valid, save it + await saveToken(tokenValue); + console.log("Token saved successfully!"); + console.log(""); + console.log(` Identity: ${me.name}`); + console.log(` Type: ${me.type}`); + } catch (error: any) { + throw new Error(`Token validation failed: ${error.message}`); + } } export async function logoutCommand(): Promise { - await clearTokens(); - console.log("Logged out. Tokens cleared."); + await clearToken(); + console.log("Token cleared."); } export async function statusCommand(): Promise { - const tokens = await loadTokens(); - if (!tokens) { - console.log(JSON.stringify({ loggedIn: false })); + const token = await loadToken(); + if (!token) { + console.log(JSON.stringify({ loggedIn: false }, null, 2)); return; } try { - const payload = JSON.parse( - Buffer.from(tokens.idToken.split(".")[1]!, "base64").toString(), - ); + const me = await getMe(token); console.log( JSON.stringify( { loggedIn: true, - username: payload["cognito:username"], - name: payload.name, - expiresAt: new Date(tokens.expiresAt).toISOString(), + name: me.name, + type: me.type, + }, + null, + 2, + ), + ); + } catch (error: any) { + console.log( + JSON.stringify( + { + loggedIn: false, + error: error.message, }, null, 2, ), ); - } catch { - console.log(JSON.stringify({ loggedIn: true })); } } diff --git a/src/commands/orders.ts b/src/commands/orders.ts index 4119211..5585b87 100644 --- a/src/commands/orders.ts +++ b/src/commands/orders.ts @@ -1,5 +1,5 @@ /** - * Order history command + * Order history commands */ import { getOrder, getOrders } from "../api.ts"; @@ -10,8 +10,8 @@ export async function ordersCommand( ): Promise { const { limit = 15, offset = 0, json = false } = options; - const tokens = await requireAuth(); - const data = await getOrders(tokens, { limit, offset }); + const token = await requireAuth(); + const data = await getOrders(token, { limit, offset }); if (json) { console.log(JSON.stringify(data, null, 2)); @@ -21,24 +21,12 @@ export async function ordersCommand( console.log(`Order history (${data.count} total):\n`); for (const order of data.results) { - const date = new Date(order.displayDate).toLocaleDateString("is-IS"); - const total = parseFloat(order.totalNetAmount).toLocaleString("is-IS"); - const itemCount = order.lines.length; + const date = new Date(order.created).toLocaleDateString("is-IS"); + const total = order.total.toLocaleString("is-IS"); console.log( - ` #${order.id} ${date} ${total} kr (${itemCount} items) [${order.status}]`, + ` ${order.token.substring(0, 8)}... ${date} ${total} kr [${order.status}]`, ); - - // Show first few items - const preview = order.lines.slice(0, 3); - for (const line of preview) { - const name = line.product?.name || line.productName; - console.log(` - ${name} x${line.quantity}`); - } - if (order.lines.length > 3) { - console.log(` ... and ${order.lines.length - 3} more items`); - } - console.log(""); } if (data.next) { @@ -50,33 +38,31 @@ export async function ordersCommand( * Show a specific order's details */ export async function orderDetailCommand( - orderId: string, + orderToken: string, options: { json?: boolean } = {}, ): Promise { - const tokens = await requireAuth(); - const order = await getOrder(tokens, orderId); + const token = await requireAuth(); + const order = await getOrder(token, orderToken); if (options.json) { console.log(JSON.stringify(order, null, 2)); return; } - const date = new Date(order.displayDate).toLocaleDateString("is-IS"); - const total = parseFloat(order.totalNetAmount).toLocaleString("is-IS"); + const date = new Date(order.created).toLocaleDateString("is-IS"); + const total = order.total.toLocaleString("is-IS"); - console.log(`Order #${order.id}`); + console.log(`Order ${order.token}`); console.log(` Date: ${date}`); console.log(` Status: ${order.status}`); + console.log(` Type: ${order.type || "N/A"}`); console.log(` Total: ${total} kr`); console.log(` Items:`); for (const line of order.lines) { - const name = line.product?.name || line.productName; - const unitPrice = parseFloat(line.unitPriceNetAmount).toLocaleString( - "is-IS", - ); + const unitPrice = line.unitPrice.toLocaleString("is-IS"); console.log( - ` ${line.productSku} ${name} x${line.quantity} ${unitPrice} kr/ea`, + ` ${line.sku} ${line.productName} x${line.quantity} ${unitPrice} kr/ea`, ); } } diff --git a/src/commands/search.ts b/src/commands/search.ts index 4774931..e8c0714 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -3,7 +3,7 @@ */ import { getProduct, searchProducts } from "../api.ts"; -import { loadTokens } from "../auth.ts"; +import { loadToken } from "../auth.ts"; export async function searchCommand( query: string, @@ -14,13 +14,16 @@ export async function searchCommand( json?: boolean; } = {}, ): Promise { - const { page = 1, pageSize = 20, store, json = false } = options; + const { page = 1, json = false } = options; - const result = await searchProducts(query, { - page, - pageSize, - storeExtId: store, - }); + const token = await loadToken(); + if (!token) { + throw new Error( + "No access token found. Create one at https://kronan.is/adgangur/adgangslyklar and run 'kronan token '", + ); + } + + const result = await searchProducts(token, query, { page }); if (json) { console.log(JSON.stringify(result, null, 2)); @@ -31,12 +34,12 @@ export async function searchCommand( `Found ${result.count} products (page ${result.page}/${result.pageCount}):\n`, ); - for (const product of result.results.hits) { - const sale = product.detail?.onSale ? " [SALE]" : ""; + for (const product of result.hits) { + const sale = product.onSale ? " [SALE]" : ""; const shortage = product.temporaryShortage ? " [OUT OF STOCK]" : ""; const discount = - product.detail?.discountPercent && product.detail.discountPercent > 0 - ? ` (${product.detail.discountPercent}% off → ${product.detail.discountedPrice} kr)` + product.discountPercent && product.discountPercent > 0 + ? ` (${product.discountPercent}% off → ${product.discountedPrice} kr)` : ""; console.log(` ${product.sku} ${product.name}`); @@ -57,8 +60,14 @@ export async function productDetailCommand( sku: string, options: { json?: boolean } = {}, ): Promise { - const tokens = await loadTokens(); - const product = await getProduct(sku, tokens ?? undefined); + const token = await loadToken(); + if (!token) { + throw new Error( + "No access token found. Create one at https://kronan.is/adgangur/adgangslyklar and run 'kronan token '", + ); + } + + const product = await getProduct(token, sku); if (options.json) { console.log(JSON.stringify(product, null, 2)); @@ -69,11 +78,14 @@ export async function productDetailCommand( console.log(` SKU: ${product.sku}`); console.log(` Price: ${product.price} kr`); console.log(` Price info: ${product.priceInfo}`); - console.log(` Category: ${product.category?.name || "N/A"}`); console.log(` In stock: ${product.temporaryShortage ? "No" : "Yes"}`); console.log(` Image: ${product.image}`); if (product.description) { - console.log(` Ingredients: ${product.description.substring(0, 200)}`); + console.log(` Description: ${product.description.substring(0, 200)}`); + } + + if (product.tags && product.tags.length > 0) { + console.log(` Tags: ${product.tags.map((t) => t.name).join(", ")}`); } } diff --git a/src/index.ts b/src/index.ts old mode 100644 new mode 100755 index e7db664..2ad2168 --- a/src/index.ts +++ b/src/index.ts @@ -7,39 +7,36 @@ * and viewing order history on Krónan (https://www.kronan.is). * * Usage: - * kronan login Login via Rafræn skilríki - * kronan logout Clear stored tokens - * kronan status Show login status - * kronan search Search for products - * kronan product Get product details - * kronan orders View order history - * kronan order View specific order - * kronan cart View cart contents + * kronan token Save access token (create at https://kronan.is/adgangur/adgangslyklar) + * kronan logout Clear stored token + * kronan status Show login status + * kronan search Search for products + * kronan product Get product details + * kronan orders View order history + * kronan order View specific order + * kronan cart View cart contents * kronan cart add [qty] Add item to cart - * kronan cart update Update cart line quantity - * kronan cart remove Remove cart line - * kronan lists View shopping lists + * kronan cart clear Clear cart + * kronan lists View product lists * * Flags: - * --json Output as JSON (for AI agents) - * --page Page number for search - * --limit Items per page - * --store Store ID for search + * --json Output as JSON (for AI agents) + * --page Page number for search + * --limit Items per page */ -import { getUserProfile } from "./api.ts"; +import { getMe } from "./api.ts"; import { requireAuth } from "./auth.ts"; import { cartAddCommand, - cartRemoveCommand, - cartUpdateCommand, + cartClearCommand, cartViewCommand, listCommand, } from "./commands/cart.ts"; import { - loginCommand, logoutCommand, statusCommand, + tokenCommand, } from "./commands/login.ts"; import { orderDetailCommand, ordersCommand } from "./commands/orders.ts"; import { productDetailCommand, searchCommand } from "./commands/search.ts"; @@ -63,13 +60,17 @@ const jsonOutput = hasFlag("json"); async function main() { try { switch (command) { - case "login": { - const phone = args[1]; - if (!phone) { - console.error("Usage: kronan login "); + case "token": { + const tokenValue = args[1]; + if (!tokenValue) { + console.error("Usage: kronan token "); + console.error(""); + console.error( + "Create an access token at: https://kronan.is/adgangur/adgangslyklar", + ); process.exit(1); } - await loginCommand(phone); + await tokenCommand(tokenValue); break; } @@ -85,16 +86,12 @@ async function main() { const query = args[1]; if (!query) { console.error( - "Usage: kronan search [--page N] [--limit N] [--store ID] [--json]", + "Usage: kronan search [--page N] [--limit N] [--json]", ); process.exit(1); } await searchCommand(query, { page: getFlag("page") ? parseInt(getFlag("page")!, 10) : undefined, - pageSize: getFlag("limit") - ? parseInt(getFlag("limit")!, 10) - : undefined, - store: getFlag("store"), json: jsonOutput, }); break; @@ -121,57 +118,43 @@ async function main() { break; case "order": { - const orderId = args[1]; - if (!orderId) { - console.error("Usage: kronan order [--json]"); + const orderToken = args[1]; + if (!orderToken) { + console.error("Usage: kronan order [--json]"); process.exit(1); } - await orderDetailCommand(orderId, { json: jsonOutput }); + await orderDetailCommand(orderToken, { json: jsonOutput }); break; } case "cart": { - const subcommand = args[1]; + // Filter out flags to get the actual subcommand + const subcommand = args.find( + (arg) => !arg.startsWith("-") && args.indexOf(arg) === 1, + ); switch (subcommand) { case "add": { const sku = args[2]; const qty = args[3] ? parseInt(args[3], 10) : 1; - if (!sku) { + if (!sku || sku.startsWith("-")) { console.error("Usage: kronan cart add [quantity] [--json]"); process.exit(1); } await cartAddCommand(sku, qty, { json: jsonOutput }); break; } + case "clear": { + await cartClearCommand({ json: jsonOutput }); + break; + } case "view": case undefined: { await cartViewCommand({ json: jsonOutput }); break; } - case "update": { - const lineId = args[2] ? parseInt(args[2], 10) : NaN; - const qty = args[3] ? parseInt(args[3], 10) : NaN; - if (Number.isNaN(lineId) || Number.isNaN(qty)) { - console.error( - "Usage: kronan cart update [--json]", - ); - process.exit(1); - } - await cartUpdateCommand(lineId, qty, { json: jsonOutput }); - break; - } - case "remove": { - const lineId = args[2] ? parseInt(args[2], 10) : NaN; - if (Number.isNaN(lineId)) { - console.error("Usage: kronan cart remove [--json]"); - process.exit(1); - } - await cartRemoveCommand(lineId, { json: jsonOutput }); - break; - } default: console.error(`Unknown cart subcommand: ${subcommand}`); - console.error("Usage: kronan cart [view|add|update|remove]"); + console.error("Usage: kronan cart [view|add|clear]"); process.exit(1); } break; @@ -183,15 +166,13 @@ async function main() { case "me": case "profile": { - const tokens = await requireAuth(); - const profile = await getUserProfile(tokens); + const token = await requireAuth(); + const me = await getMe(token); if (jsonOutput) { - console.log(JSON.stringify(profile, null, 2)); + console.log(JSON.stringify(me, null, 2)); } else { - console.log(`${profile.name}`); - console.log(` Email: ${profile.email}`); - console.log(` Phone: ${profile.phoneNumber}`); - console.log(` SSN: ${profile.ssn}`); + console.log(`${me.name}`); + console.log(` Type: ${me.type}`); } break; } @@ -222,23 +203,22 @@ function printHelp() { console.log(`kronan-cli - Krónan grocery store CLI Commands: - login Login via Rafræn skilríki (Icelandic e-ID) - logout Clear stored tokens + token Save access token (create at https://kronan.is/adgangur/adgangslyklar) + logout Clear stored token status Show login status search Search for products product Get product details by SKU orders View order history - order View specific order details + order View specific order details cart add [qty] Add item to cart cart view View cart contents (default) - cart update Update cart line quantity - cart remove Remove line from cart - lists View shopping lists + cart clear Clear cart + lists View product lists - me Show user profile + me Show current identity help Show this help Flags: @@ -246,18 +226,17 @@ Flags: --page Page number (search) --limit Results per page --offset Offset for pagination (orders) - --store Store external ID (search) Examples: - kronan login + kronan token abc123def456 kronan search "mjólk" kronan search "epli" --json --limit 5 kronan product 02500188 kronan orders --json - kronan order 727555 + kronan order abc123... kronan cart kronan cart add 02500188 2 - kronan cart remove 12345 + kronan cart clear `); }