From 120193fd367a9b0257fe362540b19e13ddaf4b54 Mon Sep 17 00:00:00 2001 From: c-99-e <268417377+c-99-e@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:16:21 +0400 Subject: [PATCH] fix: reject misleading --handle flag on get commands, enrich product list/get output - All get commands (product, page, collection, menu) now produce a clear error when --handle is passed without a positional argument, instead of silently ignoring the flag. - Extract rejectHandleFlag() helper to avoid duplicating the check. - product list: add Category column and Image (Yes/No) indicator. GraphQL query now fetches category { id name } and featuredImage { url }. - product get: add Category and Metafields sections to table and JSON output. GraphQL query now fetches category { id name } and metafields(first: 25). - Add friction log docs (FRICTION.md, CLI_FRICTION.md) and architecture spec (docs/gql-first-refactor.md) for future reference. Fixes SAU-234 --- package.json | 2 +- src/commands/collection.ts | 8 ++++- src/commands/menu.ts | 3 +- src/commands/page.ts | 2 ++ src/commands/product.ts | 59 ++++++++++++++++++++++++++++++++- src/helpers.ts | 17 ++++++++++ tests/collection-get.test.ts | 11 +++++++ tests/menu-get.test.ts | 11 +++++++ tests/page-get.test.ts | 14 ++++++++ tests/product-get.test.ts | 63 ++++++++++++++++++++++++++++++++++++ tests/product-list.test.ts | 21 ++++++++++++ 11 files changed, 207 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 32b7443..91d3e0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shopq", - "version": "0.4.0", + "version": "0.4.1", "description": "A zero-dependency Shopify Admin CLI built on Bun", "type": "module", "license": "MIT", diff --git a/src/commands/collection.ts b/src/commands/collection.ts index ca4bd10..2e8b946 100644 --- a/src/commands/collection.ts +++ b/src/commands/collection.ts @@ -1,4 +1,9 @@ -import { clampLimit, getClient, handleCommandError } from "../helpers"; +import { + clampLimit, + getClient, + handleCommandError, + rejectHandleFlag, +} from "../helpers"; import { formatError, formatOutput } from "../output"; import { register } from "../registry"; import type { ParsedArgs } from "../types"; @@ -59,6 +64,7 @@ function truncate(str: string, max: number): string { } async function handleCollectionGet(parsed: ParsedArgs): Promise { + if (rejectHandleFlag(parsed, "shopq collection get ")) return; const idOrHandle = parsed.args.join(" "); if (!idOrHandle) { formatError("Usage: shopq collection get "); diff --git a/src/commands/menu.ts b/src/commands/menu.ts index a8270af..5b1efd8 100644 --- a/src/commands/menu.ts +++ b/src/commands/menu.ts @@ -1,4 +1,4 @@ -import { getClient, handleCommandError } from "../helpers"; +import { getClient, handleCommandError, rejectHandleFlag } from "../helpers"; import { formatError, formatOutput } from "../output"; import { register } from "../registry"; import type { ParsedArgs } from "../types"; @@ -105,6 +105,7 @@ function flattenItems( } async function handleMenuGet(parsed: ParsedArgs): Promise { + if (rejectHandleFlag(parsed, "shopq menu get ")) return; const idOrHandle = parsed.args.join(" "); if (!idOrHandle) { formatError("Usage: shopq menu get "); diff --git a/src/commands/page.ts b/src/commands/page.ts index a282f07..775f78e 100644 --- a/src/commands/page.ts +++ b/src/commands/page.ts @@ -3,6 +3,7 @@ import { getClient, handleCommandError, readFileText, + rejectHandleFlag, } from "../helpers"; import { formatError, formatOutput } from "../output"; import { register } from "../registry"; @@ -292,6 +293,7 @@ function resolvePageId( } async function handlePageGet(parsed: ParsedArgs): Promise { + if (rejectHandleFlag(parsed, "shopq page get ")) return; const idOrHandle = parsed.args.join(" "); if (!idOrHandle) { formatError("Usage: shopq page get "); diff --git a/src/commands/product.ts b/src/commands/product.ts index 3da5078..b2757bb 100644 --- a/src/commands/product.ts +++ b/src/commands/product.ts @@ -4,6 +4,7 @@ import { getClient, handleCommandError, readFileJson, + rejectHandleFlag, } from "../helpers"; import { formatError, formatOutput } from "../output"; import { register } from "../registry"; @@ -20,6 +21,8 @@ const PRODUCTS_QUERY = `query ProductList($first: Int!, $after: String, $sortKey vendor variantsCount { count } totalInventory + category { id name } + featuredImage { url } } } pageInfo { @@ -37,6 +40,8 @@ interface ProductNode { vendor: string; variantsCount: { count: number }; totalInventory: number; + category: { id: string; name: string } | null; + featuredImage: { url: string } | null; } interface ProductsResponse { @@ -82,6 +87,8 @@ async function handleProductList(parsed: ParsedArgs): Promise { vendor: e.node.vendor, variantsCount: e.node.variantsCount.count, totalInventory: e.node.totalInventory, + category: e.node.category?.name ?? null, + hasImage: e.node.featuredImage !== null, })); const pageInfo = result.products.pageInfo; @@ -103,9 +110,16 @@ async function handleProductList(parsed: ParsedArgs): Promise { { key: "vendor", header: "Vendor" }, { key: "variantsCount", header: "Variants" }, { key: "totalInventory", header: "Inventory" }, + { key: "category", header: "Category" }, + { key: "hasImage", header: "Image" }, ]; - formatOutput(products, columns, { + const tableData = products.map((p) => ({ + ...p, + hasImage: p.hasImage ? "Yes" : "No", + })); + + formatOutput(tableData, columns, { json: false, noColor: parsed.flags.noColor, pageInfo, @@ -124,6 +138,17 @@ const PRODUCT_GET_QUERY = `query ProductGet($id: ID!) { vendor tags descriptionHtml + category { id name } + metafields(first: 25) { + edges { + node { + namespace + key + type + value + } + } + } variants(first: 100) { edges { node { @@ -167,6 +192,17 @@ interface ProductGetResponse { vendor: string; tags: string[]; descriptionHtml: string; + category: { id: string; name: string } | null; + metafields: { + edges: Array<{ + node: { + namespace: string; + key: string; + type: string; + value: string; + }; + }>; + }; variants: { edges: Array<{ node: { @@ -219,6 +255,7 @@ function truncate(str: string, max: number): string { } async function handleProductGet(parsed: ParsedArgs): Promise { + if (rejectHandleFlag(parsed, "shopq product get ")) return; const idOrTitle = parsed.args.join(" "); if (!idOrTitle) { formatError("Usage: shopq product get "); @@ -309,6 +346,13 @@ function outputProduct( alt: e.node.altText ?? "", })); + const metafields = product.metafields.edges.map((e) => ({ + namespace: e.node.namespace, + key: e.node.key, + type: e.node.type, + value: e.node.value, + })); + if (parsed.flags.json) { const data = { id: product.id, @@ -318,6 +362,8 @@ function outputProduct( vendor: product.vendor, tags: product.tags, description: stripHtml(product.descriptionHtml), + category: product.category, + metafields, variants: product.variants.edges.map((e) => ({ id: e.node.id, sku: e.node.sku, @@ -343,8 +389,19 @@ function outputProduct( lines.push(`${label("Type")}: ${product.productType}`); lines.push(`${label("Vendor")}: ${product.vendor}`); lines.push(`${label("Tags")}: ${product.tags.join(", ")}`); + lines.push(`${label("Category")}: ${product.category?.name ?? ""}`); lines.push(`${label("Description")}: ${truncate(plainDesc, 80)}`); + lines.push(""); + lines.push(`${label("Metafields")}:`); + if (metafields.length === 0) { + lines.push(" (none)"); + } else { + for (const mf of metafields) { + lines.push(` ${mf.namespace}.${mf.key}: ${mf.value}`); + } + } + lines.push(""); lines.push(`${label("Variants")}:`); for (const v of variants) { diff --git a/src/helpers.ts b/src/helpers.ts index b84493a..402382e 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -8,6 +8,7 @@ import { resolveConfig, } from "./graphql"; import { formatError } from "./output"; +import type { ParsedArgs } from "./types"; /** * Create a GraphQL client from parsed flags. @@ -41,6 +42,22 @@ export function handleCommandError(err: unknown): void { throw new Error(String(err)); } +/** + * Check if the user passed --handle as a flag instead of a positional arg. + * Returns true (and sets error + exit code) if the flag was misused. + * Callers should return early when this returns true. + */ +export function rejectHandleFlag(parsed: ParsedArgs, usage: string): boolean { + if (parsed.args.length === 0 && parsed.flags.handle) { + formatError( + `--handle is not a flag for this command. Pass the identifier as a positional argument: ${usage}`, + ); + process.exitCode = 2; + return true; + } + return false; +} + /** * Read a file as text, with user-friendly error handling. * Returns the file contents or writes an error to stderr and sets exit code 1. diff --git a/tests/collection-get.test.ts b/tests/collection-get.test.ts index 56a09cf..e79d178 100644 --- a/tests/collection-get.test.ts +++ b/tests/collection-get.test.ts @@ -220,4 +220,15 @@ describe("shopq collection get", () => { expect(stderr).toContain("Usage"); expect(exitCode).toBe(2); }); + + test("--handle without positional arg produces helpful error", async () => { + const { stderr, exitCode } = await run([ + "collection", + "get", + "--handle", + "summer-sale", + ]); + expect(stderr).toContain("positional"); + expect(exitCode).toBe(2); + }); }); diff --git a/tests/menu-get.test.ts b/tests/menu-get.test.ts index e9a6383..68f4fd8 100644 --- a/tests/menu-get.test.ts +++ b/tests/menu-get.test.ts @@ -179,4 +179,15 @@ describe("shopq menu get", () => { expect(stderr).toContain("Usage"); expect(exitCode).toBe(2); }); + + test("--handle without positional arg produces helpful error", async () => { + const { stderr, exitCode } = await run([ + "menu", + "get", + "--handle", + "main-menu", + ]); + expect(stderr).toContain("positional"); + expect(exitCode).toBe(2); + }); }); diff --git a/tests/page-get.test.ts b/tests/page-get.test.ts index 8d258db..44b1c94 100644 --- a/tests/page-get.test.ts +++ b/tests/page-get.test.ts @@ -241,3 +241,17 @@ describe("shopq page get — missing args", () => { expect(exitCode).toBe(2); }); }); + +describe("shopq page get — --handle flag", () => { + test("--handle without positional arg produces helpful error", async () => { + mockBehavior = "found"; + const { stderr, exitCode } = await run([ + "page", + "get", + "--handle", + "about-us", + ]); + expect(stderr).toContain("positional"); + expect(exitCode).toBe(2); + }); +}); diff --git a/tests/product-get.test.ts b/tests/product-get.test.ts index b10e0d9..409a812 100644 --- a/tests/product-get.test.ts +++ b/tests/product-get.test.ts @@ -45,6 +45,30 @@ const FULL_PRODUCT = { }, ], }, + category: { + id: "gid://shopify/TaxonomyCategory/1", + name: "Widgets", + }, + metafields: { + edges: [ + { + node: { + namespace: "custom", + key: "ingredients", + type: "single_line_text_field", + value: "Steel, Rubber", + }, + }, + { + node: { + namespace: "custom", + key: "brewing_guide", + type: "multi_line_text_field", + value: "N/A", + }, + }, + ], + }, }; const PRODUCT_B = { @@ -262,6 +286,22 @@ describe("shopq product get — by ID", () => { expect(stdout).not.toContain("

"); }); + test("table output shows category", async () => { + mockBehavior = "single"; + const { stdout } = await run(["product", "get", "1001", "--no-color"]); + expect(stdout).toContain("Category"); + expect(stdout).toContain("Widgets"); + }); + + test("table output shows metafields", async () => { + mockBehavior = "single"; + const { stdout } = await run(["product", "get", "1001", "--no-color"]); + expect(stdout).toContain("Metafields"); + expect(stdout).toContain("custom.ingredients"); + expect(stdout).toContain("Steel, Rubber"); + expect(stdout).toContain("custom.brewing_guide"); + }); + test("--json returns full product in { data } envelope", async () => { mockBehavior = "single"; const { stdout, exitCode } = await run([ @@ -284,6 +324,15 @@ describe("shopq product get — by ID", () => { expect(parsed.data.variants[0].sku).toBe("AW-001"); expect(parsed.data.images).toBeArray(); expect(parsed.data.images[0].url).toContain("image1.jpg"); + expect(parsed.data.category).toEqual({ + id: "gid://shopify/TaxonomyCategory/1", + name: "Widgets", + }); + expect(parsed.data.metafields).toBeArray(); + expect(parsed.data.metafields.length).toBe(2); + expect(parsed.data.metafields[0].namespace).toBe("custom"); + expect(parsed.data.metafields[0].key).toBe("ingredients"); + expect(parsed.data.metafields[0].value).toBe("Steel, Rubber"); expect(exitCode).toBe(0); }); @@ -330,3 +379,17 @@ describe("shopq product get — missing args", () => { expect(exitCode).toBe(2); }); }); + +describe("shopq product get — --handle flag", () => { + test("--handle without positional arg produces helpful error", async () => { + mockBehavior = "single"; + const { stderr, exitCode } = await run([ + "product", + "get", + "--handle", + "some-product", + ]); + expect(stderr).toContain("positional"); + expect(exitCode).toBe(2); + }); +}); diff --git a/tests/product-list.test.ts b/tests/product-list.test.ts index cb37ebb..f6a3f75 100644 --- a/tests/product-list.test.ts +++ b/tests/product-list.test.ts @@ -14,6 +14,8 @@ const MOCK_PRODUCTS = [ vendor: "Acme", variantsCount: { count: 3 }, totalInventory: 100, + category: { id: "gid://shopify/TaxonomyCategory/1", name: "Widgets" }, + featuredImage: { url: "https://cdn.shopify.com/widget.jpg" }, }, }, { @@ -25,6 +27,8 @@ const MOCK_PRODUCTS = [ vendor: "Globex", variantsCount: { count: 1 }, totalInventory: 0, + category: null, + featuredImage: null, }, }, ]; @@ -138,6 +142,19 @@ describe("shopq product list", () => { expect(stdout).toContain("Vendor"); expect(stdout).toContain("Variants"); expect(stdout).toContain("Inventory"); + expect(stdout).toContain("Category"); + expect(stdout).toContain("Image"); + }); + + test("table output shows category name when present", async () => { + const { stdout } = await run(["product", "list", "--no-color"]); + expect(stdout).toContain("Widgets"); + }); + + test("table output shows image indicator", async () => { + const { stdout } = await run(["product", "list", "--no-color"]); + expect(stdout).toContain("Yes"); + expect(stdout).toContain("No"); }); test("table shows pagination hint when more results", async () => { @@ -159,6 +176,10 @@ describe("shopq product list", () => { expect(parsed.data[0].vendor).toBe("Acme"); expect(parsed.data[0].variantsCount).toBe(3); expect(parsed.data[0].totalInventory).toBe(100); + expect(parsed.data[0].category).toBe("Widgets"); + expect(parsed.data[0].hasImage).toBe(true); + expect(parsed.data[1].category).toBeNull(); + expect(parsed.data[1].hasImage).toBe(false); expect(parsed.pageInfo).toEqual({ hasNextPage: true, endCursor: "cursor-page2",