From d50d849c54af175979d8c4e4d808ac367d19ff78 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Tue, 3 Nov 2026 11:19:51 +0800 Subject: [PATCH 1/3] Add shop management commands --- AGENTS.md | 5 + README.md | 4 + README.zh-CN.md | 4 + skills/README.md | 1 + skills/halo-cli-shared/SKILL.md | 2 + skills/halo-cli-shop/SKILL.md | 157 + skills/halo-cli/SKILL.md | 5 +- src/cli.ts | 5 + src/commands/auth/__test__/auth-entry.spec.ts | 52 + src/commands/auth/index.ts | 10 +- src/commands/shop/__test__/cli.spec.ts | 178 ++ src/commands/shop/__test__/format.spec.ts | 172 ++ src/commands/shop/__test__/input.spec.ts | 120 + src/commands/shop/__test__/shop-entry.spec.ts | 2642 +++++++++++++++++ src/commands/shop/api.ts | 328 ++ src/commands/shop/category.ts | 205 ++ src/commands/shop/cli.ts | 247 ++ src/commands/shop/config.ts | 129 + src/commands/shop/coupon.ts | 445 +++ src/commands/shop/customer.ts | 190 ++ src/commands/shop/discount.ts | 396 +++ src/commands/shop/format.ts | 391 +++ src/commands/shop/index.ts | 220 ++ src/commands/shop/input.ts | 199 ++ src/commands/shop/order.ts | 535 ++++ src/commands/shop/product.ts | 986 ++++++ src/commands/shop/stats.ts | 55 + src/commands/shop/types.ts | 395 +++ src/utils/__test__/completion.spec.ts | 12 + src/utils/completion.ts | 343 +++ src/utils/confirmation.ts | 2 +- src/utils/errors.ts | 2 +- 32 files changed, 8433 insertions(+), 4 deletions(-) create mode 100644 skills/halo-cli-shop/SKILL.md create mode 100644 src/commands/shop/__test__/cli.spec.ts create mode 100644 src/commands/shop/__test__/format.spec.ts create mode 100644 src/commands/shop/__test__/input.spec.ts create mode 100644 src/commands/shop/__test__/shop-entry.spec.ts create mode 100644 src/commands/shop/api.ts create mode 100644 src/commands/shop/category.ts create mode 100644 src/commands/shop/cli.ts create mode 100644 src/commands/shop/config.ts create mode 100644 src/commands/shop/coupon.ts create mode 100644 src/commands/shop/customer.ts create mode 100644 src/commands/shop/discount.ts create mode 100644 src/commands/shop/format.ts create mode 100644 src/commands/shop/index.ts create mode 100644 src/commands/shop/input.ts create mode 100644 src/commands/shop/order.ts create mode 100644 src/commands/shop/product.ts create mode 100644 src/commands/shop/stats.ts create mode 100644 src/commands/shop/types.ts diff --git a/AGENTS.md b/AGENTS.md index 29b58a4..22b3284 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ The root CLI currently exposes these business areas: - `moment` - `comment` - `notification` +- `shop` - `completion` ## Setup Commands @@ -216,6 +217,10 @@ helpers. reply in console context. - `notification get` filters the authenticated user's notification list by `metadata.name`. +- `shop` targets the Halo Pro/Commercial ecommerce Console API group + `console.api.ecommerce.halo.run/v1alpha1`. The published + `@halo-dev/api-client` package does not include these APIs, so the command area + uses manual `axios` requests through `src/commands/shop/api.ts`. ## Pull Request Checklist diff --git a/README.md b/README.md index ac8c4c9..8248ec9 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Commands: moment Moment management commands comment Comment management commands notification Notification management commands + shop Shop management commands (Halo Pro/Commercial) For more info, run any command with the `--help` flag: $ halo auth --help @@ -118,6 +119,7 @@ For more info, run any command with the `--help` flag: $ halo moment --help $ halo comment --help $ halo notification --help + $ halo shop --help Options: -h, --help Display this message @@ -174,6 +176,7 @@ Halo CLI currently includes these command groups: - `moment` - `comment` - `notification` +- `shop` — Halo Pro/Commercial shop management For details on any command, use `--help`. @@ -190,6 +193,7 @@ Included skills: - `halo-cli-search` - `halo-cli-operations` - `halo-cli-moderation-notifications` +- `halo-cli-shop` Add the skills with: diff --git a/README.zh-CN.md b/README.zh-CN.md index d6084af..9a5559a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -105,6 +105,7 @@ Commands: moment Moment management commands comment Comment management commands notification Notification management commands + shop Shop management commands (Halo Pro/Commercial) For more info, run any command with the `--help` flag: $ halo auth --help @@ -118,6 +119,7 @@ For more info, run any command with the `--help` flag: $ halo moment --help $ halo comment --help $ halo notification --help + $ halo shop --help Options: -h, --help Display this message @@ -174,6 +176,7 @@ halo comment reply - `moment` - `comment` - `notification` +- `shop` — Halo Pro/Commercial 商城管理 更多细节请使用 `--help` 查看。 @@ -190,6 +193,7 @@ halo comment reply - `halo-cli-search` - `halo-cli-operations` - `halo-cli-moderation-notifications` +- `halo-cli-shop` 可以通过下面的方式添加 skills: diff --git a/skills/README.md b/skills/README.md index 2a7ad94..c4b469b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -12,6 +12,7 @@ Recommended loading order for agents: - `halo-cli-search` - `halo-cli-operations` - `halo-cli-moderation-notifications` + - `halo-cli-shop` Design goals: diff --git a/skills/halo-cli-shared/SKILL.md b/skills/halo-cli-shared/SKILL.md index af660f4..f70aed0 100644 --- a/skills/halo-cli-shared/SKILL.md +++ b/skills/halo-cli-shared/SKILL.md @@ -48,6 +48,7 @@ halo --help - `moment` - `comment` - `notification` +- `shop` — Halo Pro/Commercial shop management - `completion ` — Generate shell completion script (bash, zsh, fish, powershell) ## Shared Rules @@ -86,3 +87,4 @@ Credentials are stored in the system keyring. - Use `halo-cli-search` for public site search. - Use `halo-cli-operations` for themes, plugins, attachments, backups, and moments. - Use `halo-cli-moderation-notifications` for comments, replies, and notifications. +- Use `halo-cli-shop` for products, orders, customers, coupons, discounts, shop statistics, and shop configuration. diff --git a/skills/halo-cli-shop/SKILL.md b/skills/halo-cli-shop/SKILL.md new file mode 100644 index 0000000..1e2ad8d --- /dev/null +++ b/skills/halo-cli-shop/SKILL.md @@ -0,0 +1,157 @@ +--- +name: halo-cli-shop +version: 1.0.0 +description: Use when managing a Halo Pro or Halo Commercial shop from the terminal or as an AI agent. Covers products, product categories, orders, customers, coupons, discounts, daily statistics, and shop configuration through `halo shop ...`. Trigger this skill whenever the user mentions Halo shop, Halo 商城, ecommerce products, orders, customers, coupons, discounts, or shop operations. +references: + - ../halo-cli-shared +metadata: + openclaw: + category: developer-tools + requires: + bins: ["halo"] + cliHelp: "halo shop --help" +--- + +# Halo CLI Shop + +Manage the Halo shop area through the `console.api.ecommerce.halo.run/v1alpha1` API. + +## Prerequisites + +The shop API only exists on Halo Pro / Halo Commercial instances with the shop feature enabled. + +```bash +halo shop status --json +``` + +Check `commercialActivated` and `shopApiAvailable` before running shop commands. If `shopApiAvailable` is false, do not retry the same command; the instance does not expose the shop API. + +All commands accept `--profile ` and `--json`. Prefer `--json` for agent workflows. + +## Command Map + +```bash +halo shop status + +halo shop product list|get|create|update|publish|unpublish|delete +halo shop category list|get|create|update|delete +halo shop order list|get|create|cancel|mark-paid|fulfill|note +halo shop customer list|get|update|addresses|entitlements +halo shop coupon list|get|create|update|delete|distribute +halo shop discount list|get|create|update|delete +halo shop stats daily +halo shop config get|set +``` + +Run `halo shop --help` or `halo shop --help` before guessing flags. + +## Common Workflows + +Inspect products and publish one: + +```bash +halo shop product list --status DRAFT --json +halo shop product get 12 --json +halo shop product publish 12 +``` + +Create a simple virtual product with one variant: + +```bash +halo shop product create \ + --title "Digital license" \ + --type VIRTUAL \ + --status PUBLISHED \ + --price 99 \ + --sku DIGITAL-001 \ + --variant-status PUBLISHED +``` + +Create a product with complex variants, specs, or images from a JSON payload: + +```bash +halo shop product create --file product.json --json +halo shop product update 12 --file product.json --json +``` + +`--file -` reads the payload from stdin. + +Order operations: + +```bash +halo shop order list --payment-status PAID --json +halo shop order get 1001 --json +halo shop order create --username demo --item 5:1:99 --json +halo shop order mark-paid 1001 --force +halo shop order fulfill 1001 --all --force --json +halo shop order note 1001 --message "Called the buyer" +``` + +`order fulfill --all` fulfills every remaining unfulfilled quantity. Use `--item :` for partial fulfillment. + +Marketing: + +```bash +halo shop coupon list --json +halo shop coupon create \ + --name "Spring sale" \ + --calculation-type PERCENTAGE \ + --discount-value 10 \ + --scope ALL \ + --valid-days 30 +halo shop coupon distribute 1 --username demo +halo shop coupon get 1 --json # distribution is asynchronous; poll to confirm +halo shop discount list --json +``` + +`coupon distribute` schedules issuance; a backend scheduler processes recipients asynchronously. Do not report the coupons as issued until `coupon get` shows the distribution as completed. + +Statistics and configuration: + +```bash +halo shop stats daily --start 2026-01-01 --end 2026-01-31 --json +halo shop config get --json +halo shop config set --guest-checkout true +halo shop config set --group payment --file payment.json +``` + +## JSON Payloads + +Payloads follow the Halo console API schemas. Common shapes: + +Product request: + +```json +{ + "title": "Digital license", + "productType": "VIRTUAL", + "status": "PUBLISHED", + "description": "One-year license", + "images": ["https://example.com/cover.png"], + "content": "

Details

", + "productVariants": [{ "price": 99, "skuCode": "DIGITAL-001", "status": "PUBLISHED" }] +} +``` + +Order create request: + +```json +{ + "username": "demo", + "items": [{ "productVariantId": 5, "quantity": 1, "customPrice": 99 }], + "customerNotes": "Created by agent", + "trackInventory": true +} +``` + +For `product update`, the CLI loads the current product first and merges the provided file and flags over it. For physical and virtual products, basic info, content, status, and specifications are updated through their dedicated endpoints so unrelated variant stock is not resubmitted. Variants are preserved unless the payload replaces `productVariants`. For an existing variant in the payload, fields omitted from the file are inherited from the current variant, so stock and other attributes are not reset. If an update needs confirmation, the CLI re-reads the product before writing specifications; provide explicit stock values if you intentionally want to overwrite concurrent changes. + +FIXED variants require a `price`; only `pricingType: "INQUIRY"` variants may omit it. A new variant without an explicit `status` defaults to `DRAFT`; when updating, an existing variant that omits `status` keeps its current status. Set `--variant-status PUBLISHED` or include `status` in the file to make a variant purchasable. + +## Safety + +- `product delete`, `category delete`, `coupon delete`, `discount delete`, `order cancel`, `order mark-paid`, `order fulfill`, `product unpublish`, `product update --status DRAFT|ARCHIVED`, `product update --variant-status DRAFT|ARCHIVED` on a published product, `coupon update --enabled false`, `discount update --enabled false`, `coupon distribute --all`, and `customer update --status BLOCKED` require interactive confirmation or `--force` in non-interactive mode. +- `product update --file` follows full-array replacement for `productVariants`: removing a published variant (including sending an empty array) archives it and requires confirmation. +- `order fulfill --all` skips subscription order lines. Subscription lines are fulfilled by the integrator and cannot be fulfilled manually. Use `--provider MANUAL`; the current backend does not expose a VIRTUAL fulfillment provider. Fulfillment cannot be reversed through the API, so confirm before using `--force`. +- Prefer `--json` and inspect the returned resource after every mutation. +- Do not print or send payment secrets from `shop config get --group payment`; treat them as credentials. diff --git a/skills/halo-cli/SKILL.md b/skills/halo-cli/SKILL.md index ae4067c..29b0c1e 100644 --- a/skills/halo-cli/SKILL.md +++ b/skills/halo-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: halo-cli version: 1.0.0 -description: Use when the task involves Halo CLI, Halo CMS terminal operations, or any command-line interaction with Halo. This includes login, profiles, posts, single pages, search, plugins, themes, attachments, backups, moments, comments, notifications, or shell completion. Trigger this skill whenever the user mentions 'halo', 'Halo CLI', or wants to manage Halo from the terminal, even if the exact command area is unclear. +description: Use when the task involves Halo CLI, Halo CMS terminal operations, or any command-line interaction with Halo. This includes login, profiles, posts, single pages, search, plugins, themes, attachments, backups, moments, comments, notifications, shop operations, or shell completion. Trigger this skill whenever the user mentions 'halo', 'Halo CLI', or wants to manage Halo from the terminal, even if the exact command area is unclear. references: - ../halo-cli-shared - ../halo-cli-auth @@ -9,6 +9,7 @@ references: - ../halo-cli-search - ../halo-cli-operations - ../halo-cli-moderation-notifications + - ../halo-cli-shop metadata: openclaw: category: developer-tools @@ -31,6 +32,7 @@ If the request only says "use Halo CLI" or mixes multiple areas, start here, the - `halo-cli-search`: public site search - `halo-cli-operations`: themes, plugins, attachments, backups, moments - `halo-cli-moderation-notifications`: comments, replies, notifications +- `halo-cli-shop`: Halo Pro/Commercial shop products, orders, customers, coupons, discounts, statistics, and configuration ## Fast Routing @@ -49,6 +51,7 @@ halo backup --help halo moment --help halo comment --help halo notification --help +halo shop --help halo completion --help ``` diff --git a/src/cli.ts b/src/cli.ts index 0f5fe71..2b86697 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { import { registerPluginCommands, tryRunPluginCommand } from "./commands/plugin/index.js"; import { registerPostCommands, tryRunPostCommand } from "./commands/post/index.js"; import { registerSearchCommands, tryRunSearchCommand } from "./commands/search/index.js"; +import { registerShopCommands, tryRunShopCommand } from "./commands/shop/index.js"; import { registerSinglePageCommands, tryRunSinglePageCommand, @@ -73,6 +74,10 @@ const commandModules = [ register: registerNotificationCommands, tryRun: tryRunNotificationCommand, }, + { + register: registerShopCommands, + tryRun: tryRunShopCommand, + }, ] as const; for (const commandModule of commandModules) { diff --git a/src/commands/auth/__test__/auth-entry.spec.ts b/src/commands/auth/__test__/auth-entry.spec.ts index cf10469..0d3bc38 100644 --- a/src/commands/auth/__test__/auth-entry.spec.ts +++ b/src/commands/auth/__test__/auth-entry.spec.ts @@ -133,6 +133,8 @@ test("tryRunAuthCommand keeps auth login --json free of target banners", async ( metadata: { name: "admin" }, spec: { displayName: "Admin" }, }, + roles: [], + passwordSet: true, }, }), }, @@ -164,6 +166,56 @@ test("tryRunAuthCommand keeps auth login --json free of target banners", async ( expect(writeSpy).not.toHaveBeenCalledWith(expect.stringContaining("TARGET")); }); +test("tryRunAuthCommand rejects anonymous sessions instead of saving a broken profile", async () => { + silenceStdout(); + + const runtimeMock = { + configStore: { + getStoredProfile: vi.fn().mockResolvedValue(undefined), + upsertProfile: vi.fn(), + }, + getClientsForResolvedProfile: vi.fn().mockReturnValue({ + console: { + user: { + getCurrentUserDetail: vi.fn().mockResolvedValue({ + data: { + user: { + metadata: { name: "anonymousUser" }, + spec: { displayName: "Anonymous User" }, + }, + roles: [], + passwordSet: false, + }, + }), + }, + }, + }), + }; + + await expect( + tryRunAuthCommand( + [ + "auth", + "login", + "--profile", + "local", + "--url", + "https://demo.halo.run", + "--auth-type", + "basic", + "--username", + "admin", + "--password", + "wrong", + "--json", + ], + runtimeMock as never, + ), + ).rejects.toThrow("Authentication failed"); + + expect(runtimeMock.configStore.upsertProfile).not.toHaveBeenCalled(); +}); + test("tryRunAuthCommand shows help for bare profile subcommands", async () => { silenceStdout(); diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index a2a226a..5e7fb2c 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -75,7 +75,15 @@ async function validateProfile(profile: HaloProfile, runtime: RuntimeContext, js ); const clients = runtime.getClientsForResolvedProfile(profile); const response = await clients.console.user.getCurrentUserDetail(); - return response.data; + const user = response.data; + + if (!user.user?.metadata?.name || user.user.metadata.name === "anonymousUser") { + throw new CliError( + "Authentication failed. Check the username, password, or token, and verify that the selected authentication method is enabled on the Halo server.", + ); + } + + return user; } export function resolveAuthProfileUseName( diff --git a/src/commands/shop/__test__/cli.spec.ts b/src/commands/shop/__test__/cli.spec.ts new file mode 100644 index 0000000..5eb19ec --- /dev/null +++ b/src/commands/shop/__test__/cli.spec.ts @@ -0,0 +1,178 @@ +import { expect, test } from "vite-plus/test"; + +import { createShopCli } from "../cli.js"; + +interface CapturedOptions { + [key: string]: unknown; +} + +function buildCli() { + const cli = createShopCli("test"); + const command = cli.command("run [value]"); + + command.option("--json", "Output JSON"); + command.option("--all", "All"); + command.option("--force", "Force"); + command.option("--stdin", "Read stdin"); + command.option("--file ", "File"); + command.option("--images ", "Images"); + command.option("--sku ", "SKU"); + command.option("--price ", "Price"); + command.option("--track-inventory ", "Track inventory"); + command.option("--clear-note", "Clear note"); + command.option("--item ", "Item"); + + let captured: CapturedOptions | undefined; + command.action((...args: unknown[]) => { + captured = args.at(-1) as CapturedOptions; + }); + + return { + cli, + getOptions: () => captured, + }; +} + +test("createShopCli preserves numeric-looking and empty option values", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse( + ["node", "test", "run", "--sku", "0012", "--price", "", "--track-inventory", "0", "--json"], + { run: false }, + ); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ + sku: "0012", + price: "", + trackInventory: "0", + json: true, + }); +}); + +test("createShopCli supports --file - as stdin", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--file", "-"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ file: "-" }); +}); + +test("createShopCli keeps repeated options as arrays", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--item", "1:1:9", "--item", "2:1:9"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ item: ["1:1:9", "2:1:9"] }); +}); + +test("createShopCli rejects repeated non-repeatable value options", () => { + const { cli } = buildCli(); + + expect(() => + cli.parse(["node", "test", "run", "--images", "a.png", "--images", "b.png"], { run: false }), + ).toThrow("Option --images was provided multiple times."); +}); + +test("createShopCli keeps repeated boolean options as a single value", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--force=false", "--force=false", "--all", "--all"], { + run: false, + }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ force: false, all: true }); +}); + +test("createShopCli parses = values for hyphenated boolean options", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--clear-note=true", "--force=0"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ clearNote: true, force: false }); +}); + +test("createShopCli keeps boolean =false values as booleans", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--json=false", "--force=false", "--stdin=false"], { + run: false, + }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ json: false, force: false, stdin: false }); +}); + +test("createShopCli handles negated boolean options", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--no-json", "value"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ json: false }); + expect(cli.args).toEqual(["value"]); +}); + +test("createShopCli keeps empty = values and following positional arguments", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--images=", "value"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ images: "" }); + expect(cli.args).toEqual(["value"]); +}); + +test("createShopCli leaves arguments after -- unchanged", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--", "--file", "-"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ "--": ["--file", "-"] }); + expect(getOptions()).not.toHaveProperty("file"); +}); + +test("createShopCli does not consume positional arguments after hyphenated boolean flags", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--clear-note", "value"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ clearNote: true }); + expect(cli.args).toEqual(["value"]); +}); + +test("createShopCli preserves positional arguments after hyphenated boolean = values", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--clear-note=true", "value"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ clearNote: true }); + expect(cli.args).toEqual(["value"]); +}); + +test("createShopCli preserves positional arguments after negated boolean = values", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--no-clear-note=false", "value"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ clearNote: true }); + expect(cli.args).toEqual(["value"]); +}); + +test("createShopCli does not consume a positional argument for a boolean flag", async () => { + const { cli, getOptions } = buildCli(); + + cli.parse(["node", "test", "run", "--all", "value"], { run: false }); + await cli.runMatchedCommand(); + + expect(getOptions()).toMatchObject({ all: true }); + expect(cli.args).toEqual(["value"]); +}); diff --git a/src/commands/shop/__test__/format.spec.ts b/src/commands/shop/__test__/format.spec.ts new file mode 100644 index 0000000..cc81c74 --- /dev/null +++ b/src/commands/shop/__test__/format.spec.ts @@ -0,0 +1,172 @@ +import { afterEach, expect, test, vi } from "vite-plus/test"; + +import { + printConfig, + printCouponList, + printDailyStatistics, + printEntitlements, + printOrderList, + printProductList, +} from "../format.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function mockStdout() { + return vi.spyOn(process.stdout, "write").mockImplementation(() => true); +} + +test("printProductList writes JSON when json mode is enabled", () => { + const stdoutSpy = mockStdout(); + const page = { + content: [{ id: 1, title: "Example", productType: "VIRTUAL", status: "DRAFT" }], + totalElements: 1, + totalPages: 1, + number: 0, + size: 20, + first: true, + last: true, + }; + + printProductList(page as never, true); + + expect(stdoutSpy).toHaveBeenCalledOnce(); + expect(stdoutSpy).toHaveBeenCalledWith(`${JSON.stringify(page, null, 2)}\n`); +}); + +test("printProductList renders a table and pagination footer", () => { + const stdoutSpy = mockStdout(); + + printProductList( + { + content: [ + { + id: 1, + title: "Example", + productType: "PHYSICAL", + status: "PUBLISHED", + minPrice: 9.9, + maxPrice: 19.9, + categoryId: 3, + }, + ], + totalElements: 1, + totalPages: 1, + number: 0, + size: 20, + first: true, + last: true, + } as never, + false, + ); + + const tableOutput = String(stdoutSpy.mock.calls[0]?.[0]); + const footerOutput = String(stdoutSpy.mock.calls[1]?.[0]); + + expect(tableOutput).toContain("TITLE"); + expect(tableOutput).toContain("Example"); + expect(tableOutput).toContain("9.90 ~ 19.90"); + expect(footerOutput).toContain("1 product(s)"); +}); + +test("printOrderList renders payment and fulfillment statuses", () => { + const stdoutSpy = mockStdout(); + + printOrderList( + { + content: [ + { + id: 1001, + orderCode: "ORD-1", + status: "OPEN", + paymentStatus: "PENDING", + fulfillmentStatus: "PENDING", + payableAmount: 88, + }, + ], + totalElements: 1, + totalPages: 1, + number: 0, + size: 20, + first: true, + last: true, + } as never, + false, + ); + + const output = String(stdoutSpy.mock.calls[0]?.[0]); + expect(output).toContain("ORD-1"); + expect(output).toContain("PENDING"); + expect(output).toContain("88.00"); +}); + +test("printCouponList formats percentage and amount values", () => { + const stdoutSpy = mockStdout(); + + printCouponList( + { + content: [ + { + id: 1, + name: "Ten percent", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + isEnabled: true, + issuedCount: 2, + totalCount: 10, + }, + { + id: 2, + name: "Five off", + calculationType: "AMOUNT", + discountValue: 5, + scope: "QUALIFYING_ITEMS", + isEnabled: false, + issuedCount: 1, + }, + ], + totalElements: 2, + totalPages: 1, + number: 0, + size: 20, + first: true, + last: true, + } as never, + false, + ); + + const output = String(stdoutSpy.mock.calls[0]?.[0]); + expect(output).toContain("10%"); + expect(output).toContain("5.00"); + expect(output).toContain("2/10"); +}); + +test("printDailyStatistics writes JSON when json mode is enabled", () => { + const stdoutSpy = mockStdout(); + const statistics = [ + { date: "2026-01-01", orders: 2, sales: 99.5, productsSold: 3, customers: 1 }, + ]; + + printDailyStatistics(statistics, true); + + expect(stdoutSpy).toHaveBeenCalledWith(`${JSON.stringify(statistics, null, 2)}\n`); +}); + +test("printEntitlements parses JSON strings", () => { + const stdoutSpy = mockStdout(); + + printEntitlements('{"vip":true}', true); + + expect(stdoutSpy).toHaveBeenCalledWith(`${JSON.stringify({ vip: true }, null, 2)}\n`); +}); + +test("printConfig writes JSON when json mode is enabled", () => { + const stdoutSpy = mockStdout(); + const config = { guestCheckoutEnabled: true }; + + printConfig(config, true); + + expect(stdoutSpy).toHaveBeenCalledWith(`${JSON.stringify(config, null, 2)}\n`); +}); diff --git a/src/commands/shop/__test__/input.spec.ts b/src/commands/shop/__test__/input.spec.ts new file mode 100644 index 0000000..dcd0666 --- /dev/null +++ b/src/commands/shop/__test__/input.spec.ts @@ -0,0 +1,120 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, test } from "vite-plus/test"; + +import { CliError } from "../../../utils/errors.js"; +import { + mergeDefined, + parseEnumListOption, + parseEnumOption, + parseMoneyOption, + parseNumberListOption, + parsePageOption, + parsePositiveInteger, + readJsonPayload, + requireString, + toStringArray, +} from "../input.js"; + +const tempDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function writeTempJson(content: string): Promise { + const directory = await mkdtemp(join(tmpdir(), "halo-shop-")); + tempDirectories.push(directory); + const file = join(directory, "payload.json"); + await writeFile(file, content, "utf8"); + return file; +} + +test("readJsonPayload returns undefined without file input", async () => { + await expect(readJsonPayload({})).resolves.toBeUndefined(); +}); + +test("readJsonPayload reads an object from a file", async () => { + const file = await writeTempJson('{"title":"Example"}'); + + await expect(readJsonPayload({ file })).resolves.toEqual({ title: "Example" }); +}); + +test("readJsonPayload rejects invalid JSON", async () => { + const file = await writeTempJson("{"); + + await expect(readJsonPayload({ file })).rejects.toThrow(CliError); + await expect(readJsonPayload({ file })).rejects.toThrow("Invalid JSON input"); +}); + +test("readJsonPayload rejects non-object JSON", async () => { + const file = await writeTempJson("[]"); + + await expect(readJsonPayload({ file })).rejects.toThrow("must be an object"); +}); + +test("readJsonPayload rejects an empty file path", async () => { + await expect(readJsonPayload({ file: "" })).rejects.toThrow("The --file path is empty."); +}); + +test("readJsonPayload rejects empty input", async () => { + const file = await writeTempJson(" "); + + await expect(readJsonPayload({ file })).rejects.toThrow("empty"); +}); + +test("mergeDefined keeps base values and applies defined overrides", () => { + expect(mergeDefined({ a: 1, b: 2 }, { a: undefined, b: 3 })).toEqual({ a: 1, b: 3 }); +}); + +test("parsePositiveInteger and parsePageOption validate integers", () => { + expect(parsePositiveInteger("12", "ID")).toBe(12); + expect(parsePageOption("1")).toBe(1); + expect(() => parsePositiveInteger("0", "ID")).toThrow(CliError); + expect(() => parsePageOption("1.5")).toThrow(CliError); +}); + +test("parseEnumOption validates and normalizes enums", () => { + expect(parseEnumOption("draft", ["DRAFT", "PUBLISHED"] as const, "--status")).toBe("DRAFT"); + expect(parseEnumOption(undefined, ["DRAFT"] as const, "--status")).toBeUndefined(); + expect(() => parseEnumOption("bad", ["DRAFT"] as const, "--status")).toThrow("Invalid --status"); +}); + +test("parseEnumListOption and parseNumberListOption parse CSV values", () => { + expect( + parseEnumListOption("DRAFT, published", ["DRAFT", "PUBLISHED"] as const, "--status"), + ).toEqual(["DRAFT", "PUBLISHED"]); + expect(parseNumberListOption("1, 2,3")).toEqual([1, 2, 3]); + expect(parseNumberListOption(undefined)).toBeUndefined(); +}); + +test("parseMoneyOption validates non-negative numbers", () => { + expect(parseMoneyOption("9.9", "--price")).toBe(9.9); + expect(parseMoneyOption(undefined, "--price")).toBeUndefined(); + expect(() => parseMoneyOption("", "--price")).toThrow("requires a non-empty value"); + expect(() => parseMoneyOption("-1", "--price")).toThrow(CliError); + expect(() => parseMoneyOption("abc", "--price")).toThrow(CliError); +}); + +test("toStringArray normalizes repeated options", () => { + expect(toStringArray(undefined)).toEqual([]); + expect(toStringArray("a")).toEqual(["a"]); + expect(toStringArray(["a", "b"])).toEqual(["a", "b"]); + expect(toStringArray(123)).toEqual(["123"]); +}); + +test("requireString accepts numeric option values parsed by cac", () => { + expect(requireString(35, "required")).toBe("35"); + expect(() => requireString(undefined, "required")).toThrow("required"); + expect(() => requireString(" ", "required")).toThrow("required"); +}); + +test("string helpers tolerate numeric option values", () => { + expect(parseNumberListOption(1)).toEqual([1]); + expect(() => parseEnumListOption(1, ["DRAFT"] as const, "--status")).toThrow("Invalid --status"); + expect(() => parseEnumOption(1, ["DRAFT"] as const, "--status")).toThrow("Invalid --status"); +}); diff --git a/src/commands/shop/__test__/shop-entry.spec.ts b/src/commands/shop/__test__/shop-entry.spec.ts new file mode 100644 index 0000000..02e3246 --- /dev/null +++ b/src/commands/shop/__test__/shop-entry.spec.ts @@ -0,0 +1,2642 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, test, vi } from "vite-plus/test"; + +import { CliError } from "../../../utils/errors.js"; +import { tryRunShopCommand } from "../index.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function writeJsonFile( + content: unknown, +): Promise<{ file: string; cleanup: () => Promise }> { + const directory = await mkdtemp(join(tmpdir(), "halo-shop-")); + const file = join(directory, "payload.json"); + await writeFile(file, JSON.stringify(content), "utf8"); + return { file, cleanup: () => rm(directory, { recursive: true, force: true }) }; +} + +function silenceStdout() { + return vi.spyOn(process.stdout, "write").mockImplementation(() => true); +} + +function createRuntimeMock(axios: Record) { + return { + getClientsForOptions: vi.fn().mockResolvedValue({ + profile: { + name: "local", + baseUrl: "https://example.com", + }, + clients: { + axios, + }, + }), + }; +} + +function readStdout(spy: { mock: { calls: unknown[][] } }): string { + return spy.mock.calls.map((call) => String(call[0] as string | undefined)).join(""); +} + +test("tryRunShopCommand returns false for unrelated commands", async () => { + await expect(tryRunShopCommand(["post"], {} as never)).resolves.toBe(false); +}); + +test("tryRunShopCommand shows help for bare shop commands", async () => { + silenceStdout(); + + await expect(tryRunShopCommand(["shop"], {} as never)).resolves.toBe(true); +}); + +test("tryRunShopCommand shows help for shop groups", async () => { + silenceStdout(); + + await expect(tryRunShopCommand(["shop", "product"], {} as never)).resolves.toBe(true); +}); + +test("tryRunShopCommand rejects unknown shop commands", async () => { + silenceStdout(); + + await expect(tryRunShopCommand(["shop", "unknown"], {} as never)).rejects.toThrow(CliError); +}); + +test("shop status reports edition, activation, and API availability", async () => { + const stdoutSpy = silenceStdout(); + + const get = vi.fn((url: string) => { + if (url === "/actuator/info") { + return Promise.resolve({ + data: { build: { name: "halo-pro", version: "2.28.0" } }, + }); + } + + return Promise.resolve({ + data: [ + { + spec: { productName: "product-halo-commercial" }, + status: { state: "active" }, + }, + ], + }); + }); + const request = vi.fn().mockResolvedValue({ data: { content: [], totalElements: 0 } }); + const runtime = createRuntimeMock({ get, request }); + + await expect(tryRunShopCommand(["shop", "status", "--json"], runtime as never)).resolves.toBe( + true, + ); + + expect(JSON.parse(readStdout(stdoutSpy))).toEqual({ + profile: "local", + baseUrl: "https://example.com", + edition: "halo-pro", + version: "2.28.0", + commercialActivated: true, + activationState: "active", + shopApiAvailable: true, + }); +}); + +test("shop status recognizes a Halo Pro activation", async () => { + const stdoutSpy = silenceStdout(); + + const get = vi.fn((url: string) => { + if (url === "/actuator/info") { + return Promise.resolve({ data: { build: { name: "halo-pro" } } }); + } + + return Promise.resolve({ + data: [ + { + spec: { productName: "product-halo-commercial" }, + status: { state: "inactive" }, + }, + { + spec: { productName: "product-halo-pro" }, + status: { state: "active" }, + }, + ], + }); + }); + const request = vi.fn().mockResolvedValue({ data: { content: [], totalElements: 0 } }); + const runtime = createRuntimeMock({ get, request }); + + await expect(tryRunShopCommand(["shop", "status", "--json"], runtime as never)).resolves.toBe( + true, + ); + + expect(JSON.parse(readStdout(stdoutSpy))).toMatchObject({ + commercialActivated: true, + activationState: "active", + }); +}); + +function notFoundError() { + return Object.assign(new Error("not found"), { + isAxiosError: true, + response: { status: 404, data: { detail: "No static resource products" } }, + }); +} + +test("shop status tolerates missing edition and license APIs", async () => { + const stdoutSpy = silenceStdout(); + + const get = vi.fn().mockRejectedValue(notFoundError()); + const request = vi.fn().mockRejectedValue(notFoundError()); + const runtime = createRuntimeMock({ get, request }); + + await expect(tryRunShopCommand(["shop", "status", "--json"], runtime as never)).resolves.toBe( + true, + ); + + expect(JSON.parse(readStdout(stdoutSpy))).toEqual({ + profile: "local", + baseUrl: "https://example.com", + edition: "unknown", + commercialActivated: false, + shopApiAvailable: false, + }); +}); + +test("shop status surfaces authentication failures", async () => { + silenceStdout(); + + const unauthorized = Object.assign(new Error("401 Unauthorized"), { + isAxiosError: true, + response: { status: 401 }, + }); + const runtime = createRuntimeMock({ + get: vi.fn().mockRejectedValue(unauthorized), + request: vi.fn().mockRejectedValue(unauthorized), + }); + + await expect(tryRunShopCommand(["shop", "status", "--json"], runtime as never)).rejects.toThrow( + "401 Unauthorized", + ); +}); + +test("shop product create posts the request and prints JSON", async () => { + const stdoutSpy = silenceStdout(); + + const product = { + id: 7, + title: "Example", + productType: "VIRTUAL", + status: "DRAFT", + permalink: "/products/example", + }; + const request = vi.fn().mockResolvedValue({ data: product }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "product", + "create", + "--title", + "Example", + "--type", + "VIRTUAL", + "--price", + "9.9", + "--json", + ], + runtime as never, + ), + ).resolves.toBe(true); + + expect(JSON.parse(readStdout(stdoutSpy))).toEqual(product); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/products"), + data: expect.objectContaining({ + title: "Example", + productType: "VIRTUAL", + status: "DRAFT", + productVariants: [ + expect.objectContaining({ + price: 9.9, + status: "DRAFT", + }), + ], + }), + }), + ); +}); + +test("shop product create defaults a new variant to draft", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: { id: 8 } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "product", + "create", + "--title", + "Published product", + "--status", + "PUBLISHED", + "--price", + "9.9", + "--json", + ], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "PUBLISHED", + productVariants: [expect.objectContaining({ price: 9.9, status: "DRAFT" })], + }), + }), + ); +}); + +test("shop product update preserves existing variant status and drafts new variants", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 10 }, { price: 20 }], + }); + + try { + const current = { + id: 7, + title: "Example", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ + data: { + ...current, + productVariants: [ + { id: 11, price: 10, status: "PUBLISHED" }, + { id: 12, price: 20, status: "DRAFT" }, + ], + }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/specifications"), + data: expect.objectContaining({ + productVariants: [ + expect.objectContaining({ id: 11, status: "PUBLISHED" }), + expect.objectContaining({ price: 20, status: "DRAFT" }), + ], + }), + }), + ); + } finally { + await cleanup(); + } +}); + +test("shop product create requires a title", async () => { + silenceStdout(); + + const runtime = createRuntimeMock({ request: vi.fn() }); + + await expect( + tryRunShopCommand(["shop", "product", "create", "--json"], runtime as never), + ).rejects.toThrow("Product title is required"); +}); + +test("shop product update merges flags with the current product", async () => { + const stdoutSpy = silenceStdout(); + + const current = { + id: 7, + title: "Old title", + handle: "old-title", + productType: "PHYSICAL", + status: "DRAFT", + content: "Body", + images: ["https://example.com/a.png"], + productVariants: [ + { + id: 11, + price: 10, + stock: 3, + trackInventory: true, + status: "PUBLISHED", + specValues: [], + }, + ], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ + data: { ...current, title: "New title" }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--title", "New title", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(JSON.parse(readStdout(stdoutSpy))).toMatchObject({ title: "New title" }); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: "GET", url: expect.stringContaining("/products/7") }), + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/basic-info"), + data: { + title: "New title", + handle: "old-title", + description: undefined, + categoryId: undefined, + }, + }), + ); +}); + +test("shop order create requires items", async () => { + silenceStdout(); + + const runtime = createRuntimeMock({ request: vi.fn() }); + + await expect( + tryRunShopCommand( + ["shop", "order", "create", "--username", "demo", "--json"], + runtime as never, + ), + ).rejects.toThrow("At least one order item is required"); +}); + +test("shop order fulfill requires confirmation", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "fulfill", "1001", "--item", "1:1", "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop order fulfill --all fills unfulfilled quantities", async () => { + const stdoutSpy = silenceStdout(); + + const order = { + id: 1001, + status: "OPEN", + items: [ + { id: 1, quantity: 3, refundedQuantity: 0, fulfilledQuantity: 1 }, + { id: 2, quantity: 2, refundedQuantity: 2, fulfilledQuantity: 0 }, + ], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: order }) + .mockResolvedValueOnce({ data: { id: 55 } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "fulfill", "1001", "--all", "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(JSON.parse(readStdout(stdoutSpy))).toEqual({ id: 55 }); + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/orders/1001/request-fulfillment"), + data: { + items: [ + { orderItemId: 1, quantity: 2 }, + { orderItemId: 2, quantity: 2 }, + ], + }, + }), + ); +}); + +test("shop order list --order-code also searches by keyword", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ + data: { content: [], totalElements: 0, totalPages: 0, number: 0, size: 20 }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "order", "list", "--order-code", "ORDER-CODE-1"], runtime as never), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + keyword: "ORDER-CODE-1", + orderCode: "ORDER-CODE-1", + }), + }), + ); +}); + +test("shop coupon distribute by username sends allCustomers false", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "coupon", "distribute", "3", "--username", "admin", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/coupons/3/auto-distribute"), + data: { allCustomers: false, usernames: ["admin"] }, + }), + ); +}); + +test("shop customer update --clear-note sends an empty note", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ + data: { id: 7, note: null, status: "ACTIVE" }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "customer", "update", "7", "--clear-note", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PATCH", + url: expect.stringContaining("/customers/7"), + data: { note: "" }, + }), + ); +}); + +test("shop customer update accepts --clear-note before the id", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ + data: { id: 7, note: null, status: "ACTIVE" }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "customer", "update", "--clear-note", "7", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PATCH", + url: expect.stringContaining("/customers/7"), + data: { note: "" }, + }), + ); +}); + +test("shop resource get treats an empty response as not found", async () => { + silenceStdout(); + + const runtime = createRuntimeMock({ request: vi.fn().mockResolvedValue({ data: "" }) }); + + await expect( + tryRunShopCommand(["shop", "product", "get", "999999"], runtime as never), + ).rejects.toThrow("Product 999999 was not found."); +}); + +test("shop config set prints a fallback when the API returns no body", async () => { + const stdoutSpy = silenceStdout(); + + const request = vi + .fn() + .mockResolvedValueOnce({ data: { guestCheckoutEnabled: true } }) + .mockResolvedValueOnce({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "config", "set", "--guest-checkout", "false", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(JSON.parse(readStdout(stdoutSpy))).toEqual({ updated: true }); +}); + +test("shop customer update requires at least one field", async () => { + silenceStdout(); + + const runtime = createRuntimeMock({ request: vi.fn() }); + + await expect( + tryRunShopCommand(["shop", "customer", "update", "1"], runtime as never), + ).rejects.toThrow("Provide at least one option"); +}); + +test("shop stats daily validates date options", async () => { + silenceStdout(); + + const runtime = createRuntimeMock({ request: vi.fn() }); + + await expect( + tryRunShopCommand(["shop", "stats", "daily", "--start", "2026/01/01"], runtime as never), + ).rejects.toThrow("yyyy-MM-dd"); +}); + +test("shop order list forwards repeated filters", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ + data: { content: [], totalElements: 0, totalPages: 0, number: 0, size: 20 }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "order", + "list", + "--status", + "OPEN,CANCELLED", + "--payment-status", + "PAID", + "--customer", + "9", + ], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: expect.stringContaining("/orders"), + params: expect.objectContaining({ + page: 1, + size: 20, + statuses: ["OPEN", "CANCELLED"], + paymentStatuses: ["PAID"], + customerId: 9, + }), + }), + ); +}); + +test("shop coupon create maps marketing flags", async () => { + const stdoutSpy = silenceStdout(); + + const coupon = { + id: 3, + name: "Spring sale", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + validDays: 30, + }; + const request = vi.fn().mockResolvedValue({ data: coupon }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "coupon", + "create", + "--name", + "Spring sale", + "--calculation-type", + "PERCENTAGE", + "--discount-value", + "10", + "--scope", + "ALL", + "--valid-days", + "30", + "--json", + ], + runtime as never, + ), + ).resolves.toBe(true); + + expect(JSON.parse(readStdout(stdoutSpy))).toEqual(coupon); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/coupons"), + data: expect.objectContaining({ + name: "Spring sale", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + validDays: 30, + }), + }), + ); +}); + +test("shop discount create requires an application type", async () => { + silenceStdout(); + + const runtime = createRuntimeMock({ request: vi.fn() }); + + await expect( + tryRunShopCommand( + [ + "shop", + "discount", + "create", + "--name", + "Ten percent", + "--calculation-type", + "PERCENTAGE", + "--discount-value", + "10", + "--scope", + "ALL", + ], + runtime as never, + ), + ).rejects.toThrow("Discount application is required"); +}); + +test("shop discount create rejects a percentage above 100", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "discount", + "create", + "--name", + "Too much", + "--application", + "AUTOMATIC", + "--calculation-type", + "PERCENTAGE", + "--discount-value", + "101", + "--scope", + "ALL", + "--json", + ], + runtime as never, + ), + ).rejects.toThrow("between 0 and 100"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop discount create rejects a non-scalar discount value", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + name: "Invalid value", + application: "AUTOMATIC", + calculationType: "AMOUNT", + discountValue: [10], + scope: "ALL", + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "discount", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("Discount value must be a non-negative number"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop config set rejects --group combined with shop settings", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "config", "set", "--group", "payment", "--guest-checkout", "true", "--json"], + runtime as never, + ), + ).rejects.toThrow("--group cannot be combined with shop setting options"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop stats daily rejects an invalid calendar date", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "stats", "daily", "--start", "2026-02-31", "--json"], + runtime as never, + ), + ).rejects.toThrow("valid date"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop config set merges shop settings over the current config", async () => { + silenceStdout(); + + const request = vi + .fn() + .mockResolvedValueOnce({ + data: { + contacts: [{ label: "Email", type: "LINK", linkUrl: "mailto:hi@example.com" }], + guestCheckoutEnabled: false, + }, + }) + .mockResolvedValueOnce({ + data: { + contacts: [{ label: "Email", type: "LINK", linkUrl: "mailto:hi@example.com" }], + guestCheckoutEnabled: true, + }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "config", "set", "--guest-checkout", "true", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/ecommerceconfigs/shop"), + data: expect.objectContaining({ + guestCheckoutEnabled: true, + contacts: [expect.objectContaining({ label: "Email" })], + }), + }), + ); +}); + +test("shop product delete --force=false still requires confirmation", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "delete", "7", "--force=false", "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop product create preserves raw string identifiers and boolean values", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: { id: 8 } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "product", + "create", + "--title", + "Raw values", + "--price", + "9.9", + "--sku", + "0012", + "--barcode", + "000123", + "--track-inventory", + "0", + "--json", + ], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + productVariants: [ + expect.objectContaining({ + skuCode: "0012", + barcode: "000123", + trackInventory: false, + }), + ], + }), + }), + ); +}); + +test("shop product create rejects an empty price", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "create", "--title", "Empty price", "--price", "", "--json"], + runtime as never, + ), + ).rejects.toThrow("--price requires a non-empty value."); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop product update accepts an explicitly empty image list", async () => { + const stdoutSpy = silenceStdout(); + + const current = { + id: 7, + title: "Example", + productType: "PHYSICAL", + status: "DRAFT", + images: ["https://example.com/a.png"], + productVariants: [], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, images: [] } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--images", "", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(JSON.parse(readStdout(stdoutSpy))).toMatchObject({ images: [] }); + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/content"), + data: expect.objectContaining({ images: [] }), + }), + ); +}); + +test("shop product create rejects file variants without a price", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + title: "License", + productType: "VIRTUAL", + status: "PUBLISHED", + productVariants: [{ skuCode: "LICENSE" }], + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "product", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("requires a price for FIXED pricing"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop product create rejects a negative file variant price", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + title: "License", + productType: "VIRTUAL", + status: "DRAFT", + productVariants: [{ price: -1 }], + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "product", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("price must be a non-negative number"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop product update rejects a negative file variant stock before writing", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 10, stock: -2 }], + }); + + try { + const current = { + id: 7, + title: "Example", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [{ id: 11, price: 10, stock: 3, status: "PUBLISHED" }], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("stock must be a non-negative integer"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop product update rejects a non-array variant payload before writing", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + title: "New title", + productVariants: {}, + }); + + try { + const current = { + id: 7, + title: "Old title", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--price", "10", "--json"], + runtime as never, + ), + ).rejects.toThrow("productVariants must be an array"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop coupon create rejects a negative valid days value from a file", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + name: "Expired immediately", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + validDays: -1, + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "coupon", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("Coupon valid days must be a positive integer"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop discount update clears the code when switching to AUTOMATIC", async () => { + silenceStdout(); + + const current = { + id: 3, + name: "Ten percent", + application: "CODE", + code: "SAVE10", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + isEnabled: true, + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, application: "AUTOMATIC", code: undefined } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "discount", "update", "3", "--application", "AUTOMATIC", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/discounts/3"), + data: expect.objectContaining({ application: "AUTOMATIC", code: undefined }), + }), + ); +}); + +test("shop order create rejects a negative file item price", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + username: "demo", + items: [{ productVariantId: 1, quantity: 1, customPrice: -10 }], + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "order", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("must be a non-negative number"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop order fulfill rejects the unavailable VIRTUAL provider", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "fulfill", "1001", "--item", "1:1", "--provider", "VIRTUAL", "--json"], + runtime as never, + ), + ).rejects.toThrow("Invalid --provider"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop order create rejects boolean file item fields", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + username: "demo", + items: [{ productVariantId: true, quantity: true, customPrice: 0 }], + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "order", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("must be a number"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop order create rejects array file item prices", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + username: "demo", + items: [{ productVariantId: 1, quantity: 1, customPrice: [0] }], + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "order", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("must be a number"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop order fulfill treats a null provider as the default", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + provider: null, + items: [{ orderItemId: 1, quantity: 1 }], + }); + + try { + const request = vi.fn().mockResolvedValueOnce({ data: { id: 55 } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "fulfill", "1001", "--file", file, "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/orders/1001/request-fulfillment"), + }), + ); + } finally { + await cleanup(); + } +}); + +test("shop order create rejects an empty item price", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "create", "--username", "demo", "--item", "1:1:", "--json"], + runtime as never, + ), + ).rejects.toThrow('Unit price is required in --item "1:1:"'); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop order fulfill --all skips subscription lines", async () => { + const stdoutSpy = silenceStdout(); + + const order = { + id: 1001, + status: "OPEN", + items: [ + { + id: 1, + quantity: 2, + refundedQuantity: 0, + fulfilledQuantity: 0, + subscriptionMetadata: { type: "PLAN" }, + }, + { id: 2, quantity: 1, refundedQuantity: 0, fulfilledQuantity: 0 }, + ], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: order }) + .mockResolvedValueOnce({ data: { id: 55 } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "fulfill", "1001", "--all", "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(JSON.parse(readStdout(stdoutSpy))).toEqual({ id: 55 }); + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/orders/1001/request-fulfillment"), + data: { items: [{ orderItemId: 2, quantity: 1 }] }, + }), + ); +}); + +test("shop coupon distribute reports that distribution is scheduled", async () => { + const stdoutSpy = silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "coupon", "distribute", "3", "--username", "admin"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(readStdout(stdoutSpy)).toContain("Scheduled coupon 3 distribution"); +}); + +test("shop coupon distribute --all requires confirmation", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "coupon", "distribute", "3", "--all", "--json"], runtime as never), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop coupon distribute --all --force sends the request", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "coupon", "distribute", "3", "--all", "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/coupons/3/auto-distribute"), + data: { allCustomers: true }, + }), + ); +}); + +test("shop product unpublish requires confirmation", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "product", "unpublish", "7", "--json"], runtime as never), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop product unpublish --force updates the status", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: { id: 7, status: "DRAFT" } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "product", "unpublish", "7", "--force", "--json"], runtime as never), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/status"), + data: { status: "DRAFT" }, + }), + ); +}); + +test("shop product update requires confirmation when unpublishing", async () => { + silenceStdout(); + + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--status", "DRAFT", "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "GET" })); +}); + +test("shop product update --force allows unpublishing", async () => { + silenceStdout(); + + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, status: "DRAFT" } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--status", "DRAFT", "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7"), + data: expect.objectContaining({ status: "DRAFT" }), + }), + ); +}); + +test("shop product update re-reads stock after confirmation", async () => { + silenceStdout(); + + const initial = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [{ id: 11, price: 10, stock: 3, status: "PUBLISHED" }], + }; + const refreshed = { + ...initial, + productVariants: [{ id: 11, price: 10, stock: 1, status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: initial }) + .mockResolvedValueOnce({ data: refreshed }) + .mockResolvedValueOnce({ + data: { + ...refreshed, + productVariants: [{ id: 11, price: 10, stock: 1, status: "DRAFT" }], + }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "product", + "update", + "7", + "--variant", + "11", + "--variant-status", + "DRAFT", + "--force", + "--json", + ], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/specifications"), + data: expect.objectContaining({ + productVariants: [expect.objectContaining({ id: 11, stock: 1, status: "DRAFT" })], + }), + }), + ); +}); + +test("shop product update merges partial file variants with current fields", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 12 }], + }); + + try { + const current = { + id: 7, + title: "Draft product", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [{ id: 11, price: 10, stock: 5, skuCode: "SKU-11", status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ + data: { + ...current, + productVariants: [ + { id: 11, price: 12, stock: 5, skuCode: "SKU-11", status: "PUBLISHED" }, + ], + }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/specifications"), + data: expect.objectContaining({ + productVariants: [ + expect.objectContaining({ + id: 11, + price: 12, + stock: 5, + skuCode: "SKU-11", + status: "PUBLISHED", + }), + ], + }), + }), + ); + } finally { + await cleanup(); + } +}); + +test("shop product create rejects external link fields for non-external products", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "product", + "create", + "--title", + "Physical product", + "--external-url", + "https://example.com/product", + "--json", + ], + runtime as never, + ), + ).rejects.toThrow("External link fields are only supported for EXTERNAL products"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop product create rejects a non-array variant payload before applying flags", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ productVariants: {} }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "product", + "create", + "--title", + "Example", + "--file", + file, + "--price", + "10", + "--json", + ], + runtime as never, + ), + ).rejects.toThrow("productVariants must be an array"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop product update re-reads variants before an unconfirmed specifications update", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 12 }], + }); + + try { + const current = { + id: 7, + title: "Draft product", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [{ id: 11, price: 10, stock: 5, status: "PUBLISHED" }], + }; + const refreshed = { + ...current, + productVariants: [{ id: 11, price: 10, stock: 2, status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: refreshed }) + .mockResolvedValueOnce({ data: refreshed }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/specifications"), + data: expect.objectContaining({ + productVariants: [expect.objectContaining({ id: 11, price: 12, stock: 2 })], + }), + }), + ); + } finally { + await cleanup(); + } +}); + +test("shop product update rejects external link fields for non-external products", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + externalLinkUrl: "https://example.com/product", + }); + + try { + const current = { + id: 7, + title: "Physical product", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("External link fields are only supported for EXTERNAL products"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop product update with only a title change does not require confirmation", async () => { + silenceStdout(); + + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, title: "Renamed product" } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--title", "Renamed product", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/basic-info"), + data: expect.objectContaining({ title: "Renamed product" }), + }), + ); +}); + +test("shop product update requires confirmation when disabling a published variant", async () => { + silenceStdout(); + + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--variant", "11", "--variant-status", "DRAFT", "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); +}); + +test("shop product update --force allows disabling a published variant", async () => { + silenceStdout(); + + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ + data: { + ...current, + productVariants: [{ id: 11, price: 10, status: "DRAFT" }], + }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "product", + "update", + "7", + "--variant", + "11", + "--variant-status", + "DRAFT", + "--force", + "--json", + ], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/specifications"), + data: expect.objectContaining({ + productVariants: [expect.objectContaining({ id: 11, status: "DRAFT" })], + }), + }), + ); +}); + +test("shop product update requires confirmation when a published variant is removed via --file", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ productVariants: [] }); + + try { + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [ + { id: 11, price: 10, status: "PUBLISHED" }, + { id: 12, price: 20, status: "PUBLISHED" }, + ], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop product update requires confirmation when one published variant is removed", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }); + + try { + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [ + { id: 11, price: 10, status: "PUBLISHED" }, + { id: 12, price: 20, status: "PUBLISHED" }, + ], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop product update requires confirmation for lowercase variant status via --file", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 10, status: "draft" }], + }); + + try { + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop product update --force normalizes variant status from --file", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 10, status: "draft" }], + }); + + try { + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ + data: { + ...current, + productVariants: [{ id: 11, price: 10, status: "DRAFT" }], + }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7"), + data: expect.objectContaining({ + productVariants: [expect.objectContaining({ id: 11, status: "DRAFT" })], + }), + }), + ); + } finally { + await cleanup(); + } +}); + +test("shop product update treats a null variant list as removal", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ productVariants: null }); + + try { + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop product update ignores variant lists for subscription products", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + title: "Renamed plan", + productVariants: [], + }); + + try { + const current = { + id: 7, + title: "Plan", + productType: "SUBSCRIPTION", + status: "PUBLISHED", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, title: "Renamed plan" } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7"), + data: expect.objectContaining({ title: "Renamed plan" }), + }), + ); + } finally { + await cleanup(); + } +}); + +test("shop product update rejects variants that belong to another product", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + title: "New title", + productVariants: [{ id: 999, price: 10, status: "PUBLISHED" }], + }); + + try { + const current = { + id: 7, + title: "Old title", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("Variant 999 was not found on this product"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop product update accepts a null variant id as a new variant", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: null, price: 10, status: "DRAFT" }], + }); + + try { + const current = { + id: 7, + title: "Draft product", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ + data: { + ...current, + productVariants: [{ id: 12, price: 10, status: "DRAFT" }], + }, + }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/products/7/specifications"), + data: expect.objectContaining({ + productVariants: [expect.objectContaining({ id: null, price: 10 })], + }), + }), + ); + } finally { + await cleanup(); + } +}); + +test("shop product update reports domains applied before a later failure", async () => { + silenceStdout(); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + const { file, cleanup } = await writeJsonFile({ + title: "New title", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }); + + try { + const current = { + id: 7, + title: "Old title", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, title: "New title" } }) + .mockRejectedValueOnce(new Error("spec failed")); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("spec failed"); + + expect(readStdout(stderr)).toContain("failed during specifications"); + expect(readStdout(stderr)).toContain("Applied domains: basic-info"); + expect(readStdout(stderr)).toContain("may have been partially applied"); + } finally { + await cleanup(); + } +}); + +test("shop coupon update disabling via --file requires confirmation", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ isEnabled: "false" }); + + try { + const current = { + id: 3, + name: "Spring sale", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + validDays: 30, + isEnabled: true, + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "coupon", "update", "3", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop discount update disabling via --file requires confirmation", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ isEnabled: "false" }); + + try { + const current = { + id: 3, + name: "Ten percent", + application: "AUTOMATIC", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + isEnabled: true, + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "discount", "update", "3", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + +test("shop coupon update disabling requires confirmation", async () => { + silenceStdout(); + + const current = { + id: 3, + name: "Spring sale", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + validDays: 30, + isEnabled: true, + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "coupon", "update", "3", "--enabled", "false", "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); +}); + +test("shop coupon update --force allows disabling", async () => { + silenceStdout(); + + const current = { + id: 3, + name: "Spring sale", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + validDays: 30, + isEnabled: true, + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, isEnabled: false } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "coupon", "update", "3", "--enabled", "false", "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/coupons/3"), + data: expect.objectContaining({ isEnabled: false }), + }), + ); +}); + +test("shop discount update disabling requires confirmation", async () => { + silenceStdout(); + + const current = { + id: 3, + name: "Ten percent", + application: "AUTOMATIC", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + isEnabled: true, + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "discount", "update", "3", "--enabled", "false", "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); +}); + +test("shop discount update --force allows disabling", async () => { + silenceStdout(); + + const current = { + id: 3, + name: "Ten percent", + application: "AUTOMATIC", + calculationType: "PERCENTAGE", + discountValue: 10, + scope: "ALL", + isEnabled: true, + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, isEnabled: false } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "discount", "update", "3", "--enabled", "false", "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/discounts/3"), + data: expect.objectContaining({ isEnabled: false }), + }), + ); +}); + +test("shop customer update --status BLOCKED requires confirmation", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "customer", "update", "7", "--status", "BLOCKED", "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop customer update --status BLOCKED --force sends the request", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: { id: 7, status: "BLOCKED" } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "customer", "update", "7", "--status", "BLOCKED", "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PATCH", + url: expect.stringContaining("/customers/7"), + data: { status: "BLOCKED" }, + }), + ); +}); + +test("shop order cancel requires confirmation", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "order", "cancel", "1001", "--json"], runtime as never), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop order cancel --force sends the request", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "order", "cancel", "1001", "--force", "--json"], runtime as never), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/orders/1001/cancel"), + }), + ); +}); + +test("shop order mark-paid requires confirmation", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "order", "mark-paid", "1001", "--json"], runtime as never), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop order mark-paid --force sends the request", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "mark-paid", "1001", "--force", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/orders/1001/mark-as-paid"), + }), + ); +}); + +test("shop category create posts the request", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: { id: 5, name: "Digital", slug: "digital" } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "category", "create", "--name", "Digital", "--slug", "digital", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/product-categories"), + data: expect.objectContaining({ name: "Digital", slug: "digital" }), + }), + ); +}); + +test("shop category delete requires confirmation", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "category", "delete", "5", "--json"], runtime as never), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop category delete --force deletes the category", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "category", "delete", "5", "--force", "--json"], runtime as never), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "DELETE", + url: expect.stringContaining("/product-categories/5"), + }), + ); +}); + +test("shop coupon delete --force deletes the coupon", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "coupon", "delete", "3", "--force", "--json"], runtime as never), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "DELETE", + url: expect.stringContaining("/coupons/3"), + }), + ); +}); + +test("shop discount delete --force deletes the discount", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: "" }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "discount", "delete", "3", "--force", "--json"], runtime as never), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "DELETE", + url: expect.stringContaining("/discounts/3"), + }), + ); +}); + +test("shop config set --group sends the group payload", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ provider: "stripe" }); + + try { + const request = vi.fn().mockResolvedValue({ data: { provider: "stripe" } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "config", "set", "--group", "payment", "--file", file, "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/ecommerceconfigs/payment"), + data: { provider: "stripe" }, + }), + ); + } finally { + await cleanup(); + } +}); + +test("shop category list passes the tree flag", async () => { + silenceStdout(); + + const request = vi.fn().mockResolvedValue({ data: [] }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "category", "list", "--tree", "--json"], runtime as never), + ).resolves.toBe(true); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: expect.stringContaining("/product-categories"), + params: { tree: true }, + }), + ); +}); + +test("shop category update merges flags with the current category", async () => { + silenceStdout(); + + const current = { + id: 5, + name: "Old name", + slug: "digital", + description: "Old description", + }; + const request = vi + .fn() + .mockResolvedValueOnce({ data: current }) + .mockResolvedValueOnce({ data: { ...current, name: "New name" } }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "category", "update", "5", "--name", "New name", "--json"], + runtime as never, + ), + ).resolves.toBe(true); + + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "PUT", + url: expect.stringContaining("/product-categories/5"), + data: expect.objectContaining({ name: "New name", slug: "digital" }), + }), + ); +}); + +test("shop discount create rejects an invalid startsAt instant", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + name: "Invalid window", + application: "AUTOMATIC", + calculationType: "AMOUNT", + discountValue: 10, + scope: "ALL", + startsAt: "tomorrow", + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "discount", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("Discount startsAt must be an ISO-8601 instant"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + +test("shop order list rejects an invalid start date", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "list", "--start-date", "2026-02-31", "--json"], + runtime as never, + ), + ).rejects.toThrow("--start-date must be an ISO-8601 instant"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop order note rejects messages over 1000 characters", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "order", "note", "1001", "--message", "x".repeat(1001), "--json"], + runtime as never, + ), + ).rejects.toThrow("at most 1000 characters"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop customer update rejects notes over 500 characters", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "customer", "update", "7", "--note", "x".repeat(501), "--json"], + runtime as never, + ), + ).rejects.toThrow("at most 500 characters"); + + expect(request).not.toHaveBeenCalled(); +}); diff --git a/src/commands/shop/api.ts b/src/commands/shop/api.ts new file mode 100644 index 0000000..c908ccd --- /dev/null +++ b/src/commands/shop/api.ts @@ -0,0 +1,328 @@ +import axios, { type AxiosInstance } from "axios"; + +import { CliError } from "../../utils/errors.js"; +import type { + Address, + Coupon, + CouponRequest, + Customer, + CustomerStatus, + DailyStatistics, + Discount, + DiscountRequest, + FulfillmentRequest, + Order, + OrderCreateRequest, + Page, + Product, + ProductCategory, + ProductCategoryRequest, + ProductRequest, + ShopSetting, +} from "./types.js"; + +const SHOP_API_BASE = "/apis/console.api.ecommerce.halo.run/v1alpha1"; + +export interface ProductListQuery { + page?: number; + size?: number; + keyword?: string; + categoryId?: number; + statuses?: string[]; + types?: string[]; + sort?: string; +} + +export interface OrderListQuery { + page?: number; + size?: number; + keyword?: string; + customerId?: number; + username?: string; + orderCode?: string; + statuses?: string[]; + paymentStatuses?: string[]; + fulfillmentStatuses?: string[]; + refundStatuses?: string[]; + startDate?: string; + endDate?: string; + sort?: string; +} + +export interface CustomerListQuery { + page?: number; + size?: number; + keyword?: string; + status?: string; + type?: string; + sort?: string; +} + +export interface CouponListQuery { + page?: number; + size?: number; + keyword?: string; + isEnabled?: boolean; + sort?: string; +} + +export interface DiscountListQuery { + page?: number; + size?: number; + sort?: string; +} + +interface RequestOptions { + params?: Record; + data?: unknown; +} + +function isMissingShopApi(data: unknown): boolean { + if (!data || typeof data !== "object") { + return false; + } + + const detail = (data as Record).detail; + if (typeof detail !== "string") { + return false; + } + + // Spring reports an unknown route as "No static resource ". Resource-level + // 404s from the shop API do not mention the API group. + return /no static resource/i.test(detail) || detail.includes("console.api.ecommerce.halo.run"); +} + +function translateShopApiError(error: unknown): unknown { + if (axios.isAxiosError(error) && error.response?.status === 404) { + if (isMissingShopApi(error.response.data)) { + return new CliError( + "Shop API is not available on this Halo instance. The shop commands require Halo Pro or Halo Commercial with the shop feature enabled.", + ); + } + } + + return error; +} + +export class ShopApi { + constructor(private readonly axios: AxiosInstance) {} + + private async request( + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + path: string, + options: RequestOptions = {}, + ): Promise { + try { + const response = await this.axios.request({ + method, + url: `${SHOP_API_BASE}${path}`, + params: options.params, + paramsSerializer: { indexes: null }, + data: options.data, + }); + const data = response.data; + return (data === "" || data === null ? undefined : data) as T; + } catch (error) { + throw translateShopApiError(error); + } + } + + private async getResource(path: string, label: string): Promise { + const value = await this.request("GET", path); + if (value === undefined) { + throw new CliError(`${label} was not found.`); + } + return value; + } + + listProducts(query: ProductListQuery = {}): Promise> { + return this.request("GET", "/products", { params: { ...query } }); + } + + getProduct(id: number): Promise { + return this.getResource(`/products/${id}`, `Product ${id}`); + } + + createProduct(payload: ProductRequest): Promise { + return this.request("POST", "/products", { data: payload }); + } + + updateProduct(id: number, payload: ProductRequest): Promise { + return this.request("PUT", `/products/${id}`, { data: payload }); + } + + updateProductBasicInfo( + id: number, + payload: { title: string; handle?: string; description?: string; categoryId?: number }, + ): Promise { + return this.request("PUT", `/products/${id}/basic-info`, { data: payload }); + } + + updateProductContent( + id: number, + payload: { content?: string; images?: string[] }, + ): Promise { + return this.request("PUT", `/products/${id}/content`, { data: payload }); + } + + updateProductSpecifications( + id: number, + payload: Pick, + ): Promise { + return this.request("PUT", `/products/${id}/specifications`, { data: payload }); + } + + updateProductStatus(id: number, status: ProductRequest["status"]): Promise { + return this.request("PUT", `/products/${id}/status`, { data: { status } }); + } + + deleteProduct(id: number): Promise { + return this.request("DELETE", `/products/${id}`); + } + + listCategories(tree = false): Promise { + return this.request("GET", "/product-categories", { params: tree ? { tree: true } : {} }); + } + + getCategory(id: number): Promise { + return this.getResource(`/product-categories/${id}`, `Product category ${id}`); + } + + createCategory(payload: ProductCategoryRequest): Promise { + return this.request("POST", "/product-categories", { data: payload }); + } + + updateCategory(id: number, payload: ProductCategoryRequest): Promise { + return this.request("PUT", `/product-categories/${id}`, { data: payload }); + } + + deleteCategory(id: number): Promise { + return this.request("DELETE", `/product-categories/${id}`); + } + + listOrders(query: OrderListQuery = {}): Promise> { + return this.request("GET", "/orders", { params: { ...query } }); + } + + getOrder(id: number): Promise { + return this.getResource(`/orders/${id}`, `Order ${id}`); + } + + createOrder(payload: OrderCreateRequest): Promise { + return this.request("POST", "/orders", { data: payload }); + } + + cancelOrder(id: number): Promise { + return this.request("POST", `/orders/${id}/cancel`); + } + + markOrderAsPaid(id: number): Promise { + return this.request("POST", `/orders/${id}/mark-as-paid`); + } + + fulfillOrder(id: number, payload: FulfillmentRequest): Promise { + return this.request("POST", `/orders/${id}/request-fulfillment`, { data: payload }); + } + + addOrderNote(id: number, message: string, payload?: string): Promise { + return this.request("POST", `/orders/${id}/timeline/notes`, { + data: payload === undefined ? { message } : { message, payload }, + }); + } + + listCustomers(query: CustomerListQuery = {}): Promise> { + return this.request("GET", "/customers", { params: { ...query } }); + } + + getCustomer(id: number): Promise { + return this.getResource(`/customers/${id}`, `Customer ${id}`); + } + + updateCustomer( + id: number, + payload: { note?: string; status?: CustomerStatus }, + ): Promise { + return this.request("PATCH", `/customers/${id}`, { data: payload }); + } + + listCustomerAddresses(id: number): Promise { + return this.request("GET", `/customers/${id}/addresses`); + } + + getCustomerEntitlements(customerId: number, productId: number): Promise { + return this.request("GET", `/customers/${customerId}/entitlements`, { + params: { productId }, + }); + } + + listCoupons(query: CouponListQuery = {}): Promise> { + return this.request("GET", "/coupons", { params: { ...query } }); + } + + getCoupon(id: number): Promise { + return this.getResource(`/coupons/${id}`, `Coupon ${id}`); + } + + createCoupon(payload: CouponRequest): Promise { + return this.request("POST", "/coupons", { data: payload }); + } + + updateCoupon(id: number, payload: CouponRequest): Promise { + return this.request("PUT", `/coupons/${id}`, { data: payload }); + } + + deleteCoupon(id: number): Promise { + return this.request("DELETE", `/coupons/${id}`); + } + + distributeCoupon( + id: number, + payload: { allCustomers?: boolean; usernames?: string[] }, + ): Promise { + return this.request("POST", `/coupons/${id}/auto-distribute`, { data: payload }); + } + + listDiscounts(query: DiscountListQuery = {}): Promise> { + return this.request("GET", "/discounts", { params: { ...query } }); + } + + getDiscount(id: number): Promise { + return this.getResource(`/discounts/${id}`, `Discount ${id}`); + } + + createDiscount(payload: DiscountRequest): Promise { + return this.request("POST", "/discounts", { data: payload }); + } + + updateDiscount(id: number, payload: DiscountRequest): Promise { + return this.request("PUT", `/discounts/${id}`, { data: payload }); + } + + deleteDiscount(id: number): Promise { + return this.request("DELETE", `/discounts/${id}`); + } + + getDailyStatistics(startDate?: string, endDate?: string): Promise { + return this.request("GET", "/statistics/orders/daily", { + params: { startDate, endDate }, + }); + } + + getShopConfig(): Promise { + return this.request("GET", "/ecommerceconfigs/shop"); + } + + updateShopConfig(payload: ShopSetting): Promise { + return this.request("PUT", "/ecommerceconfigs/shop", { data: payload }); + } + + getConfigGroup(group: string): Promise { + return this.request("GET", `/ecommerceconfigs/${encodeURIComponent(group)}`); + } + + updateConfigGroup(group: string, payload: unknown): Promise { + return this.request("PUT", `/ecommerceconfigs/${encodeURIComponent(group)}`, { + data: payload, + }); + } +} diff --git a/src/commands/shop/category.ts b/src/commands/shop/category.ts new file mode 100644 index 0000000..f8d64b4 --- /dev/null +++ b/src/commands/shop/category.ts @@ -0,0 +1,205 @@ +import { type CAC } from "cac"; + +import { confirmDangerousAction } from "../../utils/confirmation.js"; +import { parseBooleanOption } from "../../utils/options.js"; +import { printJson } from "../../utils/output.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { createShopCli } from "./cli.js"; +import { printCategory, printCategoryList } from "./format.js"; +import { + mergeDefined, + parsePositiveInteger, + readJsonPayload, + requireString, + type JsonInputOptions, +} from "./input.js"; +import type { ProductCategory, ProductCategoryRequest } from "./types.js"; + +interface ShopCommandOptions { + profile?: string; + json?: boolean; +} + +interface CategoryListOptions extends ShopCommandOptions { + tree?: boolean | string; +} + +interface CategoryMutationOptions extends ShopCommandOptions, JsonInputOptions { + name?: string; + slug?: string; + description?: string; + image?: string; + parent?: string; +} + +interface CategoryDeleteOptions extends ShopCommandOptions { + force?: boolean; +} + +function categoryToRequest(category: ProductCategory): ProductCategoryRequest { + return { + name: category.name, + slug: category.slug, + description: category.description, + image: category.image, + parentId: category.parentId, + }; +} + +function buildCategoryFlagPatch(options: CategoryMutationOptions): Record { + const patch: Record = {}; + + if (options.name !== undefined) patch.name = options.name; + if (options.slug !== undefined) patch.slug = options.slug; + if (options.description !== undefined) patch.description = options.description; + if (options.image !== undefined) patch.image = options.image; + if (options.parent !== undefined) { + patch.parentId = parsePositiveInteger(options.parent, "--parent"); + } + + return patch; +} + +function validateCategoryRequest(request: ProductCategoryRequest): void { + request.name = requireString(request.name, "Category name is required. Use --name or --file."); + request.slug = requireString(request.slug, "Category slug is required. Use --slug or --file."); +} + +export function buildCategoryCli(runtime: RuntimeContext): CAC { + const categoryCli = createShopCli("halo shop category"); + + categoryCli + .command("list", "List product categories") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--tree", "Return categories as a tree") + .action(async (options: CategoryListOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const categories = await api.listCategories(parseBooleanOption(options.tree) ?? false); + printCategoryList(categories, options.json); + }); + + categoryCli + .command("get ", "Show category details") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .action(async (id: string, options: ShopCommandOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const category = await api.getCategory(parsePositiveInteger(id, "Category ID")); + printCategory(category, options.json); + }); + + categoryCli + .command("create", "Create a product category") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--file ", "Read a ProductCategoryRequest JSON file, use - for stdin") + .option("--stdin", "Read a ProductCategoryRequest JSON payload from stdin") + .option("--name ", "Category name") + .option("--slug ", "Category slug") + .option("--description ", "Category description") + .option("--image ", "Category image URL") + .option("--parent ", "Parent category ID") + .action(async (options: CategoryMutationOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const payload: Record = { + ...(await readJsonPayload(options)), + ...buildCategoryFlagPatch(options), + }; + const request = payload as unknown as ProductCategoryRequest; + validateCategoryRequest(request); + + const category = await api.createCategory(request); + + if (options.json) { + printJson(category); + return; + } + + process.stdout.write(`Created category ${category.id} (${category.name}).\n`); + }); + + categoryCli + .command("update ", "Update a product category") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--file ", "Read a partial ProductCategoryRequest JSON file, use - for stdin") + .option("--stdin", "Read a partial ProductCategoryRequest JSON payload from stdin") + .option("--name ", "Category name") + .option("--slug ", "Category slug") + .option("--description ", "Category description") + .option("--image ", "Category image URL") + .option("--parent ", "Parent category ID") + .action(async (id: string, options: CategoryMutationOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const categoryId = parsePositiveInteger(id, "Category ID"); + const current = await api.getCategory(categoryId); + const payload = mergeDefined( + categoryToRequest(current) as unknown as Record, + { + ...(await readJsonPayload(options)), + ...buildCategoryFlagPatch(options), + }, + ); + const request = payload as unknown as ProductCategoryRequest; + validateCategoryRequest(request); + + const category = await api.updateCategory(categoryId, request); + + if (options.json) { + printJson(category); + return; + } + + process.stdout.write(`Updated category ${category.id} (${category.name}).\n`); + }); + + categoryCli + .command("delete ", "Delete a product category") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--force", "Delete without confirmation") + .action(async (id: string, options: CategoryDeleteOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const categoryId = parsePositiveInteger(id, "Category ID"); + + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop category delete", + actionLabel: "Delete", + resourceLabel: "category", + resourceName: String(categoryId), + cancellationVerb: "deleting", + }, + options, + )) + ) { + return; + } + + const api = new ShopApi(clients.axios); + await api.deleteCategory(categoryId); + + if (options.json) { + printJson({ deleted: true, id: categoryId }); + return; + } + + process.stdout.write(`Deleted category ${categoryId}.\n`); + }); + + categoryCli.usage(" [flags]"); + categoryCli.example((bin) => `${bin} list --tree`); + categoryCli.example((bin) => `${bin} create --name "Digital goods" --slug digital-goods`); + categoryCli.example((bin) => `${bin} update 1 --name "New name"`); + categoryCli.example((bin) => `${bin} delete 1 --force`); + categoryCli.help(); + + return categoryCli; +} diff --git a/src/commands/shop/cli.ts b/src/commands/shop/cli.ts new file mode 100644 index 0000000..856ad44 --- /dev/null +++ b/src/commands/shop/cli.ts @@ -0,0 +1,247 @@ +import cac, { type CAC } from "cac"; + +import { CliError } from "../../utils/errors.js"; +import { parseBooleanOption } from "../../utils/options.js"; + +/** Options that may legitimately be passed more than once. */ +const REPEATABLE_OPTIONS = new Set(["item", "username"]); + +function camelCaseOptionName(name: string): string { + return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + +function kebabCaseOptionName(name: string): string { + return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} + +function parseOptionToken(token: string): { name: string; value?: string } | undefined { + if (!token.startsWith("--") || token === "--") { + return undefined; + } + + const equalsIndex = token.indexOf("="); + const rawName = equalsIndex === -1 ? token.slice(2) : token.slice(2, equalsIndex); + if (rawName.length === 0) { + return undefined; + } + + return { + name: camelCaseOptionName(rawName), + value: equalsIndex === -1 ? undefined : token.slice(equalsIndex + 1), + }; +} + +function collectOptionKinds(cli: CAC): { + booleanOptions: Set; + valueOptions: Set; +} { + const booleanOptions = new Set(); + const valueOptions = new Set(); + + for (const command of [cli.globalCommand, ...cli.commands]) { + for (const option of command.options) { + if (option.isBoolean) { + booleanOptions.add(option.name); + } else { + valueOptions.add(option.name); + } + } + } + + return { booleanOptions, valueOptions }; +} + +function negatedBooleanBaseName(name: string): string { + return `${(name[2] as string).toLowerCase()}${name.slice(3)}`; +} + +function isNegatedBooleanOption(name: string, booleanOptions: Set): boolean { + if (!name.startsWith("no") || name.length <= 2) { + return false; + } + + return booleanOptions.has(negatedBooleanBaseName(name)); +} + +function normalizeEmptyEqualsValues(argv: string[], valueOptions: Set): string[] { + const normalized: string[] = []; + + for (let index = 0; index < argv.length; index++) { + const token = argv[index] as string; + if (token === "--") { + normalized.push(...argv.slice(index)); + break; + } + + const option = parseOptionToken(token); + if (option && option.value === "" && valueOptions.has(option.name)) { + normalized.push(token.slice(0, -1), ""); + continue; + } + + normalized.push(token); + } + + return normalized; +} + +function normalizeDashValues(argv: string[], valueOptions: Set): string[] { + const normalized: string[] = []; + + for (let index = 0; index < argv.length; index++) { + const token = argv[index] as string; + if (token === "--") { + normalized.push(...argv.slice(index)); + break; + } + + const option = parseOptionToken(token); + + if ( + option && + option.value === undefined && + argv[index + 1] === "-" && + valueOptions.has(option.name) + ) { + normalized.push(`${token}=-`); + index++; + continue; + } + + normalized.push(token); + } + + return normalized; +} + +function normalizeBooleanOptions(argv: string[], booleanOptions: Set): string[] { + const normalized: string[] = []; + + for (let index = 0; index < argv.length; index++) { + const token = argv[index] as string; + if (token === "--") { + normalized.push(...argv.slice(index)); + break; + } + + const option = parseOptionToken(token); + if (!option) { + normalized.push(token); + continue; + } + + if (booleanOptions.has(option.name)) { + if (option.value === undefined) { + normalized.push(`--${option.name}`); + continue; + } + + const parsed = parseBooleanOption(option.value); + normalized.push(parsed ? `--${option.name}` : `--no-${option.name}`); + continue; + } + + if (isNegatedBooleanOption(option.name, booleanOptions)) { + const baseName = negatedBooleanBaseName(option.name); + if (option.value === undefined) { + normalized.push(`--no-${baseName}`); + continue; + } + + const parsed = parseBooleanOption(option.value); + normalized.push(parsed ? `--no-${baseName}` : `--${baseName}`); + continue; + } + + normalized.push(token); + } + + return normalized; +} + +function collectRawValues(argv: string[], booleanOptions: Set): Map { + const values = new Map(); + + for (let index = 0; index < argv.length; index++) { + const token = argv[index] as string; + if (token === "--") { + break; + } + + const option = parseOptionToken(token); + if (!option) { + continue; + } + + if (booleanOptions.has(option.name) || isNegatedBooleanOption(option.name, booleanOptions)) { + continue; + } + + let value: string; + if (option.value !== undefined) { + value = option.value; + } else { + const next = argv[index + 1]; + if (next === undefined || (next.startsWith("-") && next !== "-")) { + continue; + } + + value = next; + index++; + } + + const existing = values.get(option.name); + if (existing) { + existing.push(value); + } else { + values.set(option.name, [value]); + } + } + + return values; +} + +/** + * cac/mri coerces numeric-looking option values into numbers and empty values + * into 0. That loses leading zeros and makes empty input indistinguishable from + * an explicit zero. Restore the raw string values after parsing so shop + * commands can validate and preserve what the user actually typed. + */ +export function createShopCli(name: string): CAC { + const cli = cac(name); + const originalParse = cli.parse.bind(cli); + + cli.parse = ((argv: string[] = [], config?: { run?: boolean }) => { + const { booleanOptions, valueOptions } = collectOptionKinds(cli); + const normalizedArgv = normalizeBooleanOptions( + normalizeDashValues(normalizeEmptyEqualsValues(argv, valueOptions), valueOptions), + booleanOptions, + ); + const rawValues = collectRawValues(normalizedArgv, booleanOptions); + + for (const [optionName, values] of rawValues) { + if (values.length > 1 && !REPEATABLE_OPTIONS.has(optionName)) { + throw new CliError( + `Option --${kebabCaseOptionName(optionName)} was provided multiple times.`, + ); + } + } + + const parsed = originalParse(normalizedArgv, config); + + for (const optionName of booleanOptions) { + const value = cli.options[optionName]; + if (Array.isArray(value)) { + cli.options[optionName] = value.at(-1); + } + } + + for (const [optionName, values] of rawValues) { + cli.options[optionName] = values.length === 1 ? values[0] : values; + } + + return parsed; + }) as CAC["parse"]; + + return cli; +} diff --git a/src/commands/shop/config.ts b/src/commands/shop/config.ts new file mode 100644 index 0000000..f5453eb --- /dev/null +++ b/src/commands/shop/config.ts @@ -0,0 +1,129 @@ +import { type CAC } from "cac"; + +import { CliError } from "../../utils/errors.js"; +import { parseBooleanOption } from "../../utils/options.js"; +import { printJson } from "../../utils/output.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { createShopCli } from "./cli.js"; +import { printConfig } from "./format.js"; +import { hasAnyOption, readJsonPayload, type JsonInputOptions } from "./input.js"; +import type { ShopSetting } from "./types.js"; + +interface ShopCommandOptions { + profile?: string; + json?: boolean; +} + +interface ConfigGetOptions extends ShopCommandOptions { + group?: string; +} + +interface ConfigSetOptions extends ShopCommandOptions, JsonInputOptions { + group?: string; + guestCheckout?: string; + subscriptionMenu?: string; + inquiryRedirectUrl?: string; +} + +const SHOP_SETTING_OPTIONS = ["guestCheckout", "subscriptionMenu", "inquiryRedirectUrl"]; + +function buildShopSettingPatch(options: ConfigSetOptions): Record { + const patch: Record = {}; + + if (options.guestCheckout !== undefined) { + patch.guestCheckoutEnabled = parseBooleanOption(options.guestCheckout); + } + if (options.subscriptionMenu !== undefined) { + patch.subscriptionMenuEnabled = parseBooleanOption(options.subscriptionMenu); + } + if (options.inquiryRedirectUrl !== undefined) { + patch.inquiryRedirectUrl = options.inquiryRedirectUrl; + } + + return patch; +} + +export function buildConfigCli(runtime: RuntimeContext): CAC { + const configCli = createShopCli("halo shop config"); + + configCli + .command("get", "Show shop configuration") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--group ", "Read an arbitrary ecommerce config group instead of shop") + .action(async (options: ConfigGetOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const config = + options.group === undefined + ? await api.getShopConfig() + : await api.getConfigGroup(options.group); + printConfig(config, options.json); + }); + + configCli + .command("set", "Update shop configuration") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--group ", "Update an arbitrary ecommerce config group instead of shop") + .option("--file ", "Read a JSON object from file, use - for stdin") + .option("--stdin", "Read a JSON object from stdin") + .option("--guest-checkout ", "Whether guest checkout is enabled") + .option("--subscription-menu ", "Whether the subscription menu is shown") + .option("--inquiry-redirect-url ", "Redirect URL for inquiry products") + .action(async (options: ConfigSetOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const filePayload = await readJsonPayload(options); + + if (options.group !== undefined) { + if (hasAnyOption(options as unknown as Record, SHOP_SETTING_OPTIONS)) { + throw new CliError("--group cannot be combined with shop setting options."); + } + if (filePayload === undefined) { + throw new CliError("Use --file or --stdin to provide the config group payload."); + } + + const config = await api.updateConfigGroup(options.group, filePayload); + + if (options.json) { + printJson(config ?? { group: options.group, updated: true }); + return; + } + + process.stdout.write(`Updated config group "${options.group}".\n`); + return; + } + + if ( + filePayload === undefined && + !hasAnyOption(options as unknown as Record, SHOP_SETTING_OPTIONS) + ) { + throw new CliError("Provide --file, --stdin, or at least one shop setting option."); + } + + const current = await api.getShopConfig(); + const payload: Record = { + ...current, + ...filePayload, + ...buildShopSettingPatch(options), + }; + const config = await api.updateShopConfig(payload as unknown as ShopSetting); + + if (options.json) { + printJson(config ?? { updated: true }); + return; + } + + process.stdout.write("Updated shop configuration.\n"); + }); + + configCli.usage(" [flags]"); + configCli.example((bin) => `${bin} get`); + configCli.example((bin) => `${bin} set --guest-checkout true`); + configCli.example((bin) => `${bin} set --file payment.json --group payment`); + configCli.help(); + + return configCli; +} diff --git a/src/commands/shop/coupon.ts b/src/commands/shop/coupon.ts new file mode 100644 index 0000000..530f06d --- /dev/null +++ b/src/commands/shop/coupon.ts @@ -0,0 +1,445 @@ +import { type CAC, type Command } from "cac"; + +import { confirmDangerousAction } from "../../utils/confirmation.js"; +import { CliError } from "../../utils/errors.js"; +import { parseBooleanOption } from "../../utils/options.js"; +import { printJson } from "../../utils/output.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { createShopCli } from "./cli.js"; +import { printCoupon, printCouponList } from "./format.js"; +import { + mergeDefined, + parseEnumOption, + parseInstantOption, + parseMoneyOption, + parseNumberListOption, + parsePageOption, + parsePositiveInteger, + parseSizeOption, + readJsonPayload, + requireString, + toStringArray, + type JsonInputOptions, +} from "./input.js"; +import type { CalculationType, Coupon, CouponRequest, DiscountScope } from "./types.js"; + +const CALCULATION_TYPES = ["AMOUNT", "PERCENTAGE"] as const; +const DISCOUNT_SCOPES = ["ALL", "QUALIFYING_ITEMS"] as const; + +interface ShopCommandOptions { + profile?: string; + json?: boolean; +} + +interface CouponListOptions extends ShopCommandOptions { + page?: string; + size?: string; + keyword?: string; + enabled?: string; + sort?: string; +} + +interface CouponMutationOptions extends ShopCommandOptions, JsonInputOptions { + name?: string; + description?: string; + calculationType?: string; + discountValue?: string; + maxDiscountAmount?: string; + scope?: string; + qualifyingProductIds?: string; + qualifyingCategoryIds?: string; + excludedProductIds?: string; + excludedCategoryIds?: string; + excludeSaleItems?: string; + minOrderAmount?: string; + totalCount?: string; + perCustomerLimit?: string; + enabled?: string; + startsAt?: string; + endsAt?: string; + validDays?: string; + combineWithCoupons?: string; + combineWithOrderDiscount?: string; + force?: boolean; +} + +interface CouponDeleteOptions extends ShopCommandOptions { + force?: boolean; +} + +interface CouponDistributeOptions extends ShopCommandOptions { + all?: boolean; + username?: string | string[]; + force?: boolean; +} + +function toPositiveIntegerOption(value: string | undefined, label: string): number | undefined { + return value === undefined ? undefined : parsePositiveInteger(value, label); +} + +function buildCouponPatch(options: CouponMutationOptions): Record { + const patch: Record = {}; + + if (options.name !== undefined) patch.name = options.name; + if (options.description !== undefined) patch.description = options.description; + + const calculationType = parseEnumOption( + options.calculationType, + CALCULATION_TYPES, + "--calculation-type", + ); + if (calculationType !== undefined) patch.calculationType = calculationType; + + const discountValue = parseMoneyOption(options.discountValue, "--discount-value"); + if (discountValue !== undefined) patch.discountValue = discountValue; + + const maxDiscountAmount = parseMoneyOption(options.maxDiscountAmount, "--max-discount-amount"); + if (maxDiscountAmount !== undefined) patch.maxDiscountAmount = maxDiscountAmount; + + const scope = parseEnumOption(options.scope, DISCOUNT_SCOPES, "--scope"); + if (scope !== undefined) patch.scope = scope; + + const qualifyingProductIds = parseNumberListOption(options.qualifyingProductIds); + if (qualifyingProductIds !== undefined) { + patch.qualifyingProductIds = qualifyingProductIds; + } + + const qualifyingCategoryIds = parseNumberListOption(options.qualifyingCategoryIds); + if (qualifyingCategoryIds !== undefined) { + patch.qualifyingCategoryIds = qualifyingCategoryIds; + } + + const excludedProductIds = parseNumberListOption(options.excludedProductIds); + if (excludedProductIds !== undefined) patch.excludedProductIds = excludedProductIds; + + const excludedCategoryIds = parseNumberListOption(options.excludedCategoryIds); + if (excludedCategoryIds !== undefined) { + patch.excludedCategoryIds = excludedCategoryIds; + } + + if (options.excludeSaleItems !== undefined) { + patch.excludeSaleItems = parseBooleanOption(options.excludeSaleItems); + } + + const minOrderAmount = parseMoneyOption(options.minOrderAmount, "--min-order-amount"); + if (minOrderAmount !== undefined) patch.minOrderAmount = minOrderAmount; + + const totalCount = toPositiveIntegerOption(options.totalCount, "--total-count"); + if (totalCount !== undefined) patch.totalCount = totalCount; + + const perCustomerLimit = toPositiveIntegerOption( + options.perCustomerLimit, + "--per-customer-limit", + ); + if (perCustomerLimit !== undefined) patch.perCustomerLimit = perCustomerLimit; + + if (options.enabled !== undefined) patch.isEnabled = parseBooleanOption(options.enabled); + if (options.startsAt !== undefined) patch.startsAt = options.startsAt; + if (options.endsAt !== undefined) patch.endsAt = options.endsAt; + + const validDays = toPositiveIntegerOption(options.validDays, "--valid-days"); + if (validDays !== undefined) patch.validDays = validDays; + + if (options.combineWithCoupons !== undefined) { + patch.combineWithCoupons = parseBooleanOption(options.combineWithCoupons); + } + if (options.combineWithOrderDiscount !== undefined) { + patch.combineWithOrderDiscount = parseBooleanOption(options.combineWithOrderDiscount); + } + + return patch; +} + +function couponToRequest(coupon: Coupon): CouponRequest { + return { + name: coupon.name, + description: coupon.description, + calculationType: coupon.calculationType, + discountValue: coupon.discountValue, + maxDiscountAmount: coupon.maxDiscountAmount, + scope: coupon.scope, + qualifyingProductIds: coupon.qualifyingProductIds, + qualifyingCategoryIds: coupon.qualifyingCategoryIds, + excludedProductIds: coupon.excludedProductIds, + excludedCategoryIds: coupon.excludedCategoryIds, + excludeSaleItems: coupon.excludeSaleItems, + minOrderAmount: coupon.minOrderAmount, + totalCount: coupon.totalCount, + perCustomerLimit: coupon.perCustomerLimit, + isEnabled: coupon.isEnabled, + startsAt: coupon.startsAt, + endsAt: coupon.endsAt, + validDays: coupon.validDays, + combineWithCoupons: coupon.combineWithCoupons, + combineWithOrderDiscount: coupon.combineWithOrderDiscount, + }; +} + +function validateCouponRequest(request: CouponRequest): void { + request.name = requireString(request.name, "Coupon name is required. Use --name or --file."); + + if (request.calculationType === undefined) { + throw new CliError("Coupon calculation type is required. Use --calculation-type."); + } + if (!CALCULATION_TYPES.includes(request.calculationType as CalculationType)) { + throw new CliError(`Invalid calculation type "${String(request.calculationType)}".`); + } + if (request.discountValue === undefined) { + throw new CliError("Coupon discount value is required. Use --discount-value."); + } + if (request.scope === undefined) { + throw new CliError("Coupon scope is required. Use --scope."); + } + if (!DISCOUNT_SCOPES.includes(request.scope as DiscountScope)) { + throw new CliError(`Invalid coupon scope "${String(request.scope)}".`); + } + if (request.validDays === undefined) { + throw new CliError("Coupon valid days is required. Use --valid-days."); + } + request.validDays = parsePositiveInteger(request.validDays, "Coupon valid days"); +} + +function normalizeCouponRequest(request: CouponRequest): void { + request.startsAt = parseInstantOption(request.startsAt, "Coupon startsAt"); + request.endsAt = parseInstantOption(request.endsAt, "Coupon endsAt"); + + if (request.isEnabled === undefined) { + return; + } + + const enabled = parseBooleanOption(request.isEnabled); + if (enabled === undefined) { + throw new CliError("Coupon isEnabled must be a boolean."); + } + + request.isEnabled = enabled; +} + +export function buildCouponCli(runtime: RuntimeContext): CAC { + const couponCli = createShopCli("halo shop coupon"); + + couponCli + .command("list", "List coupons") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--page ", "Page number", { default: 1 }) + .option("--size ", "Page size", { default: 20 }) + .option("--keyword ", "Filter by keyword") + .option("--enabled ", "Filter by enabled state") + .option("--sort ", "Sort expression, e.g. id,desc") + .action(async (options: CouponListOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const page = await api.listCoupons({ + page: parsePageOption(options.page), + size: parseSizeOption(options.size), + keyword: options.keyword, + isEnabled: options.enabled === undefined ? undefined : parseBooleanOption(options.enabled), + sort: options.sort, + }); + printCouponList(page, options.json); + }); + + couponCli + .command("get ", "Show coupon details") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .action(async (id: string, options: ShopCommandOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const coupon = await api.getCoupon(parsePositiveInteger(id, "Coupon ID")); + printCoupon(coupon, options.json); + }); + + const addMutationOptions = (command: Command): Command => + command + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--file ", "Read a CouponRequest JSON file, use - for stdin") + .option("--stdin", "Read a CouponRequest JSON payload from stdin") + .option("--name ", "Coupon name") + .option("--description ", "Coupon description") + .option("--calculation-type ", "AMOUNT or PERCENTAGE") + .option("--discount-value ", "Discount value") + .option("--max-discount-amount ", "Maximum discount amount") + .option("--scope ", "ALL or QUALIFYING_ITEMS") + .option("--qualifying-product-ids ", "Comma separated product IDs") + .option("--qualifying-category-ids ", "Comma separated category IDs") + .option("--excluded-product-ids ", "Comma separated product IDs") + .option("--excluded-category-ids ", "Comma separated category IDs") + .option("--exclude-sale-items ", "Exclude sale items") + .option("--min-order-amount ", "Minimum order amount") + .option("--total-count ", "Total issue count") + .option("--per-customer-limit ", "Per-customer limit") + .option("--enabled ", "Whether the coupon is enabled") + .option("--starts-at ", "Start time (ISO-8601)") + .option("--ends-at ", "End time (ISO-8601)") + .option("--valid-days ", "Valid days after claiming") + .option("--combine-with-coupons ", "Allow combining with coupons") + .option("--combine-with-order-discount ", "Allow combining with order discounts"); + + addMutationOptions(couponCli.command("create", "Create a coupon")).action( + async (options: CouponMutationOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const payload: Record = { + ...(await readJsonPayload(options)), + ...buildCouponPatch(options), + }; + const request = payload as unknown as CouponRequest; + validateCouponRequest(request); + normalizeCouponRequest(request); + const coupon = await api.createCoupon(request); + + if (options.json) { + printJson(coupon); + return; + } + + process.stdout.write(`Created coupon ${coupon.id} (${coupon.name}).\n`); + }, + ); + + addMutationOptions(couponCli.command("update ", "Update a coupon")) + .option("--force", "Disable the coupon without confirmation") + .action(async (id: string, options: CouponMutationOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const couponId = parsePositiveInteger(id, "Coupon ID"); + const current = await api.getCoupon(couponId); + const payload = mergeDefined(couponToRequest(current) as unknown as Record, { + ...(await readJsonPayload(options)), + ...buildCouponPatch(options), + }); + const request = payload as unknown as CouponRequest; + validateCouponRequest(request); + normalizeCouponRequest(request); + + if (current.isEnabled === true && request.isEnabled === false) { + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop coupon update", + actionLabel: "Disable", + resourceLabel: "coupon", + resourceName: String(couponId), + cancellationVerb: "disabling", + }, + options, + )) + ) { + return; + } + } + + const coupon = await api.updateCoupon(couponId, request); + + if (options.json) { + printJson(coupon); + return; + } + + process.stdout.write(`Updated coupon ${coupon.id} (${coupon.name}).\n`); + }); + + couponCli + .command("delete ", "Delete a coupon") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--force", "Delete without confirmation") + .action(async (id: string, options: CouponDeleteOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const couponId = parsePositiveInteger(id, "Coupon ID"); + + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop coupon delete", + actionLabel: "Delete", + resourceLabel: "coupon", + resourceName: String(couponId), + cancellationVerb: "deleting", + }, + options, + )) + ) { + return; + } + + const api = new ShopApi(clients.axios); + await api.deleteCoupon(couponId); + + if (options.json) { + printJson({ deleted: true, id: couponId }); + return; + } + + process.stdout.write(`Deleted coupon ${couponId}.\n`); + }); + + couponCli + .command("distribute ", "Distribute a coupon to customers") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--all", "Distribute to all customers") + .option("--username ", "Comma separated usernames, repeatable") + .option("--force", "Distribute to all customers without confirmation") + .action(async (id: string, options: CouponDistributeOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const couponId = parsePositiveInteger(id, "Coupon ID"); + const usernames = toStringArray(options.username) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); + + if (options.all && usernames.length > 0) { + throw new CliError("Use either --all or --username, not both."); + } + if (!options.all && usernames.length === 0) { + throw new CliError("Provide --all or at least one --username."); + } + + if ( + options.all && + !(await confirmDangerousAction( + { + commandPath: "halo shop coupon distribute", + actionLabel: "Distribute", + resourceLabel: "coupon", + resourceName: `${couponId} to all customers`, + cancellationVerb: "distributing", + }, + options, + )) + ) { + return; + } + + const api = new ShopApi(clients.axios); + const result = await api.distributeCoupon(couponId, { + allCustomers: Boolean(options.all), + usernames: usernames.length > 0 ? usernames : undefined, + }); + + if (options.json) { + printJson(result ?? { id: couponId, distributionScheduled: true }); + return; + } + + process.stdout.write(`Scheduled coupon ${couponId} distribution.\n`); + }); + + couponCli.usage(" [flags]"); + couponCli.example((bin) => `${bin} list --enabled true`); + couponCli.example((bin) => `${bin} get 1`); + couponCli.example( + (bin) => + `${bin} create --name "Spring sale" --calculation-type PERCENTAGE --discount-value 10 --scope ALL --valid-days 30`, + ); + couponCli.example((bin) => `${bin} distribute 1 --username demo`); + couponCli.example((bin) => `${bin} delete 1 --force`); + couponCli.help(); + + return couponCli; +} diff --git a/src/commands/shop/customer.ts b/src/commands/shop/customer.ts new file mode 100644 index 0000000..9dfa4b5 --- /dev/null +++ b/src/commands/shop/customer.ts @@ -0,0 +1,190 @@ +import { type CAC } from "cac"; + +import { confirmDangerousAction } from "../../utils/confirmation.js"; +import { CliError } from "../../utils/errors.js"; +import { printJson } from "../../utils/output.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { createShopCli } from "./cli.js"; +import { printAddressList, printCustomer, printCustomerList, printEntitlements } from "./format.js"; +import { + parseEnumListOption, + parseEnumOption, + parsePageOption, + parsePositiveInteger, + parseSizeOption, + requireString, +} from "./input.js"; +import type { CustomerStatus } from "./types.js"; + +const CUSTOMER_STATUSES = ["ACTIVE", "BLOCKED"] as const; +const CUSTOMER_TYPES = ["USER", "GUEST"] as const; + +interface ShopCommandOptions { + profile?: string; + json?: boolean; +} + +interface CustomerListOptions extends ShopCommandOptions { + page?: string; + size?: string; + keyword?: string; + status?: string; + type?: string; + sort?: string; +} + +interface CustomerUpdateOptions extends ShopCommandOptions { + status?: string; + note?: string; + clearNote?: boolean; + force?: boolean; +} + +interface CustomerEntitlementsOptions extends ShopCommandOptions { + product?: string; +} + +export function buildCustomerCli(runtime: RuntimeContext): CAC { + const customerCli = createShopCli("halo shop customer"); + + customerCli + .command("list", "List customers") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--page ", "Page number", { default: 1 }) + .option("--size ", "Page size", { default: 20 }) + .option("--keyword ", "Filter by keyword") + .option("--status ", "Customer status: ACTIVE or BLOCKED") + .option("--type ", "Customer type: USER or GUEST") + .option("--sort ", "Sort expression, e.g. id,desc") + .action(async (options: CustomerListOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const statuses = parseEnumListOption(options.status, CUSTOMER_STATUSES, "--status"); + const types = parseEnumListOption(options.type, CUSTOMER_TYPES, "--type"); + + if (statuses && statuses.length > 1) { + throw new CliError("--status accepts a single customer status."); + } + if (types && types.length > 1) { + throw new CliError("--type accepts a single customer type."); + } + + const page = await api.listCustomers({ + page: parsePageOption(options.page), + size: parseSizeOption(options.size), + keyword: options.keyword, + status: statuses?.[0], + type: types?.[0], + sort: options.sort, + }); + printCustomerList(page, options.json); + }); + + customerCli + .command("get ", "Show customer details") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .action(async (id: string, options: ShopCommandOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const customer = await api.getCustomer(parsePositiveInteger(id, "Customer ID")); + printCustomer(customer, options.json); + }); + + customerCli + .command("update ", "Update a customer's status or internal note") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--status ", "ACTIVE or BLOCKED") + .option("--note ", "Internal note") + .option("--clear-note", "Clear the internal note") + .option("--force", "Block the customer without confirmation") + .action(async (id: string, options: CustomerUpdateOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const customerId = parsePositiveInteger(id, "Customer ID"); + const status = parseEnumOption(options.status, CUSTOMER_STATUSES, "--status"); + const clearNote = options.clearNote === true; + + if (clearNote && options.note !== undefined) { + throw new CliError("Use either --note or --clear-note, not both."); + } + if (status === undefined && options.note === undefined && !clearNote) { + throw new CliError("Provide at least one option: --status, --note, or --clear-note."); + } + if (options.note !== undefined && String(options.note).length > 500) { + throw new CliError("Customer note must be at most 500 characters."); + } + + if ( + status === "BLOCKED" && + !(await confirmDangerousAction( + { + commandPath: "halo shop customer update", + actionLabel: "Block", + resourceLabel: "customer", + resourceName: String(customerId), + cancellationVerb: "blocking", + }, + options, + )) + ) { + return; + } + + const api = new ShopApi(clients.axios); + const payload: { note?: string; status?: CustomerStatus } = {}; + if (clearNote) payload.note = ""; + else if (options.note !== undefined) payload.note = String(options.note); + if (status !== undefined) payload.status = status; + + const customer = await api.updateCustomer(customerId, payload); + + if (options.json) { + printJson(customer); + return; + } + + process.stdout.write(`Updated customer ${customerId}.\n`); + }); + + customerCli + .command("addresses ", "List a customer's addresses") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .action(async (id: string, options: ShopCommandOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const addresses = await api.listCustomerAddresses(parsePositiveInteger(id, "Customer ID")); + printAddressList(addresses, options.json); + }); + + customerCli + .command("entitlements ", "Show a customer's entitlements for a product") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--product ", "Product ID") + .action(async (id: string, options: CustomerEntitlementsOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const customerId = parsePositiveInteger(id, "Customer ID"); + const productId = parsePositiveInteger( + requireString(options.product, "--product is required."), + "--product", + ); + const entitlements = await api.getCustomerEntitlements(customerId, productId); + printEntitlements(entitlements, options.json); + }); + + customerCli.usage(" [flags]"); + customerCli.example((bin) => `${bin} list --status ACTIVE`); + customerCli.example((bin) => `${bin} get 1`); + customerCli.example((bin) => `${bin} update 1 --status BLOCKED`); + customerCli.example((bin) => `${bin} update 1 --clear-note`); + customerCli.example((bin) => `${bin} addresses 1`); + customerCli.example((bin) => `${bin} entitlements 1 --product 2`); + customerCli.help(); + + return customerCli; +} diff --git a/src/commands/shop/discount.ts b/src/commands/shop/discount.ts new file mode 100644 index 0000000..e78486f --- /dev/null +++ b/src/commands/shop/discount.ts @@ -0,0 +1,396 @@ +import { type CAC, type Command } from "cac"; + +import { confirmDangerousAction } from "../../utils/confirmation.js"; +import { CliError } from "../../utils/errors.js"; +import { parseBooleanOption } from "../../utils/options.js"; +import { printJson } from "../../utils/output.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { createShopCli } from "./cli.js"; +import { printDiscount, printDiscountList } from "./format.js"; +import { + mergeDefined, + parseEnumOption, + parseInstantOption, + parseMoneyOption, + parseNumberListOption, + parsePageOption, + parsePositiveInteger, + parseSizeOption, + readJsonPayload, + requireString, + type JsonInputOptions, +} from "./input.js"; +import type { + CalculationType, + Discount, + DiscountApplication, + DiscountRequest, + DiscountScope, +} from "./types.js"; + +const CALCULATION_TYPES = ["AMOUNT", "PERCENTAGE"] as const; +const DISCOUNT_SCOPES = ["ALL", "QUALIFYING_ITEMS"] as const; +const DISCOUNT_APPLICATIONS = ["AUTOMATIC", "CODE"] as const; + +interface ShopCommandOptions { + profile?: string; + json?: boolean; +} + +interface DiscountListOptions extends ShopCommandOptions { + page?: string; + size?: string; + sort?: string; +} + +interface DiscountMutationOptions extends ShopCommandOptions, JsonInputOptions { + name?: string; + description?: string; + application?: string; + calculationType?: string; + discountValue?: string; + maxDiscountAmount?: string; + scope?: string; + code?: string; + productIds?: string; + categoryIds?: string; + excludedProductIds?: string; + excludedCategoryIds?: string; + excludeSaleItems?: string; + minOrderAmount?: string; + totalUsageLimit?: string; + usageLimitPerCustomer?: string; + enabled?: string; + startsAt?: string; + endsAt?: string; + force?: boolean; +} + +interface DiscountDeleteOptions extends ShopCommandOptions { + force?: boolean; +} + +function toPositiveIntegerOption(value: string | undefined, label: string): number | undefined { + return value === undefined ? undefined : parsePositiveInteger(value, label); +} + +function buildDiscountPatch(options: DiscountMutationOptions): Record { + const patch: Record = {}; + + if (options.name !== undefined) patch.name = options.name; + if (options.description !== undefined) patch.description = options.description; + + const application = parseEnumOption(options.application, DISCOUNT_APPLICATIONS, "--application"); + if (application !== undefined) patch.application = application; + + const calculationType = parseEnumOption( + options.calculationType, + CALCULATION_TYPES, + "--calculation-type", + ); + if (calculationType !== undefined) patch.calculationType = calculationType; + + const discountValue = parseMoneyOption(options.discountValue, "--discount-value"); + if (discountValue !== undefined) patch.discountValue = discountValue; + + const maxDiscountAmount = parseMoneyOption(options.maxDiscountAmount, "--max-discount-amount"); + if (maxDiscountAmount !== undefined) patch.maxDiscountAmount = maxDiscountAmount; + + const scope = parseEnumOption(options.scope, DISCOUNT_SCOPES, "--scope"); + if (scope !== undefined) patch.scope = scope; + + if (options.code !== undefined) patch.code = options.code; + + const productIds = parseNumberListOption(options.productIds); + if (productIds !== undefined) patch.productIds = productIds; + + const categoryIds = parseNumberListOption(options.categoryIds); + if (categoryIds !== undefined) patch.categoryIds = categoryIds; + + const excludedProductIds = parseNumberListOption(options.excludedProductIds); + if (excludedProductIds !== undefined) patch.excludedProductIds = excludedProductIds; + + const excludedCategoryIds = parseNumberListOption(options.excludedCategoryIds); + if (excludedCategoryIds !== undefined) patch.excludedCategoryIds = excludedCategoryIds; + + if (options.excludeSaleItems !== undefined) { + patch.excludeSaleItems = parseBooleanOption(options.excludeSaleItems); + } + + const minOrderAmount = parseMoneyOption(options.minOrderAmount, "--min-order-amount"); + if (minOrderAmount !== undefined) patch.minOrderAmount = minOrderAmount; + + const totalUsageLimit = toPositiveIntegerOption(options.totalUsageLimit, "--total-usage-limit"); + if (totalUsageLimit !== undefined) patch.totalUsageLimit = totalUsageLimit; + + const usageLimitPerCustomer = toPositiveIntegerOption( + options.usageLimitPerCustomer, + "--usage-limit-per-customer", + ); + if (usageLimitPerCustomer !== undefined) { + patch.usageLimitPerCustomer = usageLimitPerCustomer; + } + + if (options.enabled !== undefined) patch.isEnabled = parseBooleanOption(options.enabled); + if (options.startsAt !== undefined) patch.startsAt = options.startsAt; + if (options.endsAt !== undefined) patch.endsAt = options.endsAt; + + return patch; +} + +function discountToRequest(discount: Discount): DiscountRequest { + return { + name: discount.name, + description: discount.description, + application: discount.application, + calculationType: discount.calculationType, + discountValue: discount.discountValue, + maxDiscountAmount: discount.maxDiscountAmount, + scope: discount.scope, + code: discount.code, + productIds: discount.productIds, + categoryIds: discount.categoryIds, + excludedProductIds: discount.excludedProductIds, + excludedCategoryIds: discount.excludedCategoryIds, + excludeSaleItems: discount.excludeSaleItems, + minOrderAmount: discount.minOrderAmount, + totalUsageLimit: discount.totalUsageLimit, + usageLimitPerCustomer: discount.usageLimitPerCustomer, + isEnabled: discount.isEnabled, + startsAt: discount.startsAt, + endsAt: discount.endsAt, + }; +} + +function validateDiscountRequest(request: DiscountRequest): void { + request.name = requireString(request.name, "Discount name is required. Use --name or --file."); + + if (request.application === undefined) { + throw new CliError("Discount application is required. Use --application."); + } + if (!DISCOUNT_APPLICATIONS.includes(request.application as DiscountApplication)) { + throw new CliError(`Invalid discount application "${String(request.application)}".`); + } + if (request.calculationType === undefined) { + throw new CliError("Discount calculation type is required. Use --calculation-type."); + } + if (!CALCULATION_TYPES.includes(request.calculationType as CalculationType)) { + throw new CliError(`Invalid calculation type "${String(request.calculationType)}".`); + } + if (request.discountValue === undefined) { + throw new CliError("Discount value is required. Use --discount-value."); + } + const discountValue = parseMoneyOption(request.discountValue, "Discount value"); + if (discountValue === undefined) { + throw new CliError("Discount value must be a non-negative number."); + } + if (request.calculationType === "PERCENTAGE" && discountValue > 100) { + throw new CliError("Percentage discount value must be between 0 and 100."); + } + request.discountValue = discountValue; + if (request.scope === undefined) { + throw new CliError("Discount scope is required. Use --scope."); + } + if (!DISCOUNT_SCOPES.includes(request.scope as DiscountScope)) { + throw new CliError(`Invalid discount scope "${String(request.scope)}".`); + } +} + +function normalizeDiscountRequest(request: DiscountRequest): void { + request.startsAt = parseInstantOption(request.startsAt, "Discount startsAt"); + request.endsAt = parseInstantOption(request.endsAt, "Discount endsAt"); + + if (request.application === "AUTOMATIC") { + request.code = undefined; + } else if ( + request.application === "CODE" && + (typeof request.code !== "string" || request.code.trim().length === 0) + ) { + throw new CliError("Discount code is required for CODE discounts. Use --code or --file."); + } + + if (request.isEnabled === undefined) { + return; + } + + const enabled = parseBooleanOption(request.isEnabled); + if (enabled === undefined) { + throw new CliError("Discount isEnabled must be a boolean."); + } + + request.isEnabled = enabled; +} + +export function buildDiscountCli(runtime: RuntimeContext): CAC { + const discountCli = createShopCli("halo shop discount"); + + discountCli + .command("list", "List discounts") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--page ", "Page number", { default: 1 }) + .option("--size ", "Page size", { default: 20 }) + .option("--sort ", "Sort expression, e.g. id,desc") + .action(async (options: DiscountListOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const page = await api.listDiscounts({ + page: parsePageOption(options.page), + size: parseSizeOption(options.size), + sort: options.sort, + }); + printDiscountList(page, options.json); + }); + + discountCli + .command("get ", "Show discount details") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .action(async (id: string, options: ShopCommandOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const discount = await api.getDiscount(parsePositiveInteger(id, "Discount ID")); + printDiscount(discount, options.json); + }); + + const addMutationOptions = (command: Command): Command => + command + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--file ", "Read a DiscountRequest JSON file, use - for stdin") + .option("--stdin", "Read a DiscountRequest JSON payload from stdin") + .option("--name ", "Discount name") + .option("--description ", "Discount description") + .option("--application ", "AUTOMATIC or CODE") + .option("--calculation-type ", "AMOUNT or PERCENTAGE") + .option("--discount-value ", "Discount value") + .option("--max-discount-amount ", "Maximum discount amount") + .option("--scope ", "ALL or QUALIFYING_ITEMS") + .option("--code ", "Discount code for CODE discounts") + .option("--product-ids ", "Comma separated product IDs") + .option("--category-ids ", "Comma separated category IDs") + .option("--excluded-product-ids ", "Comma separated product IDs") + .option("--excluded-category-ids ", "Comma separated category IDs") + .option("--exclude-sale-items ", "Exclude sale items") + .option("--min-order-amount ", "Minimum order amount") + .option("--total-usage-limit ", "Total usage limit") + .option("--usage-limit-per-customer ", "Usage limit per customer") + .option("--enabled ", "Whether the discount is enabled") + .option("--starts-at ", "Start time (ISO-8601)") + .option("--ends-at ", "End time (ISO-8601)"); + + addMutationOptions(discountCli.command("create", "Create a discount")).action( + async (options: DiscountMutationOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const payload: Record = { + ...(await readJsonPayload(options)), + ...buildDiscountPatch(options), + }; + const request = payload as unknown as DiscountRequest; + validateDiscountRequest(request); + normalizeDiscountRequest(request); + const discount = await api.createDiscount(request); + + if (options.json) { + printJson(discount); + return; + } + + process.stdout.write(`Created discount ${discount.id} (${discount.name}).\n`); + }, + ); + + addMutationOptions(discountCli.command("update ", "Update a discount")) + .option("--force", "Disable the discount without confirmation") + .action(async (id: string, options: DiscountMutationOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const discountId = parsePositiveInteger(id, "Discount ID"); + const current = await api.getDiscount(discountId); + const payload = mergeDefined( + discountToRequest(current) as unknown as Record, + { + ...(await readJsonPayload(options)), + ...buildDiscountPatch(options), + }, + ); + const request = payload as unknown as DiscountRequest; + validateDiscountRequest(request); + normalizeDiscountRequest(request); + + if (current.isEnabled === true && request.isEnabled === false) { + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop discount update", + actionLabel: "Disable", + resourceLabel: "discount", + resourceName: String(discountId), + cancellationVerb: "disabling", + }, + options, + )) + ) { + return; + } + } + + const discount = await api.updateDiscount(discountId, request); + + if (options.json) { + printJson(discount); + return; + } + + process.stdout.write(`Updated discount ${discount.id} (${discount.name}).\n`); + }); + + discountCli + .command("delete ", "Delete a discount") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--force", "Delete without confirmation") + .action(async (id: string, options: DiscountDeleteOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const discountId = parsePositiveInteger(id, "Discount ID"); + + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop discount delete", + actionLabel: "Delete", + resourceLabel: "discount", + resourceName: String(discountId), + cancellationVerb: "deleting", + }, + options, + )) + ) { + return; + } + + const api = new ShopApi(clients.axios); + await api.deleteDiscount(discountId); + + if (options.json) { + printJson({ deleted: true, id: discountId }); + return; + } + + process.stdout.write(`Deleted discount ${discountId}.\n`); + }); + + discountCli.usage(" [flags]"); + discountCli.example((bin) => `${bin} list`); + discountCli.example((bin) => `${bin} get 1`); + discountCli.example( + (bin) => + `${bin} create --name "10% off" --application AUTOMATIC --calculation-type PERCENTAGE --discount-value 10 --scope ALL`, + ); + discountCli.example((bin) => `${bin} delete 1 --force`); + discountCli.help(); + + return discountCli; +} diff --git a/src/commands/shop/format.ts b/src/commands/shop/format.ts new file mode 100644 index 0000000..531018b --- /dev/null +++ b/src/commands/shop/format.ts @@ -0,0 +1,391 @@ +import Table from "cli-table3"; +import dayjs from "dayjs"; + +import { printDetailObject, printJson, printPaginationFooter } from "../../utils/output.js"; +import type { + Address, + Coupon, + Customer, + DailyStatistics, + Discount, + Order, + Page, + Product, + ProductCategory, +} from "./types.js"; + +function normalizeCell(value: string | number | boolean | undefined | null): string { + if (value === undefined || value === null) { + return ""; + } + return String(value); +} + +function printTable(headers: string[], rows: Array>, colWidths?: number[]): void { + const table = new Table({ + head: headers, + colWidths, + colAligns: headers.map(() => "left"), + style: { + compact: true, + head: [], + border: [], + "padding-left": 0, + "padding-right": 0, + }, + wordWrap: false, + chars: { + top: "", + "top-mid": "", + "top-left": "", + "top-right": "", + bottom: "", + "bottom-mid": "", + "bottom-left": "", + "bottom-right": "", + left: "", + "left-mid": "", + mid: "", + "mid-mid": "", + right: "", + "right-mid": "", + middle: " ", + }, + }); + + for (const row of rows) { + table.push(row.map(normalizeCell)); + } + + process.stdout.write(`${table.toString()}\n`); +} + +function formatDate(value?: string): string { + if (!value) { + return ""; + } + + const parsed = dayjs(value); + return parsed.isValid() ? parsed.format("YYYY-MM-DD HH:mm") : value; +} + +function formatMoney(value?: number): string { + if (value === undefined || value === null) { + return ""; + } + return value.toFixed(2); +} + +function formatPriceRange(min?: number, max?: number): string { + if (min === undefined && max === undefined) { + return ""; + } + if (min !== undefined && max !== undefined && min !== max) { + return `${formatMoney(min)} ~ ${formatMoney(max)}`; + } + return formatMoney(min ?? max); +} + +function formatCustomerName(customer?: Customer): string { + if (!customer) { + return ""; + } + const name = [customer.firstName, customer.lastName].filter(Boolean).join(" "); + return name || customer.email || customer.userId || String(customer.id); +} + +function printPageFooter(page: Page, itemLabel: string): void { + printPaginationFooter({ + page: page.number + 1, + size: page.size, + total: page.totalElements, + totalPages: page.totalPages, + hasNext: !page.last, + hasPrevious: !page.first, + itemLabel, + }); +} + +export function printProductList(page: Page, json = false): void { + if (json) { + printJson(page); + return; + } + + printTable( + ["ID", "TITLE", "TYPE", "STATUS", "PRICE", "CATEGORY", "UPDATED AT"], + page.content.map((product) => [ + String(product.id), + product.title, + product.productType, + product.status, + formatPriceRange(product.minPrice, product.maxPrice), + product.categoryId === undefined ? "" : String(product.categoryId), + formatDate(product.updatedAt ?? product.createdAt), + ]), + [6, 36, 13, 10, 18, 10, 17], + ); + printPageFooter(page, "product"); +} + +export function printProduct(product: Product, json = false): void { + if (json) { + printJson(product); + return; + } + + printDetailObject(product as unknown as Record); +} + +export function printCategoryList(categories: ProductCategory[], json = false): void { + if (json) { + printJson(categories); + return; + } + + const flatten = (items: ProductCategory[], depth = 0): Array> => + items.flatMap((category) => [ + [ + String(category.id), + `${" ".repeat(depth)}${category.name}`, + category.slug, + category.parentId === undefined ? "" : String(category.parentId), + formatDate(category.updatedAt ?? category.createdAt), + ], + ...flatten(category.children ?? [], depth + 1), + ]); + + printTable( + ["ID", "NAME", "SLUG", "PARENT", "UPDATED AT"], + flatten(categories), + [6, 36, 24, 8, 17], + ); +} + +export function printCategory(category: ProductCategory, json = false): void { + if (json) { + printJson(category); + return; + } + + printDetailObject(category as unknown as Record); +} + +export function printOrderList(page: Page, json = false): void { + if (json) { + printJson(page); + return; + } + + printTable( + ["ID", "CODE", "CUSTOMER", "STATUS", "PAYMENT", "FULFILLMENT", "PAYABLE", "PAID", "CREATED AT"], + page.content.map((order) => [ + String(order.id), + order.orderCode ?? "", + formatCustomerName(order.customer), + order.status, + order.paymentStatus ?? "", + order.fulfillmentStatus ?? "", + formatMoney(order.payableAmount), + formatMoney(order.paidAmount), + formatDate(order.createdAt), + ]), + [6, 20, 18, 9, 12, 12, 10, 10, 17], + ); + printPageFooter(page, "order"); +} + +export function printOrder(order: Order, json = false): void { + if (json) { + printJson(order); + return; + } + + printDetailObject(order as unknown as Record); +} + +export function printCustomerList(page: Page, json = false): void { + if (json) { + printJson(page); + return; + } + + printTable( + ["ID", "NAME", "EMAIL", "TYPE", "STATUS", "ORDERS", "SPENT", "CREATED AT"], + page.content.map((customer) => [ + String(customer.id), + formatCustomerName(customer), + customer.email ?? "", + customer.type ?? "", + customer.status ?? "", + customer.ordersCount === undefined ? "" : String(customer.ordersCount), + formatMoney(customer.totalSpent), + formatDate(customer.createdAt), + ]), + [6, 22, 26, 7, 9, 7, 10, 17], + ); + printPageFooter(page, "customer"); +} + +export function printCustomer(customer: Customer, json = false): void { + if (json) { + printJson(customer); + return; + } + + printDetailObject(customer as unknown as Record); +} + +export function printAddressList(addresses: Address[], json = false): void { + if (json) { + printJson(addresses); + return; + } + + printTable( + ["ID", "NAME", "PHONE", "REGION", "STREET", "DEFAULT"], + addresses.map((address) => [ + String(address.id), + [address.firstName, address.lastName].filter(Boolean).join(" "), + address.contactPhone ?? "", + [address.province, address.city, address.district].filter(Boolean).join(" "), + address.streetAddress ?? "", + address.isDefault ? "yes" : "no", + ]), + [6, 16, 16, 24, 32, 7], + ); +} + +function tryParseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +export function printEntitlements(value: unknown, json = false): void { + const parsed = typeof value === "string" ? tryParseJson(value) : value; + const normalized = parsed === undefined ? value : parsed; + + if (json) { + printJson(normalized); + return; + } + + if (typeof normalized !== "object" || normalized === null) { + process.stdout.write(`${String(normalized)}\n`); + return; + } + + printDetailObject(normalized as Record); +} + +function formatDiscountValue(calculationType: Coupon["calculationType"], value: number): string { + return calculationType === "PERCENTAGE" ? `${value}%` : formatMoney(value); +} + +export function printCouponList(page: Page, json = false): void { + if (json) { + printJson(page); + return; + } + + printTable( + ["ID", "NAME", "TYPE", "VALUE", "SCOPE", "ENABLED", "ISSUED", "CREATED AT"], + page.content.map((coupon) => [ + String(coupon.id), + coupon.name, + coupon.calculationType, + formatDiscountValue(coupon.calculationType, coupon.discountValue), + coupon.scope, + coupon.isEnabled ? "yes" : "no", + coupon.totalCount === undefined + ? String(coupon.issuedCount ?? "") + : `${coupon.issuedCount ?? 0}/${coupon.totalCount}`, + formatDate(coupon.createdAt), + ]), + [6, 26, 12, 10, 18, 8, 10, 17], + ); + printPageFooter(page, "coupon"); +} + +export function printCoupon(coupon: Coupon, json = false): void { + if (json) { + printJson(coupon); + return; + } + + printDetailObject(coupon as unknown as Record); +} + +export function printDiscountList(page: Page, json = false): void { + if (json) { + printJson(page); + return; + } + + printTable( + ["ID", "NAME", "APPLICATION", "TYPE", "VALUE", "SCOPE", "ENABLED", "USAGE", "CREATED AT"], + page.content.map((discount) => [ + String(discount.id), + discount.name, + discount.application, + discount.calculationType, + formatDiscountValue(discount.calculationType, discount.discountValue), + discount.scope, + discount.isEnabled ? "yes" : "no", + String(discount.usageCount ?? 0), + formatDate(discount.createdAt), + ]), + [6, 24, 12, 12, 10, 18, 8, 8, 17], + ); + printPageFooter(page, "discount"); +} + +export function printDiscount(discount: Discount, json = false): void { + if (json) { + printJson(discount); + return; + } + + printDetailObject(discount as unknown as Record); +} + +export function printDailyStatistics(statistics: DailyStatistics[], json = false): void { + if (json) { + printJson(statistics); + return; + } + + printTable( + ["DATE", "ORDERS", "SALES", "PRODUCTS SOLD", "NEW CUSTOMERS"], + statistics.map((item) => [ + item.date, + String(item.orders), + formatMoney(item.sales), + String(item.productsSold), + String(item.customers), + ]), + [12, 10, 12, 14, 14], + ); +} + +export function printConfig(value: unknown, json = false): void { + if (json) { + printJson(value); + return; + } + + if (typeof value === "string") { + process.stdout.write(`${value}\n`); + return; + } + + if (!value || typeof value !== "object") { + process.stdout.write(`${String(value)}\n`); + return; + } + + printDetailObject(value as Record); +} diff --git a/src/commands/shop/index.ts b/src/commands/shop/index.ts new file mode 100644 index 0000000..929220a --- /dev/null +++ b/src/commands/shop/index.ts @@ -0,0 +1,220 @@ +import axios from "axios"; +import { type CAC } from "cac"; + +import { tryRunCommandCliRoute, tryRunNestedCliRoute } from "../../utils/command-router.js"; +import { CliError } from "../../utils/errors.js"; +import { printJson } from "../../utils/output.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { buildCategoryCli } from "./category.js"; +import { createShopCli } from "./cli.js"; +import { buildConfigCli } from "./config.js"; +import { buildCouponCli } from "./coupon.js"; +import { buildCustomerCli } from "./customer.js"; +import { buildDiscountCli } from "./discount.js"; +import { buildOrderCli } from "./order.js"; +import { buildProductCli } from "./product.js"; +import { buildStatsCli } from "./stats.js"; +import type { LicenseActivation } from "./types.js"; + +const COMMERCIAL_PRODUCT_NAMES = new Set(["product-halo-commercial", "product-halo-pro"]); + +interface ShopStatusOptions { + profile?: string; + json?: boolean; +} + +interface HaloActuatorInfo { + build?: { + name?: string; + version?: string; + }; +} + +interface ShopStatusResult { + profile: string; + baseUrl: string; + edition: string; + version?: string; + commercialActivated: boolean; + activationState?: string; + shopApiAvailable: boolean; +} + +function isNotFoundError(error: unknown): boolean { + return axios.isAxiosError(error) && error.response?.status === 404; +} + +function isShopApiUnavailableError(error: unknown): boolean { + return error instanceof CliError && error.message.startsWith("Shop API is not available"); +} + +async function resolveShopStatus( + runtime: RuntimeContext, + options: ShopStatusOptions, +): Promise { + const { profile, clients } = await runtime.getClientsForOptions(options); + + let info: HaloActuatorInfo | undefined; + try { + const response = await clients.axios.get("/actuator/info"); + info = response.data; + } catch (error) { + // Older or restricted instances may not expose actuator info. + if (!isNotFoundError(error)) { + throw error; + } + } + + let activation: LicenseActivation | undefined; + try { + const response = await clients.axios.get( + "/apis/console.api.license.pro.halo.run/v1alpha1/activations", + ); + activation = + response.data.find( + (item) => + COMMERCIAL_PRODUCT_NAMES.has(item.spec?.productName ?? "") && + item.status?.state === "active", + ) ?? response.data.find((item) => COMMERCIAL_PRODUCT_NAMES.has(item.spec?.productName ?? "")); + } catch (error) { + // Non-Pro instances do not expose the license API. + if (!isNotFoundError(error)) { + throw error; + } + } + + let shopApiAvailable = false; + try { + await new ShopApi(clients.axios).listProducts({ page: 1, size: 1 }); + shopApiAvailable = true; + } catch (error) { + // The shop API is absent on non-commercial instances. + if (!isNotFoundError(error) && !isShopApiUnavailableError(error)) { + throw error; + } + } + + return { + profile: profile.name, + baseUrl: profile.baseUrl, + edition: info?.build?.name ?? "unknown", + version: info?.build?.version, + commercialActivated: activation?.status?.state === "active", + activationState: activation?.status?.state, + shopApiAvailable, + }; +} + +const SHOP_GROUPS = [ + { + name: "product", + description: "Product management commands", + build: buildProductCli, + }, + { + name: "category", + description: "Product category management commands", + build: buildCategoryCli, + }, + { + name: "order", + description: "Order management commands", + build: buildOrderCli, + }, + { + name: "customer", + description: "Customer management commands", + build: buildCustomerCli, + }, + { + name: "coupon", + description: "Coupon management commands", + build: buildCouponCli, + }, + { + name: "discount", + description: "Discount management commands", + build: buildDiscountCli, + }, + { + name: "stats", + description: "Shop statistics commands", + build: buildStatsCli, + }, + { + name: "config", + description: "Shop configuration commands", + build: buildConfigCli, + }, +] as const; + +export function registerShopCommands(cli: CAC): void { + cli.command("shop", "Shop management commands (Halo Pro/Commercial)"); +} + +function buildShopCli(runtime: RuntimeContext): CAC { + const shopCli = createShopCli("halo shop"); + + shopCli + .command("status", "Show shop edition, activation, and API availability") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .action(async (options: ShopStatusOptions) => { + const status = await resolveShopStatus(runtime, options); + + if (options.json) { + printJson(status); + return; + } + + process.stdout.write(`edition: ${status.edition}\n`); + if (status.version) { + process.stdout.write(`version: ${status.version}\n`); + } + process.stdout.write(`commercial activated: ${status.commercialActivated ? "yes" : "no"}\n`); + if (status.activationState) { + process.stdout.write(`activation state: ${status.activationState}\n`); + } + process.stdout.write(`shop API available: ${status.shopApiAvailable ? "yes" : "no"}\n`); + }); + + for (const group of SHOP_GROUPS) { + shopCli.command(group.name, group.description); + } + + shopCli.usage(" [flags]"); + shopCli.example((bin) => `${bin} status`); + shopCli.example((bin) => `${bin} product list`); + shopCli.example((bin) => `${bin} order list --payment-status PAID`); + shopCli.example((bin) => `${bin} stats daily --start 2026-01-01`); + shopCli.help(); + + return shopCli; +} + +export async function tryRunShopCommand(args: string[], runtime: RuntimeContext): Promise { + if (args[0] !== "shop") { + return false; + } + + for (const group of SHOP_GROUPS) { + if ( + await tryRunNestedCliRoute({ + branch: group.name, + cliName: `halo shop ${group.name}`, + args, + buildCli: () => group.build(runtime), + }) + ) { + return true; + } + } + + return tryRunCommandCliRoute({ + command: "shop", + cliName: "halo shop", + args, + buildCli: () => buildShopCli(runtime), + }); +} diff --git a/src/commands/shop/input.ts b/src/commands/shop/input.ts new file mode 100644 index 0000000..6c0f970 --- /dev/null +++ b/src/commands/shop/input.ts @@ -0,0 +1,199 @@ +import { readFile } from "node:fs/promises"; + +import { CliError } from "../../utils/errors.js"; + +export interface JsonInputOptions { + file?: string; + stdin?: boolean; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +export async function readJsonPayload( + options: JsonInputOptions, +): Promise | undefined> { + const file = options.file === undefined ? undefined : String(options.file).trim(); + if (options.file !== undefined && !file) { + throw new CliError("The --file path is empty."); + } + if (!file && !options.stdin) { + return undefined; + } + + if (file && options.stdin) { + throw new CliError("Provide either --file or --stdin, not both."); + } + + const raw = file === "-" || options.stdin ? await readStdin() : await readFile(file!, "utf8"); + + if (raw.trim().length === 0) { + throw new CliError("The provided JSON input is empty."); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new CliError( + `Invalid JSON input: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError("The JSON input must be an object."); + } + + return parsed as Record; +} + +export function mergeDefined>(base: T, overrides: T): T { + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) { + merged[key] = value; + } + } + return merged as T; +} + +export function requireString( + value: string | number | boolean | undefined | null, + message: string, +): string { + const normalized = value === undefined || value === null ? "" : String(value).trim(); + if (normalized.length === 0) { + throw new CliError(message); + } + return normalized; +} + +export function parsePositiveInteger(value: string | number, label: string): number { + if (typeof value !== "number" && typeof value !== "string") { + throw new CliError(`${label} must be a positive integer, got "${String(value)}".`); + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new CliError(`${label} must be a positive integer, got "${value}".`); + } + return parsed; +} + +export function parsePageOption(value?: string | number): number | undefined { + return value === undefined ? undefined : parsePositiveInteger(value, "Page"); +} + +export function parseSizeOption(value?: string | number): number | undefined { + return value === undefined ? undefined : parsePositiveInteger(value, "Size"); +} + +export function parseMoneyOption( + value: string | number | undefined, + label: string, +): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" && typeof value !== "string") { + throw new CliError(`${label} must be a non-negative number, got "${String(value)}".`); + } + const normalized = String(value).trim(); + if (normalized.length === 0) { + throw new CliError(`${label} requires a non-empty value.`); + } + const parsed = Number(normalized); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new CliError(`${label} must be a non-negative number, got "${value}".`); + } + return parsed; +} + +const ISO_INSTANT_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; + +export function parseInstantOption(value: unknown, label: string): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "string") { + throw new CliError(`${label} must be an ISO-8601 instant.`); + } + + const normalized = value.trim(); + const datePart = normalized.split("T")[0] ?? ""; + const parsedDate = new Date(`${datePart}T00:00:00Z`); + if ( + !ISO_INSTANT_PATTERN.test(normalized) || + Number.isNaN(Date.parse(normalized)) || + Number.isNaN(parsedDate.getTime()) || + parsedDate.toISOString().slice(0, 10) !== datePart + ) { + throw new CliError(`${label} must be an ISO-8601 instant, got "${value}".`); + } + + return normalized; +} + +export function toStringArray( + value: string | number | Array | undefined, +): string[] { + if (value === undefined) { + return []; + } + return (Array.isArray(value) ? value : [value]).map((item) => String(item)); +} + +export function parseEnumOption( + value: string | number | undefined, + allowed: readonly T[], + optionName: string, +): T | undefined { + if (value === undefined) { + return undefined; + } + + const normalized = String(value).trim().toUpperCase() as T; + if (!allowed.includes(normalized)) { + throw new CliError( + `Invalid ${optionName} "${value}". Supported values: ${allowed.join(", ")}.`, + ); + } + return normalized; +} + +export function parseEnumListOption( + value: string | number | undefined, + allowed: readonly T[], + optionName: string, +): T[] | undefined { + if (value === undefined) { + return undefined; + } + + return String(value) + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => parseEnumOption(item, allowed, optionName)!); +} + +export function parseNumberListOption(value: string | number | undefined): number[] | undefined { + if (value === undefined) { + return undefined; + } + + return String(value) + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => parsePositiveInteger(item, "ID")); +} + +export function hasAnyOption(options: Record, keys: string[]): boolean { + return keys.some((key) => options[key] !== undefined); +} diff --git a/src/commands/shop/order.ts b/src/commands/shop/order.ts new file mode 100644 index 0000000..58f2398 --- /dev/null +++ b/src/commands/shop/order.ts @@ -0,0 +1,535 @@ +import { type CAC } from "cac"; + +import { confirmDangerousAction } from "../../utils/confirmation.js"; +import { CliError } from "../../utils/errors.js"; +import { parseBooleanOption } from "../../utils/options.js"; +import { printJson } from "../../utils/output.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { createShopCli } from "./cli.js"; +import { printOrder, printOrderList } from "./format.js"; +import { + parseEnumListOption, + parseEnumOption, + parseInstantOption, + parseMoneyOption, + parsePageOption, + parsePositiveInteger, + parseSizeOption, + readJsonPayload, + requireString, + toStringArray, + type JsonInputOptions, +} from "./input.js"; +import type { + FulfillmentItemRequest, + FulfillmentRequest, + Order, + OrderCreateItem, + OrderCreateRequest, +} from "./types.js"; + +const ORDER_STATUSES = ["OPEN", "ARCHIVED", "CANCELLED"] as const; +const PAYMENT_STATUSES = [ + "PENDING", + "AUTHORIZING", + "PAID", + "REFUNDED", + "FAILED", + "EXPIRED", + "CANCELLED", +] as const; +const FULFILLMENT_STATUSES = ["PENDING", "PROCESSING", "FULFILLED"] as const; +const REFUND_STATUSES = ["NONE", "PARTIAL", "FULL"] as const; +const FULFILLMENT_PROVIDERS = ["MANUAL"] as const; + +interface ShopCommandOptions { + profile?: string; + json?: boolean; +} + +interface OrderListOptions extends ShopCommandOptions { + page?: string; + size?: string; + keyword?: string; + customer?: string; + username?: string; + orderCode?: string; + status?: string; + paymentStatus?: string; + fulfillmentStatus?: string; + refundStatus?: string; + startDate?: string; + endDate?: string; + sort?: string; +} + +interface OrderCreateOptions extends ShopCommandOptions, JsonInputOptions { + username?: string; + item?: string | string[]; + note?: string; + trackInventory?: string; +} + +interface OrderActionOptions extends ShopCommandOptions { + force?: boolean; +} + +interface OrderFulfillOptions extends ShopCommandOptions, JsonInputOptions { + item?: string | string[]; + all?: boolean; + provider?: string; + carrier?: string; + trackingNumber?: string; + trackingUrl?: string; + labelUrl?: string; + force?: boolean; +} + +interface OrderNoteOptions extends ShopCommandOptions { + message?: string; + payload?: string; +} + +function parseOrderItem(value: string): OrderCreateItem { + const parts = value.split(":"); + if (parts.length !== 3) { + throw new CliError(`Invalid --item "${value}". Expected ::.`); + } + + const [variantId, quantity, price] = parts as [string, string, string]; + const productVariantId = parsePositiveInteger(variantId, "Variant ID"); + const parsedQuantity = parsePositiveInteger(quantity, "Quantity"); + + if (price.trim().length === 0) { + throw new CliError(`Unit price is required in --item "${value}".`); + } + + const customPrice = Number(price); + + if (!Number.isFinite(customPrice) || customPrice < 0) { + throw new CliError(`Invalid unit price "${price}" in --item "${value}".`); + } + + return { + productVariantId, + quantity: parsedQuantity, + customPrice, + }; +} + +function requireNumericInput(value: unknown, label: string): string | number { + if (value === undefined || value === null) { + throw new CliError(`${label} is required.`); + } + + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + + throw new CliError(`${label} must be a number.`); +} + +function normalizeOrderCreateItems(items: unknown): OrderCreateItem[] { + if (!Array.isArray(items) || items.length === 0) { + throw new CliError( + "At least one order item is required. Use --item :: or --file.", + ); + } + + return items.map((value, index) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new CliError(`Order item ${index + 1} must be an object.`); + } + + const item = value as Record; + const productVariantId = parsePositiveInteger( + requireNumericInput(item.productVariantId, `items[${index}].productVariantId`), + `items[${index}].productVariantId`, + ); + const quantity = parsePositiveInteger( + requireNumericInput(item.quantity, `items[${index}].quantity`), + `items[${index}].quantity`, + ); + const customPrice = parseMoneyOption( + requireNumericInput(item.customPrice, `items[${index}].customPrice`), + `items[${index}].customPrice`, + ); + + if (customPrice === undefined) { + throw new CliError(`items[${index}].customPrice is required.`); + } + + let subscriptionTrial: boolean | undefined; + if (item.subscriptionTrial !== undefined && item.subscriptionTrial !== null) { + subscriptionTrial = parseBooleanOption(item.subscriptionTrial); + if (subscriptionTrial === undefined) { + throw new CliError(`items[${index}].subscriptionTrial must be a boolean.`); + } + } + + return { + productVariantId, + quantity, + customPrice, + ...(subscriptionTrial === undefined ? {} : { subscriptionTrial }), + }; + }); +} + +async function buildOrderCreateRequest(options: OrderCreateOptions): Promise { + const payload: Record = { + ...(await readJsonPayload(options)), + }; + + if (options.username !== undefined) payload.username = options.username; + if (options.note !== undefined) payload.customerNotes = options.note; + if (options.trackInventory !== undefined) { + payload.trackInventory = parseBooleanOption(options.trackInventory); + } + + const itemFlags = toStringArray(options.item).map(parseOrderItem); + if (itemFlags.length > 0) { + payload.items = itemFlags; + } + + const request = payload as unknown as OrderCreateRequest; + request.username = requireString( + request.username, + "Buyer username is required. Use --username or --file.", + ); + request.items = normalizeOrderCreateItems(request.items); + + return request; +} + +function resolveFulfillmentItems(order: Order): FulfillmentItemRequest[] { + return (order.items ?? []) + .filter((item) => item.subscriptionMetadata === undefined || item.subscriptionMetadata === null) + .map((item) => ({ + orderItemId: item.id, + quantity: item.quantity - (item.fulfilledQuantity ?? 0), + })) + .filter((item) => item.quantity > 0); +} + +async function buildFulfillmentRequest( + api: ShopApi, + orderId: number, + options: OrderFulfillOptions, +): Promise { + const filePayload = (await readJsonPayload(options)) ?? {}; + const payload: Record = { items: [], ...filePayload }; + + const itemFlags = toStringArray(options.item); + if (itemFlags.length > 0) { + payload.items = itemFlags.map((value) => { + const parts = value.split(":"); + if (parts.length !== 2) { + throw new CliError(`Invalid --item "${value}". Expected :.`); + } + const [orderItemId, quantity] = parts as [string, string]; + return { + orderItemId: parsePositiveInteger(orderItemId, "Order item ID"), + quantity: parsePositiveInteger(quantity, "Quantity"), + }; + }); + } else if (options.all) { + const order = await api.getOrder(orderId); + const items = resolveFulfillmentItems(order); + payload.items = items; + if (items.length === 0) { + const hasSubscriptionLine = (order.items ?? []).some( + (item) => item.subscriptionMetadata !== undefined && item.subscriptionMetadata !== null, + ); + throw new CliError( + hasSubscriptionLine + ? `Order ${orderId} has no manually fulfillable items. Subscription order lines are fulfilled by the integrator.` + : `Order ${orderId} has no unfulfilled items.`, + ); + } + } + + const provider = parseEnumOption(options.provider, FULFILLMENT_PROVIDERS, "--provider"); + if (provider !== undefined) { + payload.provider = provider; + } + + const hasLabel = [ + options.carrier, + options.trackingNumber, + options.trackingUrl, + options.labelUrl, + ].some((value) => value !== undefined); + if (hasLabel) { + payload.labels = [ + { + carrier: options.carrier, + trackingNumber: options.trackingNumber, + trackingUrl: options.trackingUrl, + labelUrl: options.labelUrl, + }, + ]; + } + + const request = payload as unknown as FulfillmentRequest; + request.provider = + request.provider == null + ? undefined + : parseEnumOption(request.provider as string, FULFILLMENT_PROVIDERS, "provider"); + if (!Array.isArray(request.items) || request.items.length === 0) { + throw new CliError( + "No fulfillment items provided. Use --item :, --all, or --file.", + ); + } + + return request; +} + +export function buildOrderCli(runtime: RuntimeContext): CAC { + const orderCli = createShopCli("halo shop order"); + + orderCli + .command("list", "List orders") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--page ", "Page number", { default: 1 }) + .option("--size ", "Page size", { default: 20 }) + .option("--keyword ", "Search keyword (order code)") + .option("--customer ", "Filter by customer ID") + .option("--username ", "Filter by buyer username") + .option("--order-code ", "Filter by order code") + .option("--status ", "Comma separated statuses: OPEN, ARCHIVED, CANCELLED") + .option("--payment-status ", "Comma separated payment statuses") + .option("--fulfillment-status ", "Comma separated fulfillment statuses") + .option("--refund-status ", "Comma separated refund statuses") + .option("--start-date ", "Creation time lower bound (ISO-8601)") + .option("--end-date ", "Creation time upper bound (ISO-8601)") + .option("--sort ", "Sort expression, e.g. id,desc") + .action(async (options: OrderListOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const page = await api.listOrders({ + page: parsePageOption(options.page), + size: parseSizeOption(options.size), + keyword: options.keyword ?? options.orderCode, + customerId: + options.customer === undefined + ? undefined + : parsePositiveInteger(options.customer, "--customer"), + username: options.username, + orderCode: options.orderCode, + statuses: parseEnumListOption(options.status, ORDER_STATUSES, "--status"), + paymentStatuses: parseEnumListOption( + options.paymentStatus, + PAYMENT_STATUSES, + "--payment-status", + ), + fulfillmentStatuses: parseEnumListOption( + options.fulfillmentStatus, + FULFILLMENT_STATUSES, + "--fulfillment-status", + ), + refundStatuses: parseEnumListOption( + options.refundStatus, + REFUND_STATUSES, + "--refund-status", + ), + startDate: parseInstantOption(options.startDate, "--start-date"), + endDate: parseInstantOption(options.endDate, "--end-date"), + sort: options.sort, + }); + printOrderList(page, options.json); + }); + + orderCli + .command("get ", "Show order details") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .action(async (id: string, options: ShopCommandOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const order = await api.getOrder(parsePositiveInteger(id, "Order ID")); + printOrder(order, options.json); + }); + + orderCli + .command("create", "Create an order as an administrator") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--file ", "Read an AdminOrderCreateRequest JSON file, use - for stdin") + .option("--stdin", "Read an AdminOrderCreateRequest JSON payload from stdin") + .option("--username ", "Buyer username") + .option("--item ", "Order item as ::, repeatable") + .option("--note ", "Customer note") + .option("--track-inventory ", "Whether to track inventory") + .action(async (options: OrderCreateOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const request = await buildOrderCreateRequest(options); + const order = await api.createOrder(request); + + if (options.json) { + printJson(order); + return; + } + + process.stdout.write( + `Created order ${order.id}${order.orderCode ? ` (${order.orderCode})` : ""}.\n`, + ); + }); + + orderCli + .command("cancel ", "Cancel an order") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--force", "Cancel without confirmation") + .action(async (id: string, options: OrderActionOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const orderId = parsePositiveInteger(id, "Order ID"); + + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop order cancel", + actionLabel: "Cancel", + resourceLabel: "order", + resourceName: String(orderId), + cancellationVerb: "cancelling", + }, + options, + )) + ) { + return; + } + + const api = new ShopApi(clients.axios); + await api.cancelOrder(orderId); + + if (options.json) { + printJson({ id: orderId, cancelled: true }); + return; + } + + process.stdout.write(`Cancelled order ${orderId}.\n`); + }); + + orderCli + .command("mark-paid ", "Mark an order as paid") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--force", "Mark as paid without confirmation") + .action(async (id: string, options: OrderActionOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const orderId = parsePositiveInteger(id, "Order ID"); + + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop order mark-paid", + actionLabel: "Mark as paid", + resourceLabel: "order", + resourceName: String(orderId), + cancellationVerb: "marking as paid", + }, + options, + )) + ) { + return; + } + + const api = new ShopApi(clients.axios); + await api.markOrderAsPaid(orderId); + + if (options.json) { + printJson({ id: orderId, markedAsPaid: true }); + return; + } + + process.stdout.write(`Marked order ${orderId} as paid.\n`); + }); + + orderCli + .command("fulfill ", "Fulfill order items") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--file ", "Read a FulfillmentRequest JSON file, use - for stdin") + .option("--stdin", "Read a FulfillmentRequest JSON payload from stdin") + .option("--item ", "Order item as :, repeatable") + .option("--all", "Fulfill all unfulfilled item quantities") + .option("--provider ", "Fulfillment provider (MANUAL)") + .option("--carrier ", "Shipping carrier") + .option("--tracking-number ", "Tracking number") + .option("--tracking-url ", "Tracking URL") + .option("--label-url ", "Shipping label URL") + .option("--force", "Fulfill without confirmation") + .action(async (id: string, options: OrderFulfillOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const orderId = parsePositiveInteger(id, "Order ID"); + const request = await buildFulfillmentRequest(api, orderId, options); + + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop order fulfill", + actionLabel: "Fulfill", + resourceLabel: "order", + resourceName: String(orderId), + cancellationVerb: "fulfilling", + }, + options, + )) + ) { + return; + } + + const result = await api.fulfillOrder(orderId, request); + + if (options.json) { + printJson(result ?? { id: orderId, fulfilled: true }); + return; + } + + process.stdout.write(`Fulfilled order ${orderId}.\n`); + }); + + orderCli + .command("note ", "Add a timeline note to an order") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--message ", "Note message") + .option("--payload ", "Optional payload, preferably a JSON string") + .action(async (id: string, options: OrderNoteOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const orderId = parsePositiveInteger(id, "Order ID"); + const message = requireString(options.message, "Note message is required. Use --message."); + if (message.length > 1000) { + throw new CliError("Note message must be at most 1000 characters."); + } + const result = await api.addOrderNote(orderId, message, options.payload); + + if (options.json) { + printJson(result ?? { id: orderId, noteAdded: true }); + return; + } + + process.stdout.write(`Added note to order ${orderId}.\n`); + }); + + orderCli.usage(" [flags]"); + orderCli.example((bin) => `${bin} list --payment-status PAID`); + orderCli.example((bin) => `${bin} get 1001`); + orderCli.example((bin) => `${bin} create --username demo --item 1:1:9.9`); + orderCli.example((bin) => `${bin} mark-paid 1001 --force`); + orderCli.example((bin) => `${bin} fulfill 1001 --all`); + orderCli.example((bin) => `${bin} note 1001 --message "Called the buyer"`); + orderCli.help(); + + return orderCli; +} diff --git a/src/commands/shop/product.ts b/src/commands/shop/product.ts new file mode 100644 index 0000000..d2e847d --- /dev/null +++ b/src/commands/shop/product.ts @@ -0,0 +1,986 @@ +import { type CAC } from "cac"; + +import { resolvePermalinkUrl } from "../../utils/browser.js"; +import { confirmDangerousAction } from "../../utils/confirmation.js"; +import { CliError } from "../../utils/errors.js"; +import { parseBooleanOption } from "../../utils/options.js"; +import { printJson } from "../../utils/output.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { createShopCli } from "./cli.js"; +import { printProduct, printProductList } from "./format.js"; +import { + hasAnyOption, + mergeDefined, + parseEnumListOption, + parseEnumOption, + parseMoneyOption, + parsePageOption, + parsePositiveInteger, + parseSizeOption, + readJsonPayload, + requireString, + type JsonInputOptions, +} from "./input.js"; +import type { Product, ProductRequest, ProductStatus, ProductVariant } from "./types.js"; + +const PRODUCT_TYPES = ["PHYSICAL", "VIRTUAL", "EXTERNAL", "SUBSCRIPTION"] as const; +const PRODUCT_STATUSES = ["DRAFT", "PUBLISHED", "ARCHIVED"] as const; +const PRICING_TYPES = ["FIXED", "INQUIRY"] as const; +const VARIANT_PATCH_OPTIONS = [ + "variant", + "price", + "originalPrice", + "stock", + "sku", + "barcode", + "variantStatus", + "shippingRequired", + "trackInventory", +]; + +interface ShopCommandOptions { + profile?: string; + json?: boolean; +} + +interface ProductListOptions extends ShopCommandOptions { + page?: string; + size?: string; + keyword?: string; + category?: string; + status?: string; + type?: string; + sort?: string; +} + +interface ProductMutationOptions extends ShopCommandOptions, JsonInputOptions { + title?: string; + handle?: string; + description?: string; + category?: string; + type?: string; + status?: string; + content?: string; + images?: string; + cover?: string; + externalUrl?: string; + externalButtonText?: string; + variant?: string; + price?: string; + originalPrice?: string; + stock?: string; + sku?: string; + barcode?: string; + variantStatus?: string; + shippingRequired?: string; + trackInventory?: string; + force?: boolean; +} + +interface ProductDeleteOptions extends ShopCommandOptions { + force?: boolean; +} + +interface ProductUnpublishOptions extends ShopCommandOptions { + force?: boolean; +} + +function parseStock(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + if (value.trim().length === 0) { + throw new CliError("--stock requires a non-empty value."); + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new CliError(`--stock must be a non-negative integer, got "${value}".`); + } + return parsed; +} + +function buildVariantPatch(options: ProductMutationOptions): Record { + const patch: Record = {}; + + const price = parseMoneyOption(options.price, "--price"); + if (price !== undefined) patch.price = price; + + const originalPrice = parseMoneyOption(options.originalPrice, "--original-price"); + if (originalPrice !== undefined) patch.originalPrice = originalPrice; + + const stock = parseStock(options.stock); + if (stock !== undefined) { + patch.stock = stock; + patch.trackInventory = parseBooleanOption(options.trackInventory) ?? true; + } else if (options.trackInventory !== undefined) { + patch.trackInventory = parseBooleanOption(options.trackInventory); + } + + if (options.sku !== undefined) patch.skuCode = options.sku; + if (options.barcode !== undefined) patch.barcode = options.barcode; + + const variantStatus = parseEnumOption( + options.variantStatus, + PRODUCT_STATUSES, + "--variant-status", + ); + if (variantStatus !== undefined) patch.status = variantStatus; + + if (options.shippingRequired !== undefined) { + patch.shippingRequired = parseBooleanOption(options.shippingRequired); + } + + return patch; +} + +function hasVariantPatch(options: ProductMutationOptions): boolean { + return hasAnyOption(options as unknown as Record, VARIANT_PATCH_OPTIONS); +} + +function variantToRequest(variant: ProductVariant): ProductVariant { + return { + id: variant.id, + specValues: variant.specValues, + price: variant.price, + originalPrice: variant.originalPrice, + pricingType: variant.pricingType, + trackInventory: variant.trackInventory, + stock: variant.stock ?? 0, + maxPerOrder: variant.maxPerOrder, + maxPerUser: variant.maxPerUser, + skuCode: variant.skuCode, + barcode: variant.barcode, + shippingProfileId: variant.shippingProfileId, + shippingRequired: variant.shippingRequired, + weightG: variant.weightG, + lengthMm: variant.lengthMm, + widthMm: variant.widthMm, + heightMm: variant.heightMm, + imageUrl: variant.imageUrl, + status: variant.status ?? "DRAFT", + }; +} + +function productToRequest(product: Product): ProductRequest { + return { + title: product.title, + productType: product.productType, + status: product.status, + handle: product.handle, + description: product.description, + categoryId: product.categoryId, + externalLinkUrl: product.externalLinkUrl, + externalLinkButtonText: product.externalLinkButtonText, + content: product.content, + images: product.images, + specDefinition: product.specDefinition, + productVariants: + product.productType === "SUBSCRIPTION" + ? undefined + : product.productVariants?.map(variantToRequest), + }; +} + +function buildProductFlagPatch(options: ProductMutationOptions): Record { + const patch: Record = {}; + + if (options.title !== undefined) patch.title = options.title; + if (options.handle !== undefined) patch.handle = options.handle; + if (options.description !== undefined) patch.description = options.description; + if (options.content !== undefined) patch.content = options.content; + + if (options.category !== undefined) { + patch.categoryId = parsePositiveInteger(options.category, "--category"); + } + + const productType = parseEnumOption(options.type, PRODUCT_TYPES, "--type"); + if (productType !== undefined) patch.productType = productType; + + const status = parseEnumOption(options.status, PRODUCT_STATUSES, "--status"); + if (status !== undefined) patch.status = status; + + if (options.externalUrl !== undefined) patch.externalLinkUrl = options.externalUrl; + if (options.externalButtonText !== undefined) { + patch.externalLinkButtonText = options.externalButtonText; + } + + if (options.images !== undefined) { + patch.images = options.images + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + + return patch; +} + +function applyCover(payload: Record, options: ProductMutationOptions): void { + if (options.cover === undefined) { + return; + } + + const existingImages = Array.isArray(payload.images) ? [...payload.images] : []; + if (existingImages.length === 0) { + payload.images = [options.cover]; + return; + } + + existingImages[0] = options.cover; + payload.images = existingImages; +} + +function parseVariantNumber(value: unknown, label: string, integer = false): number { + const parsed = typeof value === "string" && value.trim().length > 0 ? Number(value) : value; + if ( + typeof parsed !== "number" || + !Number.isFinite(parsed) || + parsed < 0 || + (integer && !Number.isInteger(parsed)) + ) { + throw new CliError( + `${label} must be a non-negative ${integer ? "integer" : "number"}, got "${String(value)}".`, + ); + } + return parsed; +} + +function normalizeVariants(payload: Record, current?: Product): void { + const variants = payload.productVariants; + if (variants === undefined || variants === null) { + return; + } + if (!Array.isArray(variants)) { + throw new CliError("productVariants must be an array."); + } + + const currentStatusById = new Map( + (current?.productVariants ?? []).flatMap((variant) => + variant.id === undefined ? [] : [[variant.id, variant.status] as const], + ), + ); + + for (const variant of variants as (ProductVariant | null)[]) { + if (!variant || typeof variant !== "object" || Array.isArray(variant)) { + throw new CliError("Product variants must be objects."); + } + + const variantLabel = `Variant ${variant.id ?? "(new)"}`; + if (variant.price != null) { + variant.price = parseVariantNumber(variant.price, `${variantLabel} price`); + } + if (variant.originalPrice != null) { + variant.originalPrice = parseVariantNumber( + variant.originalPrice, + `${variantLabel} originalPrice`, + ); + } + if (variant.stock != null) { + variant.stock = parseVariantNumber(variant.stock, `${variantLabel} stock`, true); + } + + const pricingType = + variant.pricingType == null + ? "FIXED" + : parseEnumOption(variant.pricingType, PRICING_TYPES, "variant pricingType"); + variant.pricingType = pricingType; + + if (pricingType === "FIXED" && variant.price == null) { + throw new CliError( + `${variantLabel} requires a price for FIXED pricing. Use --price or set price in --file.`, + ); + } + + const status = + variant.status == null + ? undefined + : parseEnumOption(variant.status, PRODUCT_STATUSES, "variant status"); + variant.status = + status ?? + (variant.id === undefined ? undefined : currentStatusById.get(variant.id)) ?? + "DRAFT"; + } +} + +function validateVariantOwnership(payload: Record, current: Product): void { + if (!Array.isArray(payload.productVariants)) { + return; + } + + const currentIds = new Set( + (current.productVariants ?? []).flatMap((variant) => + variant.id === undefined ? [] : [variant.id], + ), + ); + const seen = new Set(); + + for (const variant of payload.productVariants as ProductVariant[]) { + if (variant.id == null) { + continue; + } + + if (!currentIds.has(variant.id)) { + throw new CliError(`Variant ${variant.id} was not found on this product.`); + } + if (seen.has(variant.id)) { + throw new CliError(`Variant ${variant.id} was provided multiple times.`); + } + seen.add(variant.id); + } +} + +function applyVariantFlagsToProduct( + payload: Record, + options: ProductMutationOptions, + variantOptionSupported: boolean, +): void { + if (!hasVariantPatch(options)) { + return; + } + + if (payload.productType === "SUBSCRIPTION") { + throw new CliError( + "Subscription product variants are managed by subscription plan APIs and are not supported by this command.", + ); + } + + if (payload.productType === "EXTERNAL") { + throw new CliError("External products do not support product variants."); + } + + const variantPatch = buildVariantPatch(options); + const variants = Array.isArray(payload.productVariants) + ? ([...payload.productVariants] as ProductVariant[]) + : []; + + let target: ProductVariant; + if (options.variant !== undefined) { + const variantId = parsePositiveInteger(options.variant, "--variant"); + const found = variants.find((variant) => variant.id === variantId); + if (!found) { + throw new CliError(`Variant ${variantId} was not found on this product.`); + } + target = found; + } else if (variants.length === 1) { + target = variants[0] as ProductVariant; + } else if (variants.length === 0) { + if (variantPatch.price === undefined) { + throw new CliError("--price is required when adding the first product variant."); + } + target = { price: variantPatch.price as number }; + variants.push(target); + } else { + throw new CliError( + variantOptionSupported + ? "This product has multiple variants. Use --variant to choose one, or use --file to edit variants." + : "This product has multiple variants. Use --file to edit variants.", + ); + } + + Object.assign(target, variantPatch); + payload.productVariants = variants; +} + +async function buildCreateRequest(options: ProductMutationOptions): Promise { + const filePayload = (await readJsonPayload(options)) ?? {}; + const payload: Record = { ...filePayload }; + + Object.assign(payload, buildProductFlagPatch(options)); + applyCover(payload, options); + + payload.productType ??= "PHYSICAL"; + payload.status ??= "DRAFT"; + + const request = payload as unknown as ProductRequest; + request.title = requireString(request.title, "Product title is required. Use --title or --file."); + + if (!PRODUCT_TYPES.includes(request.productType)) { + throw new CliError(`Invalid product type "${request.productType}".`); + } + if (!PRODUCT_STATUSES.includes(request.status)) { + throw new CliError(`Invalid product status "${request.status}".`); + } + if ( + request.productType !== "EXTERNAL" && + (request.externalLinkUrl != null || request.externalLinkButtonText != null) + ) { + throw new CliError("External link fields are only supported for EXTERNAL products."); + } + if (payload.productVariants != null && !Array.isArray(payload.productVariants)) { + throw new CliError("productVariants must be an array."); + } + + if (hasVariantPatch(options)) { + applyVariantFlagsToProduct(payload, options, false); + } + + normalizeVariants(payload); + return request; +} + +interface ProductUpdateChanges { + basicInfo: boolean; + content: boolean; + status: boolean; + specifications: boolean; +} + +function hasOwn(payload: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(payload, key); +} + +function detectProductUpdateChanges( + filePayload: Record, + options: ProductMutationOptions, +): ProductUpdateChanges { + return { + basicInfo: + hasOwn(filePayload, "title") || + hasOwn(filePayload, "handle") || + hasOwn(filePayload, "description") || + hasOwn(filePayload, "categoryId") || + options.title !== undefined || + options.handle !== undefined || + options.description !== undefined || + options.category !== undefined, + content: + hasOwn(filePayload, "content") || + hasOwn(filePayload, "images") || + options.content !== undefined || + options.images !== undefined || + options.cover !== undefined, + status: hasOwn(filePayload, "status") || options.status !== undefined, + specifications: + hasOwn(filePayload, "specDefinition") || + hasOwn(filePayload, "productVariants") || + hasVariantPatch(options), + }; +} + +function mergeVariantsWithCurrent(payload: Record, current: Product): void { + const variants = payload.productVariants; + if (!Array.isArray(variants)) { + return; + } + + const currentById = new Map( + (current.productVariants ?? []).flatMap((variant) => + variant.id === undefined ? [] : [[variant.id, variant] as const], + ), + ); + + payload.productVariants = variants.map((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + + const variant = value as ProductVariant; + if (variant.id == null) { + return variant; + } + + const existing = currentById.get(variant.id); + return existing === undefined ? variant : { ...variantToRequest(existing), ...variant }; + }); +} + +interface ProductUpdatePlan { + request: ProductRequest; + changes: ProductUpdateChanges; +} + +function buildUpdateRequestFrom( + current: Product, + filePayload: Record, + options: ProductMutationOptions, +): ProductUpdatePlan { + const payload = mergeDefined( + productToRequest(current) as unknown as Record, + filePayload, + ); + + Object.assign(payload, buildProductFlagPatch(options)); + applyCover(payload, options); + + if (payload.productVariants === null) { + payload.productVariants = []; + } + if (payload.productVariants != null && !Array.isArray(payload.productVariants)) { + throw new CliError("productVariants must be an array."); + } + + if (payload.productType !== current.productType) { + throw new CliError(`Product type cannot be changed (current: ${current.productType}).`); + } + + if (!PRODUCT_STATUSES.includes(payload.status as ProductStatus)) { + throw new CliError(`Invalid product status "${String(payload.status)}".`); + } + + const hasExternalFields = + hasOwn(filePayload, "externalLinkUrl") || + hasOwn(filePayload, "externalLinkButtonText") || + options.externalUrl !== undefined || + options.externalButtonText !== undefined; + if (hasExternalFields && payload.productType !== "EXTERNAL") { + throw new CliError("External link fields are only supported for EXTERNAL products."); + } + + mergeVariantsWithCurrent(payload, current); + + if (hasVariantPatch(options)) { + applyVariantFlagsToProduct(payload, options, true); + } + + normalizeVariants(payload, current); + validateVariantOwnership(payload, current); + return { + request: payload as unknown as ProductRequest, + changes: detectProductUpdateChanges(filePayload, options), + }; +} + +async function buildUpdateRequest( + api: ShopApi, + id: number, + options: ProductMutationOptions, +): Promise<{ + current: Product; + filePayload: Record; + plan: ProductUpdatePlan; +}> { + const filePayload = (await readJsonPayload(options)) ?? {}; + const current = await api.getProduct(id); + return { + current, + filePayload, + plan: buildUpdateRequestFrom(current, filePayload, options), + }; +} + +interface ProductUpdateDanger { + unpublish: boolean; + disabledVariantIds: number[]; +} + +function detectProductUpdateDanger(current: Product, request: ProductRequest): ProductUpdateDanger { + return { + unpublish: current.status === "PUBLISHED" && request.status !== "PUBLISHED", + disabledVariantIds: + current.status === "PUBLISHED" && request.status === "PUBLISHED" + ? findDisabledVariantIds(current, request) + : [], + }; +} + +async function confirmProductUpdateDanger( + danger: ProductUpdateDanger, + options: ProductMutationOptions, + productId: number, +): Promise { + if (danger.unpublish) { + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop product update", + actionLabel: "Unpublish", + resourceLabel: "product", + resourceName: String(productId), + cancellationVerb: "unpublishing", + }, + options, + )) + ) { + return false; + } + } + + if (danger.disabledVariantIds.length > 0) { + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop product update", + actionLabel: "Disable", + resourceLabel: "variants of product", + resourceName: `${productId}: ${danger.disabledVariantIds.join(", ")}`, + cancellationVerb: "disabling", + }, + options, + )) + ) { + return false; + } + } + + return true; +} + +function hasProductUpdateDanger(danger: ProductUpdateDanger): boolean { + return danger.unpublish || danger.disabledVariantIds.length > 0; +} + +function newProductUpdateDanger( + previous: ProductUpdateDanger, + next: ProductUpdateDanger, +): ProductUpdateDanger { + return { + unpublish: next.unpublish && !previous.unpublish, + disabledVariantIds: next.disabledVariantIds.filter( + (id) => !previous.disabledVariantIds.includes(id), + ), + }; +} + +function mergeProductUpdateDanger( + previous: ProductUpdateDanger, + next: ProductUpdateDanger, +): ProductUpdateDanger { + return { + unpublish: previous.unpublish || next.unpublish, + disabledVariantIds: [...new Set([...previous.disabledVariantIds, ...next.disabledVariantIds])], + }; +} + +function findDisabledVariantIds(current: Product, request: ProductRequest): number[] { + if (current.productType === "SUBSCRIPTION") { + return []; + } + + const requestedVariants = request.productVariants; + if (!Array.isArray(requestedVariants)) { + return []; + } + + const nextStatus = new Map( + requestedVariants.flatMap((variant) => + variant.id === undefined ? [] : [[variant.id, variant.status] as const], + ), + ); + + return (current.productVariants ?? []).flatMap((variant) => { + if (variant.id === undefined || variant.status !== "PUBLISHED") { + return []; + } + + if (!nextStatus.has(variant.id)) { + return [variant.id]; + } + + const status = nextStatus.get(variant.id); + return status === "DRAFT" || status === "ARCHIVED" ? [variant.id] : []; + }); +} + +function reportProduct( + product: Product, + action: "Created" | "Updated", + baseUrl: string, + json = false, +): void { + if (json) { + printJson(product); + return; + } + + process.stdout.write(`${action} product ${product.id} (${product.title}).\n`); + if (product.permalink) { + process.stdout.write( + `permalink: ${resolvePermalinkUrl(baseUrl, product.permalink, "Product")}\n`, + ); + } + process.stdout.write(`inspect: halo shop product get ${product.id}\n`); +} + +export function buildProductCli(runtime: RuntimeContext): CAC { + const productCli = createShopCli("halo shop product"); + + productCli + .command("list", "List products") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--page ", "Page number", { default: 1 }) + .option("--size ", "Page size", { default: 20 }) + .option("--keyword ", "Filter by keyword") + .option("--category ", "Filter by category ID") + .option("--status ", "Comma separated statuses: DRAFT, PUBLISHED, ARCHIVED") + .option("--type ", "Comma separated types: PHYSICAL, VIRTUAL, EXTERNAL, SUBSCRIPTION") + .option("--sort ", "Sort expression, e.g. id,desc") + .action(async (options: ProductListOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const page = await api.listProducts({ + page: parsePageOption(options.page), + size: parseSizeOption(options.size), + keyword: options.keyword, + categoryId: + options.category === undefined + ? undefined + : parsePositiveInteger(options.category, "--category"), + statuses: parseEnumListOption(options.status, PRODUCT_STATUSES, "--status"), + types: parseEnumListOption(options.type, PRODUCT_TYPES, "--type"), + sort: options.sort, + }); + printProductList(page, options.json); + }); + + productCli + .command("get ", "Show product details") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .action(async (id: string, options: ShopCommandOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const product = await api.getProduct(parsePositiveInteger(id, "Product ID")); + printProduct(product, options.json); + }); + + productCli + .command("create", "Create a product") + .option("--profile ", "Halo profile name") + .option("--json", "Output JSON") + .option("--file ", "Read a ProductRequest JSON file, use - for stdin") + .option("--stdin", "Read a ProductRequest JSON payload from stdin") + .option("--title ", "Product title") + .option("--handle <handle>", "Product handle") + .option("--description <text>", "Short description") + .option("--category <id>", "Category ID") + .option("--type <type>", "PHYSICAL, VIRTUAL, EXTERNAL, or SUBSCRIPTION") + .option("--status <status>", "DRAFT, PUBLISHED, or ARCHIVED") + .option("--content <content>", "Product content") + .option("--images <urls>", "Comma separated image URLs") + .option("--cover <url>", "Cover image URL") + .option("--external-url <url>", "External link URL for EXTERNAL products") + .option("--external-button-text <text>", "External link button text") + .option("--price <number>", "Create or update the first variant price") + .option("--original-price <number>", "Variant original price") + .option("--stock <number>", "Variant stock") + .option("--sku <sku>", "Variant SKU code") + .option("--barcode <barcode>", "Variant barcode") + .option("--variant-status <status>", "Variant status: DRAFT, PUBLISHED, or ARCHIVED") + .option("--shipping-required <boolean>", "Whether the variant requires shipping") + .option("--track-inventory <boolean>", "Whether the variant tracks inventory") + .action(async (options: ProductMutationOptions) => { + const { profile, clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const request = await buildCreateRequest(options); + const product = await api.createProduct(request); + reportProduct(product, "Created", profile.baseUrl, options.json); + }); + + productCli + .command("update <id>", "Update a product") + .option("--profile <name>", "Halo profile name") + .option("--json", "Output JSON") + .option("--file <path>", "Read a partial ProductRequest JSON file, use - for stdin") + .option("--stdin", "Read a partial ProductRequest JSON payload from stdin") + .option("--title <title>", "Product title") + .option("--handle <handle>", "Product handle") + .option("--description <text>", "Short description") + .option("--category <id>", "Category ID") + .option("--status <status>", "DRAFT, PUBLISHED, or ARCHIVED") + .option("--content <content>", "Product content") + .option("--images <urls>", "Comma separated image URLs") + .option("--cover <url>", "Cover image URL") + .option("--external-url <url>", "External link URL for EXTERNAL products") + .option("--external-button-text <text>", "External link button text") + .option("--variant <id>", "Variant ID to edit") + .option("--price <number>", "Variant price") + .option("--original-price <number>", "Variant original price") + .option("--stock <number>", "Variant stock") + .option("--sku <sku>", "Variant SKU code") + .option("--barcode <barcode>", "Variant barcode") + .option("--variant-status <status>", "Variant status: DRAFT, PUBLISHED, or ARCHIVED") + .option("--shipping-required <boolean>", "Whether the variant requires shipping") + .option("--track-inventory <boolean>", "Whether the variant tracks inventory") + .option("--force", "Unpublish, archive, or disable variants without confirmation") + .action(async (id: string, options: ProductMutationOptions) => { + const { profile, clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const productId = parsePositiveInteger(id, "Product ID"); + const { current, filePayload, plan } = await buildUpdateRequest(api, productId, options); + let { request, changes } = plan; + const initialDanger = detectProductUpdateDanger(current, request); + let confirmedDanger = initialDanger; + + if (!(await confirmProductUpdateDanger(confirmedDanger, options, productId))) { + return; + } + + let refreshPending = changes.specifications; + while (refreshPending || hasProductUpdateDanger(confirmedDanger)) { + refreshPending = false; + const refreshedCurrent = await api.getProduct(productId); + const refreshedPlan = buildUpdateRequestFrom(refreshedCurrent, filePayload, options); + const refreshedDanger = detectProductUpdateDanger(refreshedCurrent, refreshedPlan.request); + const newDanger = newProductUpdateDanger(confirmedDanger, refreshedDanger); + + if (!hasProductUpdateDanger(newDanger)) { + request = refreshedPlan.request; + changes = refreshedPlan.changes; + break; + } + + if (!(await confirmProductUpdateDanger(newDanger, options, productId))) { + return; + } + confirmedDanger = mergeProductUpdateDanger(confirmedDanger, newDanger); + } + + let product: Product; + if (request.productType === "PHYSICAL" || request.productType === "VIRTUAL") { + let updated: Product | undefined; + const appliedDomains: string[] = []; + let failedDomain = ""; + + try { + if (changes.basicInfo) { + failedDomain = "basic-info"; + updated = await api.updateProductBasicInfo(productId, { + title: request.title, + handle: request.handle, + description: request.description, + categoryId: request.categoryId, + }); + appliedDomains.push("basic-info"); + } + if (changes.content) { + failedDomain = "content"; + updated = await api.updateProductContent(productId, { + content: request.content, + images: request.images, + }); + appliedDomains.push("content"); + } + if (changes.specifications) { + failedDomain = "specifications"; + updated = await api.updateProductSpecifications(productId, { + specDefinition: request.specDefinition, + productVariants: request.productVariants, + }); + appliedDomains.push("specifications"); + } + if (changes.status) { + failedDomain = "status"; + updated = await api.updateProductStatus(productId, request.status); + appliedDomains.push("status"); + } + } catch (error) { + const applied = appliedDomains.length > 0 ? appliedDomains.join(", ") : "none"; + process.stderr.write( + `Product ${productId} update failed during ${failedDomain}. Applied domains: ${applied}. The failed domain may have been partially applied; re-read the product before retrying. Remaining domains were not sent.\n`, + ); + throw error; + } + + if (updated === undefined) { + throw new CliError( + "No product changes were provided. Use --file or at least one update flag.", + ); + } + product = updated; + } else { + const latestCurrent = await api.getProduct(productId); + const latestPlan = buildUpdateRequestFrom(latestCurrent, filePayload, options); + const latestDanger = detectProductUpdateDanger(latestCurrent, latestPlan.request); + if (hasProductUpdateDanger(newProductUpdateDanger(confirmedDanger, latestDanger))) { + throw new CliError( + "The product changed while preparing the update. Re-run the command to review the new changes.", + ); + } + request = latestPlan.request; + changes = latestPlan.changes; + product = await api.updateProduct(productId, request); + } + + reportProduct(product, "Updated", profile.baseUrl, options.json); + }); + + productCli + .command("publish <id>", "Publish a product") + .option("--profile <name>", "Halo profile name") + .option("--json", "Output JSON") + .action(async (id: string, options: ShopCommandOptions) => { + const { profile, clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const product = await api.updateProductStatus( + parsePositiveInteger(id, "Product ID"), + "PUBLISHED", + ); + reportProduct(product, "Updated", profile.baseUrl, options.json); + }); + + productCli + .command("unpublish <id>", "Move a product back to draft") + .option("--profile <name>", "Halo profile name") + .option("--json", "Output JSON") + .option("--force", "Unpublish without confirmation") + .action(async (id: string, options: ProductUnpublishOptions) => { + const productId = parsePositiveInteger(id, "Product ID"); + + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop product unpublish", + actionLabel: "Unpublish", + resourceLabel: "product", + resourceName: String(productId), + cancellationVerb: "unpublishing", + }, + options, + )) + ) { + return; + } + + const { profile, clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const product = await api.updateProductStatus(productId, "DRAFT"); + reportProduct(product, "Updated", profile.baseUrl, options.json); + }); + + productCli + .command("delete <id>", "Delete a product") + .option("--profile <name>", "Halo profile name") + .option("--json", "Output JSON") + .option("--force", "Delete without confirmation") + .action(async (id: string, options: ProductDeleteOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const productId = parsePositiveInteger(id, "Product ID"); + + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop product delete", + actionLabel: "Delete", + resourceLabel: "product", + resourceName: String(productId), + cancellationVerb: "deleting", + }, + options, + )) + ) { + return; + } + + const api = new ShopApi(clients.axios); + await api.deleteProduct(productId); + + if (options.json) { + printJson({ deleted: true, id: productId }); + return; + } + + process.stdout.write(`Deleted product ${productId}.\n`); + }); + + productCli.usage("<command> [flags]"); + productCli.example((bin) => `${bin} list --status DRAFT,PUBLISHED`); + productCli.example((bin) => `${bin} get 1`); + productCli.example((bin) => `${bin} create --title "Example" --type VIRTUAL --price 9.9`); + productCli.example((bin) => `${bin} update 1 --file product.json`); + productCli.example((bin) => `${bin} publish 1`); + productCli.example((bin) => `${bin} delete 1 --force`); + productCli.help(); + + return productCli; +} diff --git a/src/commands/shop/stats.ts b/src/commands/shop/stats.ts new file mode 100644 index 0000000..6e24ae7 --- /dev/null +++ b/src/commands/shop/stats.ts @@ -0,0 +1,55 @@ +import { type CAC } from "cac"; + +import { CliError } from "../../utils/errors.js"; +import { RuntimeContext } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import { createShopCli } from "./cli.js"; +import { printDailyStatistics } from "./format.js"; + +interface StatsDailyOptions { + profile?: string; + json?: boolean; + start?: string; + end?: string; +} + +function validateDateOption(value: string | undefined, optionName: string): string | undefined { + if (value === undefined) { + return undefined; + } + + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + const parsed = match === null ? new Date(Number.NaN) : new Date(`${value}T00:00:00Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) { + throw new CliError(`${optionName} must be a valid date in yyyy-MM-dd format, got "${value}".`); + } + + return value; +} + +export function buildStatsCli(runtime: RuntimeContext): CAC { + const statsCli = createShopCli("halo shop stats"); + + statsCli + .command("daily", "Show daily order statistics") + .option("--profile <name>", "Halo profile name") + .option("--json", "Output JSON") + .option("--start <date>", "Start date, defaults to 30 days ago (yyyy-MM-dd)") + .option("--end <date>", "End date, defaults to today (yyyy-MM-dd)") + .action(async (options: StatsDailyOptions) => { + const { clients } = await runtime.getClientsForOptions(options); + const api = new ShopApi(clients.axios); + const statistics = await api.getDailyStatistics( + validateDateOption(options.start, "--start"), + validateDateOption(options.end, "--end"), + ); + printDailyStatistics(statistics, options.json); + }); + + statsCli.usage("<command> [flags]"); + statsCli.example((bin) => `${bin} daily`); + statsCli.example((bin) => `${bin} daily --start 2026-01-01 --end 2026-01-31`); + statsCli.help(); + + return statsCli; +} diff --git a/src/commands/shop/types.ts b/src/commands/shop/types.ts new file mode 100644 index 0000000..9506955 --- /dev/null +++ b/src/commands/shop/types.ts @@ -0,0 +1,395 @@ +export interface Page<T> { + content: T[]; + totalElements: number; + totalPages: number; + number: number; + size: number; + first?: boolean; + last?: boolean; + empty?: boolean; +} + +export type ProductType = "PHYSICAL" | "VIRTUAL" | "EXTERNAL" | "SUBSCRIPTION"; +export type ProductStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED"; +export type PricingType = "FIXED" | "INQUIRY"; +export type OrderStatus = "OPEN" | "ARCHIVED" | "CANCELLED"; +export type PaymentStatus = + | "PENDING" + | "AUTHORIZING" + | "PAID" + | "REFUNDED" + | "FAILED" + | "EXPIRED" + | "CANCELLED"; +export type FulfillmentStatus = "PENDING" | "PROCESSING" | "FULFILLED"; +export type RefundStatus = "NONE" | "PARTIAL" | "FULL"; +export type CustomerStatus = "ACTIVE" | "BLOCKED"; +export type CustomerType = "USER" | "GUEST"; +export type CalculationType = "AMOUNT" | "PERCENTAGE"; +export type DiscountScope = "ALL" | "QUALIFYING_ITEMS"; +export type DiscountApplication = "AUTOMATIC" | "CODE"; + +export interface SpecDefinitionValue { + value: string; +} + +export interface SpecDefinition { + id?: string; + name: string; + usedForSku?: boolean; + values?: SpecDefinitionValue[]; +} + +export interface SpecValue { + specId?: string; + name?: string; + value: string; +} + +export interface ProductVariant { + id?: number; + productId?: number; + specValues?: SpecValue[]; + price?: number; + originalPrice?: number; + pricingType?: PricingType; + trackInventory?: boolean; + stock?: number; + maxPerOrder?: number; + maxPerUser?: number; + skuCode?: string; + barcode?: string; + shippingProfileId?: number; + shippingRequired?: boolean; + weightG?: number; + lengthMm?: number; + widthMm?: number; + heightMm?: number; + imageUrl?: string; + status?: ProductStatus; + createdAt?: string; + updatedAt?: string; +} + +export interface Product { + id: number; + handle?: string; + permalink?: string; + title: string; + description?: string; + coverImageUrl?: string; + productType: ProductType; + specDefinition?: SpecDefinition[]; + categoryId?: number; + status: ProductStatus; + externalLinkUrl?: string; + externalLinkButtonText?: string; + createdAt?: string; + updatedAt?: string; + content?: string; + images?: string[]; + productVariants?: ProductVariant[]; + minPrice?: number; + maxPrice?: number; + minOriginalPrice?: number; + maxOriginalPrice?: number; +} + +export interface ProductRequest { + title: string; + productType: ProductType; + status: ProductStatus; + handle?: string; + description?: string; + categoryId?: number; + externalLinkUrl?: string; + externalLinkButtonText?: string; + content?: string; + images?: string[]; + specDefinition?: SpecDefinition[]; + productVariants?: ProductVariant[]; +} + +export interface ProductCategory { + id: number; + name: string; + slug: string; + description?: string; + image?: string; + parentId?: number; + children?: ProductCategory[]; + createdAt?: string; + updatedAt?: string; +} + +export interface ProductCategoryRequest { + name: string; + slug: string; + description?: string; + image?: string; + parentId?: number; +} + +export interface OrderItem { + id: number; + productVariantId?: number; + productId?: number; + quantity: number; + refundedQuantity?: number; + fulfilledQuantity?: number; + unitPrice?: number; + subtotal?: number; + discountAmount?: number; + itemTitle?: string; + itemImageUrl?: string; + subscriptionMetadata?: Record<string, unknown> | null; +} + +export interface OrderShippingAddress { + firstName?: string; + lastName?: string; + contactPhone?: string; + province?: string; + city?: string; + district?: string; + streetAddress?: string; + postalCode?: string; +} + +export interface Customer { + id: number; + userId?: string; + firstName?: string; + lastName?: string; + email?: string; + avatar?: string; + phone?: string; + note?: string; + tags?: string[]; + acceptsMarketing?: boolean; + ordersCount?: number; + totalSpent?: number; + type?: CustomerType; + status?: CustomerStatus; + createdAt?: string; + lastOrderAt?: string; + lastSeenAt?: string; +} + +export interface Order { + id: number; + orderCode?: string; + customerId?: number; + totalAmount?: number; + subtotalAmount?: number; + discountAmount?: number; + shippingAmount?: number; + payableAmount?: number; + paidAmount?: number; + status: OrderStatus; + paymentStatus?: PaymentStatus; + fulfillmentStatus?: FulfillmentStatus; + refundStatus?: RefundStatus; + customerNotes?: string; + ipAddress?: string; + shippingAddress?: OrderShippingAddress; + items?: OrderItem[]; + customer?: Customer; + discountCode?: string; + discountName?: string; + couponDiscountAmount?: number; + paidAt?: string; + fulfilledAt?: string; + autoConfirmAt?: string; + confirmAt?: string; + expireAt?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface OrderCreateItem { + productVariantId: number; + quantity: number; + customPrice: number; + subscriptionTrial?: boolean; +} + +export interface OrderCreateRequest { + username: string; + items: OrderCreateItem[]; + customerNotes?: string; + trackInventory?: boolean; +} + +export interface FulfillmentItemRequest { + orderItemId: number; + quantity: number; +} + +export interface FulfillmentLabelRequest { + carrier?: string; + trackingNumber?: string; + trackingUrl?: string; + labelUrl?: string; +} + +export interface FulfillmentRequest { + provider?: "MANUAL" | "VIRTUAL"; + items: FulfillmentItemRequest[]; + labels?: FulfillmentLabelRequest[]; +} + +export interface Address { + id: number; + customerId?: number; + firstName?: string; + lastName?: string; + contactPhone?: string; + province?: string; + provinceCode?: string; + city?: string; + cityCode?: string; + district?: string; + districtCode?: string; + streetAddress?: string; + postalCode?: string; + isDefault?: boolean; + createdAt?: string; + updatedAt?: string; +} + +export interface Coupon { + id: number; + name: string; + description?: string; + calculationType: CalculationType; + discountValue: number; + maxDiscountAmount?: number; + scope: DiscountScope; + qualifyingProductIds?: number[]; + qualifyingCategoryIds?: number[]; + excludedProductIds?: number[]; + excludedCategoryIds?: number[]; + excludeSaleItems?: boolean; + minOrderAmount?: number; + totalCount?: number; + issuedCount?: number; + perCustomerLimit?: number; + isEnabled?: boolean; + startsAt?: string; + endsAt?: string; + validDays: number; + combineWithCoupons?: boolean; + combineWithOrderDiscount?: boolean; + distributionProcessedCount?: number; + distributionStartedAt?: string; + distributionCompletedAt?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface CouponRequest { + name: string; + description?: string; + calculationType: CalculationType; + discountValue: number; + maxDiscountAmount?: number; + scope: DiscountScope; + qualifyingProductIds?: number[]; + qualifyingCategoryIds?: number[]; + excludedProductIds?: number[]; + excludedCategoryIds?: number[]; + excludeSaleItems?: boolean; + minOrderAmount?: number; + totalCount?: number; + perCustomerLimit?: number; + isEnabled?: boolean; + startsAt?: string; + endsAt?: string; + validDays: number; + combineWithCoupons?: boolean; + combineWithOrderDiscount?: boolean; +} + +export interface Discount { + id: number; + name: string; + description?: string; + application: DiscountApplication; + calculationType: CalculationType; + discountValue: number; + maxDiscountAmount?: number; + scope: DiscountScope; + code?: string; + productIds?: number[]; + categoryIds?: number[]; + excludedProductIds?: number[]; + excludedCategoryIds?: number[]; + excludeSaleItems?: boolean; + minOrderAmount?: number; + totalUsageLimit?: number; + usageLimitPerCustomer?: number; + usageCount?: number; + isEnabled?: boolean; + startsAt?: string; + endsAt?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface DiscountRequest { + name: string; + description?: string; + application: DiscountApplication; + calculationType: CalculationType; + discountValue: number; + maxDiscountAmount?: number; + scope: DiscountScope; + code?: string; + productIds?: number[]; + categoryIds?: number[]; + excludedProductIds?: number[]; + excludedCategoryIds?: number[]; + excludeSaleItems?: boolean; + minOrderAmount?: number; + totalUsageLimit?: number; + usageLimitPerCustomer?: number; + isEnabled?: boolean; + startsAt?: string; + endsAt?: string; +} + +export interface DailyStatistics { + date: string; + orders: number; + sales: number; + productsSold: number; + customers: number; +} + +export interface ContactInformation { + label: string; + icon?: string; + type?: "LINK" | "IMAGE"; + linkLabel?: string; + linkUrl?: string; + image?: string; + imageHref?: string; + help?: string; +} + +export interface ShopSetting { + contacts?: ContactInformation[]; + inquiryRedirectUrl?: string; + guestCheckoutEnabled?: boolean; + subscriptionMenuEnabled?: boolean; +} + +export interface LicenseActivation { + spec?: { + productName?: string; + }; + status?: { + state?: string; + }; +} diff --git a/src/utils/__test__/completion.spec.ts b/src/utils/__test__/completion.spec.ts index cd7a382..ecb69e2 100644 --- a/src/utils/__test__/completion.spec.ts +++ b/src/utils/__test__/completion.spec.ts @@ -41,6 +41,18 @@ test("getCompletionCandidates suggests flags when current token starts with dash ); }); +test("getCompletionCandidates suggests shop commands and flags", () => { + expect(getCompletionCandidates(["shop"], "")).toEqual( + expect.arrayContaining(["status", "product", "order", "coupon"]), + ); + expect(getCompletionCandidates(["shop", "order"], "")).toEqual( + expect.arrayContaining(["fulfill", "cancel", "mark-paid"]), + ); + expect(getCompletionCandidates(["shop", "order", "fulfill"], "--")).toEqual( + expect.arrayContaining(["--force", "--item", "--all"]), + ); +}); + test("renderBashCompletion emits a complete function", () => { const script = renderBashCompletion(); expect(script).toContain("complete -F _halo_completion halo"); diff --git a/src/utils/completion.ts b/src/utils/completion.ts index 7ee5eac..930bb36 100644 --- a/src/utils/completion.ts +++ b/src/utils/completion.ts @@ -264,6 +264,349 @@ const COMPLETION_TREE: CompletionNode = { "mark-as-read": { flags: [...PROFILE_JSON_FLAGS, "--all"] }, }, }, + shop: { + flags: HELP_FLAGS, + subcommands: { + status: { flags: PROFILE_JSON_FLAGS }, + product: { + flags: HELP_FLAGS, + subcommands: { + list: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--page", + "--size", + "--keyword", + "--category", + "--status", + "--type", + "--sort", + ], + }, + get: { flags: PROFILE_JSON_FLAGS }, + create: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--title", + "--handle", + "--description", + "--category", + "--type", + "--status", + "--content", + "--images", + "--cover", + "--external-url", + "--external-button-text", + "--price", + "--original-price", + "--stock", + "--sku", + "--barcode", + "--variant-status", + "--shipping-required", + "--track-inventory", + ], + }, + update: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--title", + "--handle", + "--description", + "--category", + "--status", + "--content", + "--images", + "--cover", + "--external-url", + "--external-button-text", + "--variant", + "--price", + "--original-price", + "--stock", + "--sku", + "--barcode", + "--variant-status", + "--shipping-required", + "--track-inventory", + "--force", + ], + }, + publish: { flags: PROFILE_JSON_FLAGS }, + unpublish: { flags: FORCE_FLAGS }, + delete: { flags: FORCE_FLAGS }, + }, + }, + category: { + flags: HELP_FLAGS, + subcommands: { + list: { flags: [...PROFILE_JSON_FLAGS, "--tree"] }, + get: { flags: PROFILE_JSON_FLAGS }, + create: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--name", + "--slug", + "--description", + "--image", + "--parent", + ], + }, + update: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--name", + "--slug", + "--description", + "--image", + "--parent", + ], + }, + delete: { flags: FORCE_FLAGS }, + }, + }, + order: { + flags: HELP_FLAGS, + subcommands: { + list: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--page", + "--size", + "--keyword", + "--customer", + "--username", + "--order-code", + "--status", + "--payment-status", + "--fulfillment-status", + "--refund-status", + "--start-date", + "--end-date", + "--sort", + ], + }, + get: { flags: PROFILE_JSON_FLAGS }, + create: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--username", + "--item", + "--note", + "--track-inventory", + ], + }, + cancel: { flags: FORCE_FLAGS }, + "mark-paid": { flags: FORCE_FLAGS }, + fulfill: { + flags: [ + ...FORCE_FLAGS, + "--file", + "--stdin", + "--item", + "--all", + "--provider", + "--carrier", + "--tracking-number", + "--tracking-url", + "--label-url", + ], + }, + note: { flags: [...PROFILE_JSON_FLAGS, "--message", "--payload"] }, + }, + }, + customer: { + flags: HELP_FLAGS, + subcommands: { + list: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--page", + "--size", + "--keyword", + "--status", + "--type", + "--sort", + ], + }, + get: { flags: PROFILE_JSON_FLAGS }, + update: { + flags: [...PROFILE_JSON_FLAGS, "--status", "--note", "--clear-note", "--force"], + }, + addresses: { flags: PROFILE_JSON_FLAGS }, + entitlements: { flags: [...PROFILE_JSON_FLAGS, "--product"] }, + }, + }, + coupon: { + flags: HELP_FLAGS, + subcommands: { + list: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--page", + "--size", + "--keyword", + "--enabled", + "--sort", + ], + }, + get: { flags: PROFILE_JSON_FLAGS }, + create: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--name", + "--description", + "--calculation-type", + "--discount-value", + "--max-discount-amount", + "--scope", + "--qualifying-product-ids", + "--qualifying-category-ids", + "--excluded-product-ids", + "--excluded-category-ids", + "--exclude-sale-items", + "--min-order-amount", + "--total-count", + "--per-customer-limit", + "--enabled", + "--starts-at", + "--ends-at", + "--valid-days", + "--combine-with-coupons", + "--combine-with-order-discount", + ], + }, + update: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--name", + "--description", + "--calculation-type", + "--discount-value", + "--max-discount-amount", + "--scope", + "--qualifying-product-ids", + "--qualifying-category-ids", + "--excluded-product-ids", + "--excluded-category-ids", + "--exclude-sale-items", + "--min-order-amount", + "--total-count", + "--per-customer-limit", + "--enabled", + "--starts-at", + "--ends-at", + "--valid-days", + "--combine-with-coupons", + "--combine-with-order-discount", + "--force", + ], + }, + delete: { flags: FORCE_FLAGS }, + distribute: { flags: [...PROFILE_JSON_FLAGS, "--all", "--username", "--force"] }, + }, + }, + discount: { + flags: HELP_FLAGS, + subcommands: { + list: { flags: [...PROFILE_JSON_FLAGS, "--page", "--size", "--sort"] }, + get: { flags: PROFILE_JSON_FLAGS }, + create: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--name", + "--description", + "--application", + "--calculation-type", + "--discount-value", + "--max-discount-amount", + "--scope", + "--code", + "--product-ids", + "--category-ids", + "--excluded-product-ids", + "--excluded-category-ids", + "--exclude-sale-items", + "--min-order-amount", + "--total-usage-limit", + "--usage-limit-per-customer", + "--enabled", + "--starts-at", + "--ends-at", + ], + }, + update: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--file", + "--stdin", + "--name", + "--description", + "--application", + "--calculation-type", + "--discount-value", + "--max-discount-amount", + "--scope", + "--code", + "--product-ids", + "--category-ids", + "--excluded-product-ids", + "--excluded-category-ids", + "--exclude-sale-items", + "--min-order-amount", + "--total-usage-limit", + "--usage-limit-per-customer", + "--enabled", + "--starts-at", + "--ends-at", + "--force", + ], + }, + delete: { flags: FORCE_FLAGS }, + }, + }, + stats: { + flags: HELP_FLAGS, + subcommands: { + daily: { flags: [...PROFILE_JSON_FLAGS, "--start", "--end"] }, + }, + }, + config: { + flags: HELP_FLAGS, + subcommands: { + get: { flags: [...PROFILE_JSON_FLAGS, "--group"] }, + set: { + flags: [ + ...PROFILE_JSON_FLAGS, + "--group", + "--file", + "--stdin", + "--guest-checkout", + "--subscription-menu", + "--inquiry-redirect-url", + ], + }, + }, + }, + }, + }, completion: { flags: HELP_FLAGS, }, diff --git a/src/utils/confirmation.ts b/src/utils/confirmation.ts index 0b0006e..eb336be 100644 --- a/src/utils/confirmation.ts +++ b/src/utils/confirmation.ts @@ -21,7 +21,7 @@ export async function confirmDangerousAction( config: DangerousActionConfig, options: DangerousActionOptions, ): Promise<boolean> { - if (options.force) { + if (options.force === true) { return true; } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index b10dcac..415db3d 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -16,7 +16,7 @@ function pickApiErrorMessage(data: unknown): string | undefined { } const maybeData = data as Record<string, unknown>; - const fields = ["message", "error", "title", "detail"]; + const fields = ["detail", "message", "error", "title"]; for (const field of fields) { const value = maybeData[field]; From b51ecaf4427a7c8dba59f5e9d2c5f5b1c979952f Mon Sep 17 00:00:00 2001 From: Ryan Wang <i@ryanc.cc> Date: Tue, 3 Nov 2026 14:08:18 +0800 Subject: [PATCH 2/3] Add shop doctor diagnostics and review fixes - Add a read-only shop diagnostic command with capability, permission, and configuration reporting - Validate coupon discount values and percentage limits - Merge config group updates instead of replacing the whole group - Require confirmation when removing variants from published products - Add tests, completion entries, and skill documentation --- skills/halo-cli-shop/SKILL.md | 13 +- src/commands/shop/__test__/shop-entry.spec.ts | 402 ++++++++++++- src/commands/shop/config.ts | 10 +- src/commands/shop/coupon.ts | 8 + src/commands/shop/doctor.ts | 528 ++++++++++++++++++ src/commands/shop/index.ts | 119 +--- src/commands/shop/product.ts | 54 +- src/commands/shop/status.ts | 147 +++++ src/utils/__test__/completion.spec.ts | 2 +- src/utils/completion.ts | 1 + 10 files changed, 1175 insertions(+), 109 deletions(-) create mode 100644 src/commands/shop/doctor.ts create mode 100644 src/commands/shop/status.ts diff --git a/skills/halo-cli-shop/SKILL.md b/skills/halo-cli-shop/SKILL.md index 1e2ad8d..b34090c 100644 --- a/skills/halo-cli-shop/SKILL.md +++ b/skills/halo-cli-shop/SKILL.md @@ -21,10 +21,14 @@ Manage the Halo shop area through the `console.api.ecommerce.halo.run/v1alpha1` The shop API only exists on Halo Pro / Halo Commercial instances with the shop feature enabled. ```bash -halo shop status --json +halo shop doctor --json ``` -Check `commercialActivated` and `shopApiAvailable` before running shop commands. If `shopApiAvailable` is false, do not retry the same command; the instance does not expose the shop API. +Run `doctor` before a shop workflow. It reports edition, activation, `shopApiAvailable`, `shopApiStatus` (`available`, `denied`, `unavailable`, or `unknown`), per-feature readability, read/write permissions for each shop resource, configuration counts, and actionable `warnings`. + +Use `shopApiStatus` rather than the boolean alone: `denied` means the API exists but the current profile lacks permission, while `unavailable` means the instance does not expose the shop API. A `features` entry is true only when the current profile can read that resource; use `warnings` to distinguish `PERMISSION_DENIED` from `RESOURCE_UNAVAILABLE`. If a warning uses `PERMISSION_DENIED`, `PERMISSIONS_UNAVAILABLE`, or `READ_ONLY_ACCESS`, adjust the profile or token instead of retrying. + +`halo shop status --json` is a lighter check that only reports edition, activation, and API availability. All commands accept `--profile <name>` and `--json`. Prefer `--json` for agent workflows. @@ -32,6 +36,7 @@ All commands accept `--profile <name>` and `--json`. Prefer `--json` for agent w ```bash halo shop status +halo shop doctor halo shop product list|get|create|update|publish|unpublish|delete halo shop category list|get|create|update|delete @@ -115,6 +120,8 @@ halo shop config set --guest-checkout true halo shop config set --group payment --file payment.json ``` +`config set --group` merges the file over the current group payload. Nested objects are replaced; omitted top-level groups are preserved. + ## JSON Payloads Payloads follow the Halo console API schemas. Common shapes: @@ -151,7 +158,7 @@ FIXED variants require a `price`; only `pricingType: "INQUIRY"` variants may omi ## Safety - `product delete`, `category delete`, `coupon delete`, `discount delete`, `order cancel`, `order mark-paid`, `order fulfill`, `product unpublish`, `product update --status DRAFT|ARCHIVED`, `product update --variant-status DRAFT|ARCHIVED` on a published product, `coupon update --enabled false`, `discount update --enabled false`, `coupon distribute --all`, and `customer update --status BLOCKED` require interactive confirmation or `--force` in non-interactive mode. -- `product update --file` follows full-array replacement for `productVariants`: removing a published variant (including sending an empty array) archives it and requires confirmation. +- `product update --file` follows full-array replacement for `productVariants`: removing any variant (including a draft variant, or sending an empty array) while the product is published requires confirmation. - `order fulfill --all` skips subscription order lines. Subscription lines are fulfilled by the integrator and cannot be fulfilled manually. Use `--provider MANUAL`; the current backend does not expose a VIRTUAL fulfillment provider. Fulfillment cannot be reversed through the API, so confirm before using `--force`. - Prefer `--json` and inspect the returned resource after every mutation. - Do not print or send payment secrets from `shop config get --group payment`; treat them as credentials. diff --git a/src/commands/shop/__test__/shop-entry.spec.ts b/src/commands/shop/__test__/shop-entry.spec.ts index 02e3246..d60b965 100644 --- a/src/commands/shop/__test__/shop-entry.spec.ts +++ b/src/commands/shop/__test__/shop-entry.spec.ts @@ -142,6 +142,13 @@ function notFoundError() { }); } +function httpError(status: number) { + return Object.assign(new Error(`HTTP ${status}`), { + isAxiosError: true, + response: { status }, + }); +} + test("shop status tolerates missing edition and license APIs", async () => { const stdoutSpy = silenceStdout(); @@ -179,6 +186,292 @@ test("shop status surfaces authentication failures", async () => { ); }); +const SHOP_DOCTOR_PROBES: Record<string, unknown> = { + "/apis/console.api.ecommerce.halo.run/v1alpha1/products?page=1&size=1": { + content: [], + totalElements: 3, + }, + "/apis/console.api.ecommerce.halo.run/v1alpha1/product-categories?tree=false": [], + "/apis/console.api.ecommerce.halo.run/v1alpha1/orders?page=1&size=1": { + content: [], + totalElements: 2, + }, + "/apis/console.api.ecommerce.halo.run/v1alpha1/customers?page=1&size=1": { + content: [], + totalElements: 1, + }, + "/apis/console.api.ecommerce.halo.run/v1alpha1/coupons?page=1&size=1": { + content: [], + totalElements: 0, + }, + "/apis/console.api.ecommerce.halo.run/v1alpha1/discounts?page=1&size=1": { + content: [], + totalElements: 0, + }, + "/apis/console.api.ecommerce.halo.run/v1alpha1/digital-resources": [], + "/apis/console.api.ecommerce.halo.run/v1alpha1/virtual-delivery-configs": [], + "/apis/console.api.ecommerce.halo.run/v1alpha1/cdks?page=1&size=1": { + content: [], + totalElements: 0, + }, + "/apis/console.api.ecommerce.halo.run/v1alpha1/subscriptions?page=1&size=1": { + content: [], + totalElements: 0, + }, + "/apis/console.api.ecommerce.halo.run/v1alpha1/statistics/orders/daily": [], + "/apis/console.api.ecommerce.halo.run/v1alpha1/ecommerceconfigs/shop": {}, + "/apis/console.api.ecommerce.halo.run/v1alpha1/payment-providers": [{}, {}], + "/apis/console.api.ecommerce.halo.run/v1alpha1/payment-methods": [{}], + "/apis/console.api.ecommerce.halo.run/v1alpha1/shipping-profiles": [{}], + "/apis/console.api.ecommerce.halo.run/v1alpha1/sales-channels": [{}], + "/apis/console.api.ecommerce.halo.run/v1alpha1/fulfillment-methods": [{}], + "/apis/console.api.ecommerce.halo.run/v1alpha1/product-spec-templates?page=1&size=1": { + content: [], + totalElements: 0, + }, +}; + +function createDoctorGet(rules: Array<Record<string, unknown>>) { + return vi.fn((url: string) => { + if (url === "/actuator/info") { + return Promise.resolve({ + data: { build: { name: "halo-pro", version: "2.28.0" } }, + }); + } + if (url === "/apis/console.api.license.pro.halo.run/v1alpha1/activations") { + return Promise.resolve({ + data: [ + { + spec: { productName: "product-halo-commercial" }, + status: { state: "active" }, + }, + ], + }); + } + if (url === "/apis/uc.api.halo.run/v1alpha1/users/-/permissions") { + return Promise.resolve({ + data: { + roles: [{ metadata: { name: "super-role" } }], + permissions: [{ metadata: { name: "role-template-shop-admin" }, rules }], + }, + }); + } + if (url in SHOP_DOCTOR_PROBES) { + return Promise.resolve({ data: SHOP_DOCTOR_PROBES[url] }); + } + return Promise.reject(notFoundError()); + }); +} + +test("shop doctor reports capabilities, permissions, and configuration", async () => { + const stdoutSpy = silenceStdout(); + const get = createDoctorGet([ + { + apiGroups: ["console.api.ecommerce.halo.run"], + resources: ["*"], + verbs: ["*"], + }, + ]); + const request = vi.fn().mockResolvedValue({ data: { content: [], totalElements: 1 } }); + const runtime = createRuntimeMock({ get, request }); + + await expect(tryRunShopCommand(["shop", "doctor", "--json"], runtime as never)).resolves.toBe( + true, + ); + + const result = JSON.parse(readStdout(stdoutSpy)); + expect(result).toMatchObject({ + profile: "local", + baseUrl: "https://example.com", + edition: "halo-pro", + version: "2.28.0", + commercialActivated: true, + activationState: "active", + shopApiAvailable: true, + shopApiStatus: "available", + permissions: { + roles: ["super-role"], + write: { + products: true, + categories: true, + orders: true, + statistics: true, + }, + }, + configuration: { + paymentProviders: 2, + paymentMethods: 1, + shippingProfiles: 1, + salesChannels: 1, + fulfillmentMethods: 1, + specTemplates: 0, + }, + warnings: [], + }); + expect(Object.values(result.features)).toEqual(Array(18).fill(true)); + expect(runtime.getClientsForOptions).toHaveBeenCalledTimes(1); +}); + +test("shop doctor reports read-only permissions and missing configuration", async () => { + const stdoutSpy = silenceStdout(); + const get = createDoctorGet([ + { + apiGroups: ["console.api.ecommerce.halo.run"], + resources: ["*"], + verbs: ["get", "list"], + }, + ]); + const getWithEmptyConfiguration = vi.fn((url: string) => { + if (url === "/apis/console.api.ecommerce.halo.run/v1alpha1/cdks?page=1&size=1") { + return Promise.reject(notFoundError()); + } + if ( + url === "/apis/console.api.ecommerce.halo.run/v1alpha1/payment-providers" || + url === "/apis/console.api.ecommerce.halo.run/v1alpha1/payment-methods" || + url === "/apis/console.api.ecommerce.halo.run/v1alpha1/shipping-profiles" || + url === "/apis/console.api.ecommerce.halo.run/v1alpha1/sales-channels" || + url === "/apis/console.api.ecommerce.halo.run/v1alpha1/fulfillment-methods" + ) { + return Promise.resolve({ data: [] }); + } + return get(url); + }); + const request = vi.fn().mockResolvedValue({ data: { content: [], totalElements: 0 } }); + const runtime = createRuntimeMock({ get: getWithEmptyConfiguration, request }); + + await expect(tryRunShopCommand(["shop", "doctor", "--json"], runtime as never)).resolves.toBe( + true, + ); + + const result = JSON.parse(readStdout(stdoutSpy)); + expect(Object.values(result.permissions.write)).toEqual(Array(18).fill(false)); + expect(result.warnings.map((warning: { code: string }) => warning.code)).toEqual( + expect.arrayContaining([ + "READ_ONLY_ACCESS", + "NO_PAYMENT_PROVIDERS", + "NO_PAYMENT_METHODS", + "NO_SHIPPING_PROFILES", + "NO_SALES_CHANNELS", + "NO_FULFILLMENT_METHODS", + ]), + ); +}); + +test("shop doctor reports unknown write access when permissions are unavailable", async () => { + const stdoutSpy = silenceStdout(); + const get = createDoctorGet([]); + const getWithForbiddenPermissions = vi.fn((url: string) => { + if (url === "/apis/uc.api.halo.run/v1alpha1/users/-/permissions") { + return Promise.reject( + Object.assign(new Error("forbidden"), { + isAxiosError: true, + response: { status: 403 }, + }), + ); + } + return get(url); + }); + const request = vi.fn().mockResolvedValue({ data: { content: [], totalElements: 0 } }); + const runtime = createRuntimeMock({ get: getWithForbiddenPermissions, request }); + + await expect(tryRunShopCommand(["shop", "doctor", "--json"], runtime as never)).resolves.toBe( + true, + ); + + const result = JSON.parse(readStdout(stdoutSpy)); + expect(Object.values(result.permissions.write)).toEqual(Array(18).fill(null)); + expect(result.warnings.map((warning: { code: string }) => warning.code)).toContain( + "PERMISSIONS_UNAVAILABLE", + ); +}); + +test("shop doctor keeps probing when product listing is denied", async () => { + const stdoutSpy = silenceStdout(); + const get = createDoctorGet([ + { + apiGroups: ["console.api.ecommerce.halo.run"], + resources: ["*"], + verbs: ["get", "list"], + }, + ]); + const getWithDeniedProbes = vi.fn((url: string) => { + if (url.startsWith("/apis/console.api.ecommerce.halo.run/v1alpha1/")) { + return Promise.reject(httpError(403)); + } + return get(url); + }); + const request = vi.fn().mockRejectedValue(httpError(403)); + const runtime = createRuntimeMock({ get: getWithDeniedProbes, request }); + + await expect(tryRunShopCommand(["shop", "doctor", "--json"], runtime as never)).resolves.toBe( + true, + ); + + const result = JSON.parse(readStdout(stdoutSpy)); + expect(result).toMatchObject({ + shopApiAvailable: false, + shopApiStatus: "denied", + }); + const codes = result.warnings.map((warning: { code: string }) => warning.code); + expect(codes).toContain("PERMISSION_DENIED"); + expect(codes).toContain("READ_ONLY_ACCESS"); + expect(codes).not.toContain("SHOP_API_UNAVAILABLE"); +}); + +test("shop doctor reports probe failures without aborting the diagnosis", async () => { + const stdoutSpy = silenceStdout(); + const get = createDoctorGet([ + { + apiGroups: ["console.api.ecommerce.halo.run"], + resources: ["*"], + verbs: ["*"], + }, + ]); + const request = vi.fn().mockRejectedValue(httpError(500)); + const runtime = createRuntimeMock({ get, request }); + + await expect(tryRunShopCommand(["shop", "doctor", "--json"], runtime as never)).resolves.toBe( + true, + ); + + const result = JSON.parse(readStdout(stdoutSpy)); + expect(result).toMatchObject({ + shopApiAvailable: false, + shopApiStatus: "unknown", + }); + expect(result.features.products).toBe(false); + expect(result.features.orders).toBe(true); + const codes = result.warnings.map((warning: { code: string }) => warning.code); + expect(codes).toContain("PROBE_FAILED"); + expect(codes).not.toContain("SHOP_API_UNAVAILABLE"); +}); + +test("shop doctor reports shop API unavailability", async () => { + const stdoutSpy = silenceStdout(); + const get = vi.fn().mockRejectedValue(notFoundError()); + const request = vi.fn().mockRejectedValue(notFoundError()); + const runtime = createRuntimeMock({ get, request }); + + await expect(tryRunShopCommand(["shop", "doctor", "--json"], runtime as never)).resolves.toBe( + true, + ); + + const result = JSON.parse(readStdout(stdoutSpy)); + expect(result).toMatchObject({ + shopApiAvailable: false, + shopApiStatus: "unavailable", + permissions: { + roles: [], + }, + warnings: [ + { + code: "SHOP_API_UNAVAILABLE", + }, + ], + }); + expect(Object.values(result.features)).toEqual(Array(18).fill(false)); +}); + test("shop product create posts the request and prints JSON", async () => { const stdoutSpy = silenceStdout(); @@ -686,6 +979,62 @@ test("shop coupon create maps marketing flags", async () => { ); }); +test("shop coupon create rejects a percentage above 100", async () => { + silenceStdout(); + + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + [ + "shop", + "coupon", + "create", + "--name", + "Too much", + "--calculation-type", + "PERCENTAGE", + "--discount-value", + "101", + "--scope", + "ALL", + "--valid-days", + "30", + "--json", + ], + runtime as never, + ), + ).rejects.toThrow("between 0 and 100"); + + expect(request).not.toHaveBeenCalled(); +}); + +test("shop coupon create rejects a non-scalar discount value from a file", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + name: "Invalid value", + calculationType: "AMOUNT", + discountValue: [10], + scope: "ALL", + validDays: 30, + }); + + try { + const request = vi.fn(); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand(["shop", "coupon", "create", "--file", file, "--json"], runtime as never), + ).rejects.toThrow("Coupon discount value must be a non-negative number"); + + expect(request).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } +}); + test("shop discount create requires an application type", async () => { silenceStdout(); @@ -1846,6 +2195,40 @@ test("shop product update requires confirmation when one published variant is re } }); +test("shop product update requires confirmation when a draft variant is removed", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 10, status: "PUBLISHED" }], + }); + + try { + const current = { + id: 7, + title: "Published product", + productType: "PHYSICAL", + status: "PUBLISHED", + productVariants: [ + { id: 11, price: 10, status: "PUBLISHED" }, + { id: 12, price: 20, status: "DRAFT" }, + ], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + test("shop product update requires confirmation for lowercase variant status via --file", async () => { silenceStdout(); @@ -2489,13 +2872,16 @@ test("shop discount delete --force deletes the discount", async () => { ); }); -test("shop config set --group sends the group payload", async () => { +test("shop config set --group merges the group payload over the current config", async () => { silenceStdout(); const { file, cleanup } = await writeJsonFile({ provider: "stripe" }); try { - const request = vi.fn().mockResolvedValue({ data: { provider: "stripe" } }); + const request = vi + .fn() + .mockResolvedValueOnce({ data: { provider: "stripe", currency: "USD" } }) + .mockResolvedValue({ data: "" }); const runtime = createRuntimeMock({ request }); await expect( @@ -2505,11 +2891,19 @@ test("shop config set --group sends the group payload", async () => { ), ).resolves.toBe(true); - expect(request).toHaveBeenCalledWith( + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: "GET", + url: expect.stringContaining("/ecommerceconfigs/payment"), + }), + ); + expect(request).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ method: "PUT", url: expect.stringContaining("/ecommerceconfigs/payment"), - data: { provider: "stripe" }, + data: { provider: "stripe", currency: "USD" }, }), ); } finally { diff --git a/src/commands/shop/config.ts b/src/commands/shop/config.ts index f5453eb..d4ba706 100644 --- a/src/commands/shop/config.ts +++ b/src/commands/shop/config.ts @@ -85,7 +85,15 @@ export function buildConfigCli(runtime: RuntimeContext): CAC { throw new CliError("Use --file or --stdin to provide the config group payload."); } - const config = await api.updateConfigGroup(options.group, filePayload); + const current = await api.getConfigGroup(options.group); + if (!current || typeof current !== "object" || Array.isArray(current)) { + throw new CliError(`Config group "${options.group}" did not return a JSON object.`); + } + + const config = await api.updateConfigGroup(options.group, { + ...(current as Record<string, unknown>), + ...filePayload, + }); if (options.json) { printJson(config ?? { group: options.group, updated: true }); diff --git a/src/commands/shop/coupon.ts b/src/commands/shop/coupon.ts index 530f06d..37a4855 100644 --- a/src/commands/shop/coupon.ts +++ b/src/commands/shop/coupon.ts @@ -188,6 +188,14 @@ function validateCouponRequest(request: CouponRequest): void { if (request.discountValue === undefined) { throw new CliError("Coupon discount value is required. Use --discount-value."); } + const discountValue = parseMoneyOption(request.discountValue, "Coupon discount value"); + if (discountValue === undefined) { + throw new CliError("Coupon discount value must be a non-negative number."); + } + if (request.calculationType === "PERCENTAGE" && discountValue > 100) { + throw new CliError("Percentage coupon discount value must be between 0 and 100."); + } + request.discountValue = discountValue; if (request.scope === undefined) { throw new CliError("Coupon scope is required. Use --scope."); } diff --git a/src/commands/shop/doctor.ts b/src/commands/shop/doctor.ts new file mode 100644 index 0000000..ba724d1 --- /dev/null +++ b/src/commands/shop/doctor.ts @@ -0,0 +1,528 @@ +import type { AxiosInstance } from "axios"; + +import { RuntimeContext } from "../../utils/runtime.js"; +import { + inspectShopStatus, + isForbiddenError, + isNotFoundError, + isUnauthorizedError, + type ShopApiProbeStatus, + type ShopStatusOptions, +} from "./status.js"; + +const SHOP_API_BASE = "/apis/console.api.ecommerce.halo.run/v1alpha1"; +const ECOMMERCE_API_GROUP = "console.api.ecommerce.halo.run"; +const PERMISSIONS_PATH = "/apis/uc.api.halo.run/v1alpha1/users/-/permissions"; + +const SHOP_FEATURES = [ + "products", + "categories", + "orders", + "customers", + "coupons", + "discounts", + "digitalResources", + "virtualDelivery", + "cdks", + "subscriptions", + "statistics", + "config", + "paymentProviders", + "paymentMethods", + "shippingProfiles", + "salesChannels", + "fulfillmentMethods", + "specTemplates", +] as const; + +export type ShopFeature = (typeof SHOP_FEATURES)[number]; + +const CONFIGURATION_KEYS = [ + "paymentProviders", + "paymentMethods", + "shippingProfiles", + "salesChannels", + "fulfillmentMethods", + "specTemplates", +] as const; + +type ConfigurationKey = (typeof CONFIGURATION_KEYS)[number]; + +export type ShopDoctorWarning = { + code: string; + message: string; +}; + +export interface ShopDoctorResult { + profile: string; + baseUrl: string; + edition: string; + version?: string; + commercialActivated: boolean; + activationState?: string; + shopApiAvailable: boolean; + shopApiStatus: ShopApiProbeStatus; + features: Record<ShopFeature, boolean>; + permissions: { + roles: string[]; + read: Record<ShopFeature, boolean>; + write: Record<ShopFeature, boolean | null>; + }; + configuration: Record<ConfigurationKey, number | null>; + warnings: ShopDoctorWarning[]; +} + +interface ShopProbe { + key: ShopFeature; + label: string; + path: string; + permission: string; + required?: boolean; + configuration?: ConfigurationKey; +} + +const PRODUCT_PROBE: ShopProbe = { + key: "products", + label: "products", + path: "/products?page=1&size=1", + permission: "products", + required: true, +}; + +const SHOP_PROBES: ShopProbe[] = [ + { + key: "categories", + label: "product categories", + path: "/product-categories?tree=false", + permission: "product-categories", + required: true, + }, + { + key: "orders", + label: "orders", + path: "/orders?page=1&size=1", + permission: "orders", + required: true, + }, + { + key: "customers", + label: "customers", + path: "/customers?page=1&size=1", + permission: "customers", + required: true, + }, + { + key: "coupons", + label: "coupons", + path: "/coupons?page=1&size=1", + permission: "coupons", + required: true, + }, + { + key: "discounts", + label: "discounts", + path: "/discounts?page=1&size=1", + permission: "discounts", + required: true, + }, + { + key: "digitalResources", + label: "digital resources", + path: "/digital-resources", + permission: "digital-resources", + }, + { + key: "virtualDelivery", + label: "virtual delivery configurations", + path: "/virtual-delivery-configs", + permission: "virtual-delivery-configs", + }, + { + key: "cdks", + label: "CDKs", + path: "/cdks?page=1&size=1", + permission: "cdks", + }, + { + key: "subscriptions", + label: "subscriptions", + path: "/subscriptions?page=1&size=1", + permission: "subscriptions", + }, + { + key: "statistics", + label: "shop statistics", + path: "/statistics/orders/daily", + permission: "statistics", + required: true, + }, + { + key: "config", + label: "shop configuration", + path: "/ecommerceconfigs/shop", + permission: "ecommerceconfigs", + required: true, + }, + { + key: "paymentProviders", + label: "payment providers", + path: "/payment-providers", + permission: "payment-providers", + configuration: "paymentProviders", + }, + { + key: "paymentMethods", + label: "payment methods", + path: "/payment-methods", + permission: "payment-methods", + configuration: "paymentMethods", + }, + { + key: "shippingProfiles", + label: "shipping profiles", + path: "/shipping-profiles", + permission: "shipping-profiles", + configuration: "shippingProfiles", + }, + { + key: "salesChannels", + label: "sales channels", + path: "/sales-channels", + permission: "sales-channels", + configuration: "salesChannels", + }, + { + key: "fulfillmentMethods", + label: "fulfillment methods", + path: "/fulfillment-methods", + permission: "fulfillment-methods", + configuration: "fulfillmentMethods", + }, + { + key: "specTemplates", + label: "product spec templates", + path: "/product-spec-templates?page=1&size=1", + permission: "product-spec-templates", + configuration: "specTemplates", + }, +]; + +interface RoleRule { + apiGroups?: string[]; + resources?: string[]; + verbs?: string[]; +} + +interface RoleLike { + metadata?: { + name?: string; + }; + rules?: RoleRule[]; +} + +interface PermissionsResponse { + roles?: RoleLike[]; + permissions?: RoleLike[]; + uiPermissions?: string[]; +} + +interface ProbeOutcome { + ok: boolean; + status?: number; + count?: number; + unexpected?: boolean; +} + +function emptyFeatures(): Record<ShopFeature, boolean> { + return Object.fromEntries(SHOP_FEATURES.map((key) => [key, false])) as Record< + ShopFeature, + boolean + >; +} + +function emptyConfiguration(): Record<ConfigurationKey, number | null> { + return Object.fromEntries(CONFIGURATION_KEYS.map((key) => [key, null])) as Record< + ConfigurationKey, + number | null + >; +} + +function readCount(data: unknown): number | undefined { + if (Array.isArray(data)) { + return data.length; + } + if (data && typeof data === "object") { + const value = data as Record<string, unknown>; + if (Array.isArray(value.content)) { + return typeof value.totalElements === "number" ? value.totalElements : value.content.length; + } + if (typeof value.totalElements === "number") { + return value.totalElements; + } + } + return undefined; +} + +function responseStatus(error: unknown): number | undefined { + return (error as { response?: { status?: number } }).response?.status; +} + +async function probeShopEndpoint(axios: AxiosInstance, probe: ShopProbe): Promise<ProbeOutcome> { + try { + const response = await axios.get(`${SHOP_API_BASE}${probe.path}`); + return { ok: true, count: readCount(response.data) }; + } catch (error) { + if (isNotFoundError(error)) { + return { ok: false, status: 404 }; + } + if (isForbiddenError(error)) { + return { ok: false, status: 403 }; + } + if (isUnauthorizedError(error)) { + throw error; + } + return { ok: false, status: responseStatus(error), unexpected: true }; + } +} + +function hasPermission(rules: RoleRule[], resource: string, verbs: string[]): boolean { + return rules.some((rule) => { + const groups = rule.apiGroups ?? []; + const resources = rule.resources ?? []; + const ruleVerbs = rule.verbs ?? []; + + const groupMatches = groups.includes("*") || groups.includes(ECOMMERCE_API_GROUP); + const resourceMatches = resources.some( + (candidate) => + candidate === "*" || + candidate === resource || + candidate.startsWith(`${resource}/`) || + candidate.startsWith("*/"), + ); + const verbMatches = ruleVerbs.includes("*") || verbs.some((verb) => ruleVerbs.includes(verb)); + + return groupMatches && resourceMatches && verbMatches; + }); +} + +async function resolvePermissions( + axios: AxiosInstance, +): Promise<{ data?: PermissionsResponse; unavailable: boolean; status?: number }> { + try { + const response = await axios.get(PERMISSIONS_PATH); + return { data: response.data as PermissionsResponse, unavailable: false }; + } catch (error) { + if (isUnauthorizedError(error)) { + throw error; + } + return { unavailable: true, status: responseStatus(error) }; + } +} + +export async function resolveShopDoctor( + runtime: RuntimeContext, + options: ShopStatusOptions, +): Promise<ShopDoctorResult> { + const inspection = await inspectShopStatus(runtime, options); + const { status, shopApiStatus } = inspection; + const { clients } = inspection.resolved; + const features = emptyFeatures(); + const configuration = emptyConfiguration(); + const warnings: ShopDoctorWarning[] = []; + const permissions: ShopDoctorResult["permissions"] = { + roles: [], + read: emptyFeatures(), + write: Object.fromEntries(SHOP_FEATURES.map((key) => [key, null])) as Record< + ShopFeature, + boolean | null + >, + }; + + if (shopApiStatus === "unavailable") { + warnings.push({ + code: "SHOP_API_UNAVAILABLE", + message: + "The shop API is not available. Shop commands require Halo Pro or Halo Commercial with the ecommerce feature enabled.", + }); + return { + ...status, + shopApiStatus, + features, + permissions, + configuration, + warnings, + }; + } + + const productsProbe: ProbeOutcome = + shopApiStatus === "available" + ? { ok: true } + : shopApiStatus === "denied" + ? { ok: false, status: 403 } + : { ok: false, unexpected: true, status: responseStatus(inspection.shopApiError) }; + + const [permissionsResult, ...outcomes] = await Promise.all([ + resolvePermissions(clients.axios), + ...SHOP_PROBES.map((probe) => probeShopEndpoint(clients.axios, probe)), + ]); + + const permissionsData = permissionsResult.data; + const permissionRoles = [ + ...(permissionsData?.roles ?? []), + ...(permissionsData?.permissions ?? []), + ]; + const rules = permissionRoles.flatMap((role) => role.rules ?? []); + const roleNames = (permissionsData?.roles ?? []) + .map((role) => role.metadata?.name) + .filter((name): name is string => typeof name === "string" && name.length > 0) + .sort(); + const writeUnknown = permissionsResult.unavailable; + + permissions.roles = roleNames; + + if (writeUnknown) { + const statusSuffix = permissionsResult.status ? ` (HTTP ${permissionsResult.status})` : ""; + warnings.push({ + code: "PERMISSIONS_UNAVAILABLE", + message: `Unable to read the current user's permissions${statusSuffix}; write access is unknown.`, + }); + } + + const probes = [PRODUCT_PROBE, ...SHOP_PROBES]; + for (const [index, outcome] of [productsProbe, ...outcomes].entries()) { + const probe = probes[index]!; + features[probe.key] = outcome.ok; + permissions.read[probe.key] = outcome.ok; + + if (!writeUnknown) { + permissions.write[probe.key] = hasPermission(rules, probe.permission, [ + "create", + "update", + "patch", + "delete", + ]); + } + + if (outcome.ok) { + if (probe.configuration !== undefined && outcome.count !== undefined) { + configuration[probe.configuration] = outcome.count; + } + continue; + } + + if (outcome.status === 403) { + warnings.push({ + code: "PERMISSION_DENIED", + message: `Permission denied while checking ${probe.label}.`, + }); + continue; + } + + if (outcome.status === 404 && probe.required) { + warnings.push({ + code: "RESOURCE_UNAVAILABLE", + message: `The shop API does not expose ${probe.label}.`, + }); + } + + if (outcome.unexpected || outcome.status === undefined) { + warnings.push({ + code: "PROBE_FAILED", + message: `Failed to check ${probe.label}${outcome.status ? ` (HTTP ${outcome.status})` : ""}.`, + }); + } + } + + if (!writeUnknown && SHOP_FEATURES.every((key) => permissions.write[key] === false)) { + warnings.push({ + code: "READ_ONLY_ACCESS", + message: "The current user has no write permissions for any shop resource.", + }); + } + + if (status.activationState === undefined) { + warnings.push({ + code: "ACTIVATION_UNKNOWN", + message: "Unable to read the Halo Pro or Halo Commercial activation state.", + }); + } else if (!status.commercialActivated) { + warnings.push({ + code: "COMMERCIAL_NOT_ACTIVATED", + message: "Halo Pro or Halo Commercial is not activated.", + }); + } + + const configurationWarnings: Array<[ConfigurationKey, string, string]> = [ + ["paymentProviders", "NO_PAYMENT_PROVIDERS", "No payment providers are available."], + ["paymentMethods", "NO_PAYMENT_METHODS", "No payment methods are configured."], + ["shippingProfiles", "NO_SHIPPING_PROFILES", "No shipping profiles are configured."], + ["salesChannels", "NO_SALES_CHANNELS", "No sales channels are configured."], + ["fulfillmentMethods", "NO_FULFILLMENT_METHODS", "No fulfillment methods are configured."], + ]; + for (const [key, code, message] of configurationWarnings) { + if (configuration[key] === 0) { + warnings.push({ code, message }); + } + } + + warnings.sort((left, right) => left.code.localeCompare(right.code)); + + return { + ...status, + shopApiStatus, + features, + permissions, + configuration, + warnings, + }; +} + +export function printShopDoctor(result: ShopDoctorResult): void { + process.stdout.write(`profile: ${result.profile} -> ${result.baseUrl}\n`); + process.stdout.write(`edition: ${result.edition}\n`); + if (result.version) { + process.stdout.write(`version: ${result.version}\n`); + } + const activation = + result.activationState === undefined ? "unknown" : result.commercialActivated ? "yes" : "no"; + process.stdout.write(`commercial activated: ${activation}\n`); + if (result.activationState) { + process.stdout.write(`activation state: ${result.activationState}\n`); + } + process.stdout.write(`shop API status: ${result.shopApiStatus}\n`); + + const enabled = SHOP_FEATURES.filter((key) => result.features[key]); + process.stdout.write(`features: ${enabled.length > 0 ? enabled.join(", ") : "none"}\n`); + + if (result.permissions.roles.length > 0) { + process.stdout.write(`roles: ${result.permissions.roles.join(", ")}\n`); + } + + const writeStates = SHOP_FEATURES.map((key) => result.permissions.write[key]); + if (writeStates.every((value) => value === null)) { + process.stdout.write("write permissions: unknown\n"); + } else { + const writable = SHOP_FEATURES.filter((key) => result.permissions.write[key] === true); + process.stdout.write( + `write permissions: ${writable.length > 0 ? writable.join(", ") : "none"}\n`, + ); + } + + const configured = CONFIGURATION_KEYS.flatMap((key) => { + const value = result.configuration[key]; + return value === null ? [] : [`${key}=${value}`]; + }); + if (configured.length > 0) { + process.stdout.write(`configuration: ${configured.join(", ")}\n`); + } + + if (result.warnings.length === 0) { + process.stdout.write("warnings: none\n"); + return; + } + + process.stdout.write(`warnings (${result.warnings.length}):\n`); + for (const warning of result.warnings) { + process.stdout.write(`- ${warning.code}: ${warning.message}\n`); + } +} diff --git a/src/commands/shop/index.ts b/src/commands/shop/index.ts index 929220a..48e5223 100644 --- a/src/commands/shop/index.ts +++ b/src/commands/shop/index.ts @@ -1,110 +1,19 @@ -import axios from "axios"; import { type CAC } from "cac"; import { tryRunCommandCliRoute, tryRunNestedCliRoute } from "../../utils/command-router.js"; -import { CliError } from "../../utils/errors.js"; import { printJson } from "../../utils/output.js"; import { RuntimeContext } from "../../utils/runtime.js"; -import { ShopApi } from "./api.js"; import { buildCategoryCli } from "./category.js"; import { createShopCli } from "./cli.js"; import { buildConfigCli } from "./config.js"; import { buildCouponCli } from "./coupon.js"; import { buildCustomerCli } from "./customer.js"; import { buildDiscountCli } from "./discount.js"; +import { printShopDoctor, resolveShopDoctor } from "./doctor.js"; import { buildOrderCli } from "./order.js"; import { buildProductCli } from "./product.js"; import { buildStatsCli } from "./stats.js"; -import type { LicenseActivation } from "./types.js"; - -const COMMERCIAL_PRODUCT_NAMES = new Set(["product-halo-commercial", "product-halo-pro"]); - -interface ShopStatusOptions { - profile?: string; - json?: boolean; -} - -interface HaloActuatorInfo { - build?: { - name?: string; - version?: string; - }; -} - -interface ShopStatusResult { - profile: string; - baseUrl: string; - edition: string; - version?: string; - commercialActivated: boolean; - activationState?: string; - shopApiAvailable: boolean; -} - -function isNotFoundError(error: unknown): boolean { - return axios.isAxiosError(error) && error.response?.status === 404; -} - -function isShopApiUnavailableError(error: unknown): boolean { - return error instanceof CliError && error.message.startsWith("Shop API is not available"); -} - -async function resolveShopStatus( - runtime: RuntimeContext, - options: ShopStatusOptions, -): Promise<ShopStatusResult> { - const { profile, clients } = await runtime.getClientsForOptions(options); - - let info: HaloActuatorInfo | undefined; - try { - const response = await clients.axios.get<HaloActuatorInfo>("/actuator/info"); - info = response.data; - } catch (error) { - // Older or restricted instances may not expose actuator info. - if (!isNotFoundError(error)) { - throw error; - } - } - - let activation: LicenseActivation | undefined; - try { - const response = await clients.axios.get<LicenseActivation[]>( - "/apis/console.api.license.pro.halo.run/v1alpha1/activations", - ); - activation = - response.data.find( - (item) => - COMMERCIAL_PRODUCT_NAMES.has(item.spec?.productName ?? "") && - item.status?.state === "active", - ) ?? response.data.find((item) => COMMERCIAL_PRODUCT_NAMES.has(item.spec?.productName ?? "")); - } catch (error) { - // Non-Pro instances do not expose the license API. - if (!isNotFoundError(error)) { - throw error; - } - } - - let shopApiAvailable = false; - try { - await new ShopApi(clients.axios).listProducts({ page: 1, size: 1 }); - shopApiAvailable = true; - } catch (error) { - // The shop API is absent on non-commercial instances. - if (!isNotFoundError(error) && !isShopApiUnavailableError(error)) { - throw error; - } - } - - return { - profile: profile.name, - baseUrl: profile.baseUrl, - edition: info?.build?.name ?? "unknown", - version: info?.build?.version, - commercialActivated: activation?.status?.state === "active", - activationState: activation?.status?.state, - shopApiAvailable, - }; -} +import { resolveShopStatus, type ShopStatusOptions } from "./status.js"; const SHOP_GROUPS = [ { @@ -172,19 +81,41 @@ function buildShopCli(runtime: RuntimeContext): CAC { if (status.version) { process.stdout.write(`version: ${status.version}\n`); } - process.stdout.write(`commercial activated: ${status.commercialActivated ? "yes" : "no"}\n`); + const activation = + status.activationState === undefined + ? "unknown" + : status.commercialActivated + ? "yes" + : "no"; + process.stdout.write(`commercial activated: ${activation}\n`); if (status.activationState) { process.stdout.write(`activation state: ${status.activationState}\n`); } process.stdout.write(`shop API available: ${status.shopApiAvailable ? "yes" : "no"}\n`); }); + shopCli + .command("doctor", "Diagnose shop capabilities, permissions, and configuration") + .option("--profile <name>", "Halo profile name") + .option("--json", "Output JSON") + .action(async (options: ShopStatusOptions) => { + const result = await resolveShopDoctor(runtime, options); + + if (options.json) { + printJson(result); + return; + } + + printShopDoctor(result); + }); + for (const group of SHOP_GROUPS) { shopCli.command(group.name, group.description); } shopCli.usage("<command> [flags]"); shopCli.example((bin) => `${bin} status`); + shopCli.example((bin) => `${bin} doctor --json`); shopCli.example((bin) => `${bin} product list`); shopCli.example((bin) => `${bin} order list --payment-status PAID`); shopCli.example((bin) => `${bin} stats daily --start 2026-01-01`); diff --git a/src/commands/shop/product.ts b/src/commands/shop/product.ts index d2e847d..7b80662 100644 --- a/src/commands/shop/product.ts +++ b/src/commands/shop/product.ts @@ -560,16 +560,16 @@ async function buildUpdateRequest( interface ProductUpdateDanger { unpublish: boolean; + removedVariantIds: number[]; disabledVariantIds: number[]; } function detectProductUpdateDanger(current: Product, request: ProductRequest): ProductUpdateDanger { + const guardVariants = current.status === "PUBLISHED" && request.status === "PUBLISHED"; return { unpublish: current.status === "PUBLISHED" && request.status !== "PUBLISHED", - disabledVariantIds: - current.status === "PUBLISHED" && request.status === "PUBLISHED" - ? findDisabledVariantIds(current, request) - : [], + removedVariantIds: guardVariants ? findRemovedVariantIds(current, request) : [], + disabledVariantIds: guardVariants ? findDisabledVariantIds(current, request) : [], }; } @@ -595,6 +595,23 @@ async function confirmProductUpdateDanger( } } + if (danger.removedVariantIds.length > 0) { + if ( + !(await confirmDangerousAction( + { + commandPath: "halo shop product update", + actionLabel: "Remove", + resourceLabel: "variants of product", + resourceName: `${productId}: ${danger.removedVariantIds.join(", ")}`, + cancellationVerb: "removing", + }, + options, + )) + ) { + return false; + } + } + if (danger.disabledVariantIds.length > 0) { if ( !(await confirmDangerousAction( @@ -616,7 +633,9 @@ async function confirmProductUpdateDanger( } function hasProductUpdateDanger(danger: ProductUpdateDanger): boolean { - return danger.unpublish || danger.disabledVariantIds.length > 0; + return ( + danger.unpublish || danger.removedVariantIds.length > 0 || danger.disabledVariantIds.length > 0 + ); } function newProductUpdateDanger( @@ -625,6 +644,9 @@ function newProductUpdateDanger( ): ProductUpdateDanger { return { unpublish: next.unpublish && !previous.unpublish, + removedVariantIds: next.removedVariantIds.filter( + (id) => !previous.removedVariantIds.includes(id), + ), disabledVariantIds: next.disabledVariantIds.filter( (id) => !previous.disabledVariantIds.includes(id), ), @@ -637,10 +659,30 @@ function mergeProductUpdateDanger( ): ProductUpdateDanger { return { unpublish: previous.unpublish || next.unpublish, + removedVariantIds: [...new Set([...previous.removedVariantIds, ...next.removedVariantIds])], disabledVariantIds: [...new Set([...previous.disabledVariantIds, ...next.disabledVariantIds])], }; } +function findRemovedVariantIds(current: Product, request: ProductRequest): number[] { + if (current.productType === "SUBSCRIPTION") { + return []; + } + + const requestedVariants = request.productVariants; + if (!Array.isArray(requestedVariants)) { + return []; + } + + const requestedIds = new Set( + requestedVariants.flatMap((variant) => (variant.id === undefined ? [] : [variant.id])), + ); + + return (current.productVariants ?? []).flatMap((variant) => + variant.id !== undefined && !requestedIds.has(variant.id) ? [variant.id] : [], + ); +} + function findDisabledVariantIds(current: Product, request: ProductRequest): number[] { if (current.productType === "SUBSCRIPTION") { return []; @@ -663,7 +705,7 @@ function findDisabledVariantIds(current: Product, request: ProductRequest): numb } if (!nextStatus.has(variant.id)) { - return [variant.id]; + return []; } const status = nextStatus.get(variant.id); diff --git a/src/commands/shop/status.ts b/src/commands/shop/status.ts new file mode 100644 index 0000000..d8a8452 --- /dev/null +++ b/src/commands/shop/status.ts @@ -0,0 +1,147 @@ +import axios from "axios"; + +import type { HaloProfile } from "../../shared/profile.js"; +import { CliError } from "../../utils/errors.js"; +import { RuntimeContext, type HaloClients } from "../../utils/runtime.js"; +import { ShopApi } from "./api.js"; +import type { LicenseActivation } from "./types.js"; + +const COMMERCIAL_PRODUCT_NAMES = new Set(["product-halo-commercial", "product-halo-pro"]); + +export interface ShopStatusOptions { + profile?: string; + json?: boolean; +} + +interface HaloActuatorInfo { + build?: { + name?: string; + version?: string; + }; +} + +export interface ShopStatusResult { + profile: string; + baseUrl: string; + edition: string; + version?: string; + commercialActivated: boolean; + activationState?: string; + shopApiAvailable: boolean; +} + +export type ShopApiProbeStatus = "available" | "denied" | "unavailable" | "unknown"; + +export interface ResolvedShopRuntime { + profile: HaloProfile; + clients: HaloClients; +} + +export interface ShopStatusInspection { + status: ShopStatusResult; + shopApiStatus: ShopApiProbeStatus; + shopApiError?: unknown; + resolved: ResolvedShopRuntime; +} + +export function isNotFoundError(error: unknown): boolean { + return axios.isAxiosError(error) && error.response?.status === 404; +} + +export function isForbiddenError(error: unknown): boolean { + return axios.isAxiosError(error) && error.response?.status === 403; +} + +export function isUnauthorizedError(error: unknown): boolean { + return axios.isAxiosError(error) && error.response?.status === 401; +} + +function isShopApiUnavailableError(error: unknown): boolean { + return error instanceof CliError && error.message.startsWith("Shop API is not available"); +} + +export async function resolveShopStatus( + runtime: RuntimeContext, + options: ShopStatusOptions, +): Promise<ShopStatusResult> { + const inspection = await inspectShopStatus(runtime, options); + if (inspection.shopApiStatus === "unknown" && inspection.shopApiError !== undefined) { + throw inspection.shopApiError; + } + return inspection.status; +} + +export async function inspectShopStatus( + runtime: RuntimeContext, + options: ShopStatusOptions, + resolved?: ResolvedShopRuntime, +): Promise<ShopStatusInspection> { + const context = resolved ?? (await runtime.getClientsForOptions(options)); + const { profile, clients } = context; + + let info: HaloActuatorInfo | undefined; + try { + const response = await clients.axios.get<HaloActuatorInfo>("/actuator/info"); + info = response.data; + } catch (error) { + // Older or restricted instances may not expose actuator info. + if (!isNotFoundError(error) && !isForbiddenError(error)) { + throw error; + } + } + + let activation: LicenseActivation | undefined; + try { + const response = await clients.axios.get<LicenseActivation[]>( + "/apis/console.api.license.pro.halo.run/v1alpha1/activations", + ); + activation = + response.data.find( + (item) => + COMMERCIAL_PRODUCT_NAMES.has(item.spec?.productName ?? "") && + item.status?.state === "active", + ) ?? response.data.find((item) => COMMERCIAL_PRODUCT_NAMES.has(item.spec?.productName ?? "")); + } catch (error) { + // Non-Pro instances do not expose the license API, and limited tokens may + // not be allowed to read it. + if (!isNotFoundError(error) && !isForbiddenError(error)) { + throw error; + } + } + + let shopApiStatus: ShopApiProbeStatus = "unknown"; + let shopApiError: unknown; + try { + await new ShopApi(clients.axios).listProducts({ page: 1, size: 1 }); + shopApiStatus = "available"; + } catch (error) { + if (isUnauthorizedError(error)) { + throw error; + } + if (isForbiddenError(error)) { + shopApiStatus = "denied"; + shopApiError = error; + } else if (isNotFoundError(error) || isShopApiUnavailableError(error)) { + shopApiStatus = "unavailable"; + } else { + // A server error does not prove the shop API is absent. Keep the status + // unknown so callers can still report the failure and continue probing. + shopApiError = error; + } + } + + return { + status: { + profile: profile.name, + baseUrl: profile.baseUrl, + edition: info?.build?.name ?? "unknown", + version: info?.build?.version, + commercialActivated: activation?.status?.state === "active", + activationState: activation?.status?.state, + shopApiAvailable: shopApiStatus === "available", + }, + shopApiStatus, + shopApiError, + resolved: context, + }; +} diff --git a/src/utils/__test__/completion.spec.ts b/src/utils/__test__/completion.spec.ts index ecb69e2..0dbaf86 100644 --- a/src/utils/__test__/completion.spec.ts +++ b/src/utils/__test__/completion.spec.ts @@ -43,7 +43,7 @@ test("getCompletionCandidates suggests flags when current token starts with dash test("getCompletionCandidates suggests shop commands and flags", () => { expect(getCompletionCandidates(["shop"], "")).toEqual( - expect.arrayContaining(["status", "product", "order", "coupon"]), + expect.arrayContaining(["status", "doctor", "product", "order", "coupon"]), ); expect(getCompletionCandidates(["shop", "order"], "")).toEqual( expect.arrayContaining(["fulfill", "cancel", "mark-paid"]), diff --git a/src/utils/completion.ts b/src/utils/completion.ts index 930bb36..fb15c6f 100644 --- a/src/utils/completion.ts +++ b/src/utils/completion.ts @@ -268,6 +268,7 @@ const COMPLETION_TREE: CompletionNode = { flags: HELP_FLAGS, subcommands: { status: { flags: PROFILE_JSON_FLAGS }, + doctor: { flags: PROFILE_JSON_FLAGS }, product: { flags: HELP_FLAGS, subcommands: { From 8353462451155f83705d0ef1c962fcd1a8ce4d98 Mon Sep 17 00:00:00 2001 From: Ryan Wang <i@ryanc.cc> Date: Tue, 3 Nov 2026 14:39:34 +0800 Subject: [PATCH 3/3] Require confirmation for all variant removals The specifications endpoint archives omitted variants regardless of product status, so detect removals for draft and archived products too. --- skills/halo-cli-shop/SKILL.md | 2 +- src/commands/shop/__test__/shop-entry.spec.ts | 34 +++++++++++++++++++ src/commands/shop/product.ts | 8 +++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/skills/halo-cli-shop/SKILL.md b/skills/halo-cli-shop/SKILL.md index b34090c..17c5a43 100644 --- a/skills/halo-cli-shop/SKILL.md +++ b/skills/halo-cli-shop/SKILL.md @@ -158,7 +158,7 @@ FIXED variants require a `price`; only `pricingType: "INQUIRY"` variants may omi ## Safety - `product delete`, `category delete`, `coupon delete`, `discount delete`, `order cancel`, `order mark-paid`, `order fulfill`, `product unpublish`, `product update --status DRAFT|ARCHIVED`, `product update --variant-status DRAFT|ARCHIVED` on a published product, `coupon update --enabled false`, `discount update --enabled false`, `coupon distribute --all`, and `customer update --status BLOCKED` require interactive confirmation or `--force` in non-interactive mode. -- `product update --file` follows full-array replacement for `productVariants`: removing any variant (including a draft variant, or sending an empty array) while the product is published requires confirmation. +- `product update --file` follows full-array replacement for `productVariants`: removing any variant (including a draft variant, or sending an empty array) requires confirmation, regardless of the product's status. - `order fulfill --all` skips subscription order lines. Subscription lines are fulfilled by the integrator and cannot be fulfilled manually. Use `--provider MANUAL`; the current backend does not expose a VIRTUAL fulfillment provider. Fulfillment cannot be reversed through the API, so confirm before using `--force`. - Prefer `--json` and inspect the returned resource after every mutation. - Do not print or send payment secrets from `shop config get --group payment`; treat them as credentials. diff --git a/src/commands/shop/__test__/shop-entry.spec.ts b/src/commands/shop/__test__/shop-entry.spec.ts index d60b965..fc33cf8 100644 --- a/src/commands/shop/__test__/shop-entry.spec.ts +++ b/src/commands/shop/__test__/shop-entry.spec.ts @@ -2229,6 +2229,40 @@ test("shop product update requires confirmation when a draft variant is removed" } }); +test("shop product update requires confirmation when a draft product variant is removed", async () => { + silenceStdout(); + + const { file, cleanup } = await writeJsonFile({ + productVariants: [{ id: 11, price: 10 }], + }); + + try { + const current = { + id: 7, + title: "Draft product", + productType: "PHYSICAL", + status: "DRAFT", + productVariants: [ + { id: 11, price: 10, status: "DRAFT" }, + { id: 12, price: 20, status: "PUBLISHED" }, + ], + }; + const request = vi.fn().mockResolvedValueOnce({ data: current }); + const runtime = createRuntimeMock({ request }); + + await expect( + tryRunShopCommand( + ["shop", "product", "update", "7", "--file", file, "--json"], + runtime as never, + ), + ).rejects.toThrow("requires confirmation in interactive mode or use --force"); + + expect(request).toHaveBeenCalledOnce(); + } finally { + await cleanup(); + } +}); + test("shop product update requires confirmation for lowercase variant status via --file", async () => { silenceStdout(); diff --git a/src/commands/shop/product.ts b/src/commands/shop/product.ts index 7b80662..faf9e4c 100644 --- a/src/commands/shop/product.ts +++ b/src/commands/shop/product.ts @@ -568,7 +568,9 @@ function detectProductUpdateDanger(current: Product, request: ProductRequest): P const guardVariants = current.status === "PUBLISHED" && request.status === "PUBLISHED"; return { unpublish: current.status === "PUBLISHED" && request.status !== "PUBLISHED", - removedVariantIds: guardVariants ? findRemovedVariantIds(current, request) : [], + // The specifications endpoint archives every variant omitted from the + // replacement array, regardless of the product's publication status. + removedVariantIds: findRemovedVariantIds(current, request), disabledVariantIds: guardVariants ? findDisabledVariantIds(current, request) : [], }; } @@ -600,10 +602,10 @@ async function confirmProductUpdateDanger( !(await confirmDangerousAction( { commandPath: "halo shop product update", - actionLabel: "Remove", + actionLabel: "Archive", resourceLabel: "variants of product", resourceName: `${productId}: ${danger.removedVariantIds.join(", ")}`, - cancellationVerb: "removing", + cancellationVerb: "archiving", }, options, ))