diff --git a/README.md b/README.md index 4434a2f..314164f 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,14 @@ 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 clear Clear cart +kronan cart View cart +kronan cart add [qty] Add item to cart +kronan cart set '' Replace whole cart with JSON lines +kronan cart update --quantity N Update one line (qty 0 removes) +kronan cart update '' Bulk update via JSON patch +kronan cart remove Remove a single line +kronan cart clear Clear cart + (set/update/remove/clear support --dry-run) kronan orders Order history kronan order Specific order details @@ -85,7 +90,32 @@ kronan lists View product lists kronan me Show current identity ``` -All commands support `--json` for structured output. +All commands support `--json` for structured output. Cart mutation commands +(`set`, `update`, `remove`, `clear`) also accept `--dry-run` to preview the +resulting cart without calling the API. + +### Cart editing examples + +```bash +# Replace the entire cart with a fixed set of lines +kronan cart set '[{"sku":"02500188","quantity":3}]' +kronan cart set '{"02500188":1,"100151784":2}' + +# Update a single line by SKU or numeric line id +kronan cart update 02500188 --quantity 5 +kronan cart update 13065307 --quantity 0 # qty 0 removes the line + +# Bulk update: object/array of {sku:qty} as a patch. +# Listed SKUs are set to the given qty (0 removes); unlisted lines are left +# alone. SKUs not currently in the cart are added when qty > 0. +kronan cart update '{"02500188":3,"100151784":0,"100224198":1}' + +# Remove a single line (shorthand for --quantity 0) +kronan cart remove 02500188 + +# Preview any of the above without hitting the API +kronan cart update '{"02500188":3}' --dry-run --json +``` ## AI agent usage diff --git a/src/api.ts b/src/api.ts index a9cbfcb..1727271 100644 --- a/src/api.ts +++ b/src/api.ts @@ -544,6 +544,12 @@ export async function addCheckoutLines( /** * Replace all checkout lines. + * + * Workaround for upstream API behaviour: `POST /checkout/lines/` with + * `{lines: [], replace: true}` is silently a no-op (returns the unchanged + * checkout instead of clearing it — see arnif/kronan-cli#7). When the caller + * asks for an empty cart we instead fetch the current lines and send each one + * back with `quantity: 0`, which the backend honours. */ export async function replaceCheckoutLines( token: AuthToken, @@ -553,7 +559,34 @@ export async function replaceCheckoutLines( substitution?: boolean; }>, ): Promise { - return addCheckoutLines(token, lines, true); + if (lines.length > 0) { + return addCheckoutLines(token, lines, true); + } + + const checkout = await getCheckout(token); + const existing = checkout?.lines ?? []; + if (existing.length === 0) { + // Cart already empty; nothing to do. Synthesize an empty checkout if the + // GET returned null (no active checkout). + return ( + checkout ?? { + token: "", + lines: [], + total: 0, + subtotal: 0, + baggingFee: 0, + serviceFee: 0, + shippingFee: 0, + shippingFeeCutoff: 0, + } + ); + } + const deletes = existing.map((line) => ({ + sku: line.product?.sku ?? "", + quantity: 0, + substitution: line.substitution ?? true, + })); + return addCheckoutLines(token, deletes, true); } // --- Product Lists API Functions --- diff --git a/src/cli.test.ts b/src/cli.test.ts index cfe3eeb..a310f1b 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -12,6 +12,36 @@ describe("CLI", () => { expect(result).toContain("token"); }); + test("help lists cart set/update/remove commands", async () => { + const result = await $`bun src/index.ts help`.text(); + expect(result).toContain("cart set"); + expect(result).toContain("cart update"); + expect(result).toContain("cart remove"); + expect(result).toContain("--dry-run"); + }); + + test("cart set without JSON argument errors", async () => { + const proc = Bun.spawn(["bun", "src/index.ts", "cart", "set"], { + stderr: "pipe", + }); + const stderr = await new Response(proc.stderr).text(); + expect(stderr).toContain("Usage: kronan cart set"); + }); + + test("cart update without --quantity errors", async () => { + const proc = Bun.spawn( + ["bun", "src/index.ts", "cart", "update", "02500188"], + { stderr: "pipe" }, + ); + const stderr = await new Response(proc.stderr).text(); + expect(stderr).toContain("--quantity"); + }); + + test("help documents bulk cart update JSON form", async () => { + const result = await $`bun src/index.ts help`.text(); + expect(result).toContain("json-patch"); + }); + test("no arguments shows help", async () => { const result = await $`bun src/index.ts`.text(); expect(result).toContain("kronan-cli"); diff --git a/src/commands/cart.test.ts b/src/commands/cart.test.ts new file mode 100644 index 0000000..64702c2 --- /dev/null +++ b/src/commands/cart.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { parseCartLinesJson } from "./cart.ts"; + +describe("parseCartLinesJson", () => { + test("parses array of {sku, quantity}", () => { + const lines = parseCartLinesJson( + '[{"sku":"02500059","quantity":1},{"sku":"100151784","quantity":2}]', + ); + expect(lines).toEqual([ + { sku: "02500059", quantity: 1 }, + { sku: "100151784", quantity: 2 }, + ]); + }); + + test("preserves substitution when present in array form", () => { + const lines = parseCartLinesJson( + '[{"sku":"02500059","quantity":1,"substitution":false}]', + ); + expect(lines).toEqual([ + { sku: "02500059", quantity: 1, substitution: false }, + ]); + }); + + test("parses object map {sku:quantity}", () => { + const lines = parseCartLinesJson('{"02500059":1,"100151784":2}'); + expect(lines.length).toBe(2); + expect(lines).toEqual( + expect.arrayContaining([ + { sku: "02500059", quantity: 1 }, + { sku: "100151784", quantity: 2 }, + ]), + ); + }); + + test("rejects invalid JSON", () => { + expect(() => parseCartLinesJson("not json")).toThrow(/Invalid JSON/); + }); + + test("rejects negative quantities in array form", () => { + expect(() => + parseCartLinesJson('[{"sku":"02500059","quantity":-1}]'), + ).toThrow(/non-negative integer/); + }); + + test("rejects negative quantities in map form", () => { + expect(() => parseCartLinesJson('{"02500059":-1}')).toThrow( + /non-negative integer/, + ); + }); + + test("rejects non-integer quantities", () => { + expect(() => + parseCartLinesJson('[{"sku":"02500059","quantity":1.5}]'), + ).toThrow(/non-negative integer/); + }); + + test("rejects missing sku", () => { + expect(() => parseCartLinesJson('[{"quantity":1}]')).toThrow( + /sku must be a non-empty string/, + ); + }); + + test("rejects non-number map value", () => { + expect(() => parseCartLinesJson('{"02500059":"1"}')).toThrow( + /must be a number/, + ); + }); + + test("rejects scalar JSON", () => { + expect(() => parseCartLinesJson("42")).toThrow( + /must be an array.*or an object map/, + ); + }); +}); diff --git a/src/commands/cart.ts b/src/commands/cart.ts index 8774d2d..c2074b8 100644 --- a/src/commands/cart.ts +++ b/src/commands/cart.ts @@ -73,42 +73,317 @@ export async function cartAddCommand( } } +export interface CartLineInput { + sku: string; + quantity: number; + substitution?: boolean; +} + +/** + * Parse the JSON argument accepted by `cart set`. + * Accepts either: + * - an array: [{"sku":"02500059","quantity":1,"substitution":true}] + * - an object map: {"02500059":1,"100151784":2} + */ +export function parseCartLinesJson(raw: string): CartLineInput[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error( + `Invalid JSON for cart lines: ${(err as Error).message}. Pass an array of {sku,quantity} or an object map {sku:quantity}.`, + ); + } + + if (Array.isArray(parsed)) { + return parsed.map((item, idx) => { + if (!item || typeof item !== "object") { + throw new Error(`Cart lines[${idx}] must be an object.`); + } + const obj = item as Record; + const sku = obj.sku; + const quantity = obj.quantity; + if (typeof sku !== "string" || sku.length === 0) { + throw new Error(`Cart lines[${idx}].sku must be a non-empty string.`); + } + if (typeof quantity !== "number" || !Number.isFinite(quantity)) { + throw new Error(`Cart lines[${idx}].quantity must be a number.`); + } + if (quantity < 0 || !Number.isInteger(quantity)) { + throw new Error( + `Cart lines[${idx}].quantity must be a non-negative integer.`, + ); + } + const line: CartLineInput = { sku, quantity }; + if (typeof obj.substitution === "boolean") { + line.substitution = obj.substitution; + } + return line; + }); + } + + if (parsed && typeof parsed === "object") { + return Object.entries(parsed as Record).map( + ([sku, quantity]) => { + if (sku.length === 0) { + throw new Error("Cart lines map contains an empty SKU."); + } + if (typeof quantity !== "number" || !Number.isFinite(quantity)) { + throw new Error( + `Cart lines map value for "${sku}" must be a number.`, + ); + } + if (quantity < 0 || !Number.isInteger(quantity)) { + throw new Error( + `Cart lines map value for "${sku}" must be a non-negative integer.`, + ); + } + return { sku, quantity }; + }, + ); + } + + throw new Error( + "Cart lines JSON must be an array of {sku,quantity} or an object map {sku:quantity}.", + ); +} + +function checkoutLinesToInput(lines: PublicCheckoutLine[]): CartLineInput[] { + return lines.map((line) => ({ + sku: line.product?.sku ?? "", + quantity: line.quantity, + substitution: line.substitution ?? true, + })); +} + +function printPlannedLines( + lines: CartLineInput[], + options: { json?: boolean }, +): void { + if (options.json) { + console.log(JSON.stringify({ dryRun: true, lines }, null, 2)); + return; + } + if (lines.length === 0) { + console.log("[dry-run] Resulting cart: empty."); + return; + } + console.log(`[dry-run] Resulting cart (${lines.length} lines):`); + for (const line of lines) { + const sub = line.substitution === false ? " no-sub" : ""; + console.log(` ${line.sku} x${line.quantity}${sub}`); + } + console.log("[dry-run] No changes sent to API."); +} + /** * Set cart lines (replaces all existing lines). */ export async function cartSetCommand( - lines: Array<{ sku: string; quantity: number }>, - options: { json?: boolean } = {}, + lines: CartLineInput[], + options: { json?: boolean; dryRun?: boolean } = {}, +): Promise { + const normalized = lines.map((l) => ({ + sku: l.sku, + quantity: l.quantity, + substitution: l.substitution ?? true, + })); + + if (options.dryRun) { + printPlannedLines(normalized, options); + return; + } + + const token = await requireAuth(); + const result = await replaceCheckoutLines(token, normalized); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Cart updated with ${normalized.length} items.`); + } +} + +/** + * Update a single cart line by SKU or numeric checkout line id. + * Quantity of 0 removes the line. + */ +export async function cartUpdateCommand( + identifier: string, + quantity: number, + options: { json?: boolean; dryRun?: boolean } = {}, ): Promise { + if (!Number.isFinite(quantity) || !Number.isInteger(quantity)) { + throw new Error("--quantity must be an integer."); + } + if (quantity < 0) { + throw new Error("--quantity must be 0 or greater (use 0 to remove)."); + } + const token = await requireAuth(); + const checkout = await getCheckout(token); + const existing: PublicCheckoutLine[] = checkout?.lines ?? []; - const result = await replaceCheckoutLines( - token, - lines.map((l) => ({ ...l, substitution: true })), + const idCandidate = /^\d+$/.test(identifier) + ? Number.parseInt(identifier, 10) + : null; + const matches = existing.filter( + (line) => + line.product?.sku === identifier || + (idCandidate !== null && line.id === idCandidate), ); + const uniqueMatches = Array.from( + new Map(matches.map((line) => [line.id, line])).values(), + ); + + if (uniqueMatches.length === 0) { + throw new Error( + `No cart line matched "${identifier}" (tried SKU and line id).`, + ); + } + if (uniqueMatches.length > 1) { + const ids = uniqueMatches.map((l) => l.id).join(", "); + throw new Error( + `Ambiguous: "${identifier}" matched multiple cart lines (ids: ${ids}). Pass the numeric line id instead.`, + ); + } + const target = uniqueMatches[0]!; + + const nextLines: CartLineInput[] = checkoutLinesToInput(existing) + .map((line) => + line.sku === target.product?.sku ? { ...line, quantity } : line, + ) + .filter((line) => line.quantity > 0); + + if (options.dryRun) { + printPlannedLines(nextLines, options); + return; + } + + const result = await replaceCheckoutLines(token, nextLines); if (options.json) { console.log(JSON.stringify(result, null, 2)); + return; + } + if (quantity === 0) { + console.log( + `Removed line [${target.id}] ${target.product?.name ?? target.product?.sku ?? identifier}.`, + ); } else { - console.log(`Cart updated with ${lines.length} items.`); + console.log( + `Updated line [${target.id}] ${target.product?.name ?? target.product?.sku ?? identifier} to quantity ${quantity}.`, + ); + } +} + +/** + * Apply a bulk patch to the cart. Each patch entry sets the given SKU to the + * given quantity (0 removes). SKUs not present in the patch are left untouched. + * SKUs not currently in the cart are added when quantity > 0. + * + * Substitution is preserved from the existing line unless the patch entry + * explicitly sets it; new lines default to substitution: true. + */ +export async function cartBulkUpdateCommand( + patches: CartLineInput[], + options: { json?: boolean; dryRun?: boolean } = {}, +): Promise { + if (patches.length === 0) { + throw new Error("Bulk update patch must contain at least one entry."); + } + + const seen = new Set(); + for (const p of patches) { + if (seen.has(p.sku)) { + throw new Error(`Duplicate SKU "${p.sku}" in bulk update patch.`); + } + seen.add(p.sku); + } + + const token = await requireAuth(); + const checkout = await getCheckout(token); + const existing: PublicCheckoutLine[] = checkout?.lines ?? []; + + const bySku = new Map(); + for (const line of checkoutLinesToInput(existing)) { + bySku.set(line.sku, line); + } + + for (const patch of patches) { + const current = bySku.get(patch.sku); + if (patch.quantity === 0) { + bySku.delete(patch.sku); + continue; + } + bySku.set(patch.sku, { + sku: patch.sku, + quantity: patch.quantity, + substitution: patch.substitution ?? current?.substitution ?? true, + }); } + + const nextLines = Array.from(bySku.values()); + + if (options.dryRun) { + printPlannedLines(nextLines, options); + return; + } + + const result = await replaceCheckoutLines(token, nextLines); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + const added = patches.filter( + (p) => p.quantity > 0 && !existing.some((l) => l.product?.sku === p.sku), + ).length; + const removed = patches.filter( + (p) => p.quantity === 0 && existing.some((l) => l.product?.sku === p.sku), + ).length; + const updated = patches.length - added - removed; + console.log( + `Bulk update applied: ${updated} updated, ${added} added, ${removed} removed. Cart now has ${nextLines.length} lines.`, + ); +} + +/** + * Remove a single cart line by SKU or numeric checkout line id. + */ +export async function cartRemoveCommand( + identifier: string, + options: { json?: boolean; dryRun?: boolean } = {}, +): Promise { + await cartUpdateCommand(identifier, 0, options); } /** * Clear the cart (remove all items). */ export async function cartClearCommand( - options: { json?: boolean } = {}, + options: { json?: boolean; dryRun?: boolean } = {}, ): Promise { - const token = await requireAuth(); + if (options.dryRun) { + printPlannedLines([], options); + return; + } + const token = await requireAuth(); const result = await replaceCheckoutLines(token, []); if (options.json) { console.log(JSON.stringify(result, null, 2)); - } else { - console.log("Cart cleared."); + return; + } + const remaining = result.lines?.length ?? 0; + if (remaining > 0) { + throw new Error( + `Cart clear did not take effect (${remaining} lines remain). The API may have changed.`, + ); } + console.log("Cart cleared."); } /** diff --git a/src/index.ts b/src/index.ts index 6b0391a..921cd1d 100755 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,9 @@ * kronan order View specific order * kronan cart View cart contents * kronan cart add [qty] Add item to cart + * kronan cart set Replace cart with JSON lines + * kronan cart update --quantity N Update a line (0 removes) + * kronan cart remove Remove a cart line * kronan cart clear Clear cart * kronan lists View product lists * @@ -31,8 +34,13 @@ import { getMe } from "./api.ts"; import { requireAuth } from "./auth.ts"; import { cartAddCommand, + cartBulkUpdateCommand, cartClearCommand, + cartRemoveCommand, + cartSetCommand, + cartUpdateCommand, cartViewCommand, + parseCartLinesJson, } from "./commands/cart.ts"; import { categoriesCommand, @@ -92,6 +100,7 @@ function hasFlag(name: string): boolean { } const jsonOutput = hasFlag("json"); +const dryRun = hasFlag("dry-run"); async function main() { try { @@ -314,8 +323,84 @@ async function main() { await cartAddCommand(sku, qty, { json: jsonOutput }); break; } + case "set": { + const json = args[2]; + if (!json || json.startsWith("-")) { + console.error( + "Usage: kronan cart set '' [--dry-run] [--json]", + ); + console.error(""); + console.error("Examples:"); + console.error( + ' kronan cart set \'[{"sku":"02500059","quantity":1}]\'', + ); + console.error( + ' kronan cart set \'{"02500059":1,"100151784":2}\'', + ); + process.exit(1); + } + const lines = parseCartLinesJson(json); + await cartSetCommand(lines, { json: jsonOutput, dryRun }); + break; + } + case "update": { + const identifier = args[2]; + if (!identifier || identifier.startsWith("-")) { + console.error( + "Usage: kronan cart update --quantity N [--dry-run] [--json]", + ); + console.error( + " kronan cart update '' [--dry-run] [--json]", + ); + process.exit(1); + } + // Bulk patch when arg looks like JSON (starts with [ or {). + if (identifier.startsWith("[") || identifier.startsWith("{")) { + const patches = parseCartLinesJson(identifier); + await cartBulkUpdateCommand(patches, { + json: jsonOutput, + dryRun, + }); + break; + } + const qtyFlag = getFlag("quantity"); + if (qtyFlag === undefined) { + console.error( + "Usage: kronan cart update --quantity N [--dry-run] [--json]", + ); + console.error( + " kronan cart update '' [--dry-run] [--json]", + ); + console.error("(--quantity is required for single-line update)"); + process.exit(1); + } + const qty = parseInt(qtyFlag, 10); + if (Number.isNaN(qty)) { + console.error("--quantity must be an integer."); + process.exit(1); + } + await cartUpdateCommand(identifier, qty, { + json: jsonOutput, + dryRun, + }); + break; + } + case "remove": { + const identifier = args[2]; + if (!identifier || identifier.startsWith("-")) { + console.error( + "Usage: kronan cart remove [--dry-run] [--json]", + ); + process.exit(1); + } + await cartRemoveCommand(identifier, { + json: jsonOutput, + dryRun, + }); + break; + } case "clear": { - await cartClearCommand({ json: jsonOutput }); + await cartClearCommand({ json: jsonOutput, dryRun }); break; } case "view": @@ -325,7 +410,9 @@ async function main() { } default: console.error(`Unknown cart subcommand: ${subcommand}`); - console.error("Usage: kronan cart [view|add|clear]"); + console.error( + "Usage: kronan cart [view|add|set|update|remove|clear]", + ); process.exit(1); } break; @@ -607,7 +694,17 @@ Orders: Cart: cart View cart contents (default) cart add [qty] Add item to cart + cart set '' Replace cart with JSON lines. + Accepts an array of {sku,quantity[,substitution]} + or an object map {sku:quantity}. + cart update --quantity N + Update a single cart line. --quantity 0 removes. + cart update '' Bulk update: object/array of {sku:qty}. + Listed SKUs are set (qty 0 removes); other lines untouched. + Adds new SKUs when qty > 0. + cart remove Remove a cart line (matches SKU or line id). cart clear Clear cart + (set/update/remove/clear support --dry-run) Product Lists: lists View all product lists @@ -649,6 +746,8 @@ Flags: --name Profile name for token command --force Force destructive operations --include-ignored Include ignored items in stats + --dry-run Print planned cart lines without calling API + (cart set/update/remove/clear) Examples: kronan token abc123 --name personal @@ -665,6 +764,11 @@ Examples: kronan order delete-lines abc123 line1 line2 kronan cart kronan cart add 02500188 2 + kronan cart set '[{"sku":"02500188","quantity":3}]' + kronan cart set '{"02500188":1,"100151784":2}' --dry-run + kronan cart update 02500188 --quantity 5 + kronan cart update '{"02500188":3,"100151784":0}' --dry-run + kronan cart remove 02500188 kronan lists create "Weekly Shopping" --description "Groceries for the week" kronan lists add mylist-token 02500188 3 kronan notes add --text "Buy milk" --sku 02500188 --quantity 2