From 9850b6b8e7c70e91e4704b29c5c71a3949848eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cvuth-dogo=E2=80=9D?= Date: Mon, 19 Jan 2026 17:30:49 +0700 Subject: [PATCH] feat: add shopify-development skill - Add comprehensive Shopify development skill with validated GraphQL - Fixed 4 mutations using Shopify MCP (fulfillmentCreate, appSubscription, etc.) - Added shopify_graphql.py utilities with pagination & rate limiting - Updated API version to 2026-01 - Added zircote/.claude as reference source --- README.md | 3 +- skills/shopify-development/README.md | 60 ++ skills/shopify-development/SKILL.md | 366 +++++++++++ .../references/app-development.md | 578 ++++++++++++++++++ .../references/extensions.md | 555 +++++++++++++++++ .../shopify-development/references/themes.md | 498 +++++++++++++++ skills/shopify-development/scripts/.gitignore | 49 ++ .../scripts/requirements.txt | 19 + .../scripts/shopify_graphql.py | 428 +++++++++++++ .../scripts/shopify_init.py | 441 +++++++++++++ .../scripts/tests/test_shopify_init.py | 379 ++++++++++++ 11 files changed, 3375 insertions(+), 1 deletion(-) create mode 100644 skills/shopify-development/README.md create mode 100644 skills/shopify-development/SKILL.md create mode 100644 skills/shopify-development/references/app-development.md create mode 100644 skills/shopify-development/references/extensions.md create mode 100644 skills/shopify-development/references/themes.md create mode 100644 skills/shopify-development/scripts/.gitignore create mode 100644 skills/shopify-development/scripts/requirements.txt create mode 100644 skills/shopify-development/scripts/shopify_graphql.py create mode 100644 skills/shopify-development/scripts/shopify_init.py create mode 100644 skills/shopify-development/scripts/tests/test_shopify_init.py diff --git a/README.md b/README.md index efde533b..21aa52fe 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ This collection would not be possible without the incredible work of the Claude - **[zebbern/claude-code-guide](https://github.com/zebbern/claude-code-guide)**: Comprehensive Security suite & Guide (Source for ~60 new skills). - **[alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills)**: Senior Engineering and PM toolkit. - **[karanb192/awesome-claude-skills](https://github.com/karanb192/awesome-claude-skills)**: A massive list of verified skills for Claude Code. +- **[zircote/.claude](https://github.com/zircote/.claude)**: Shopify development skill reference. ### Inspirations @@ -270,6 +271,6 @@ For repository maintainers, add these topics to maximize discoverability: claude-code, gemini-cli, codex-cli, antigravity, cursor, github-copilot, opencode, agentic-skills, ai-coding, llm-tools, ai-agents, autonomous-coding, mcp, ai-developer-tools, ai-pair-programming, vibe-coding, skill, skills, SKILL.md, rules.md, CLAUDE.md, GEMINI.md, CURSOR.md -claude-code, gemini-cli, codex-cli, antigravity, cursor, github-copilot, opencode, +claude-code, gemini-cli, codex-cli, antigravity, cursor, github-copilot, opencode, agentic-skills, ai-coding, llm-tools, ai-agents, autonomous-coding, mcp ``` diff --git a/skills/shopify-development/README.md b/skills/shopify-development/README.md new file mode 100644 index 00000000..998f63cb --- /dev/null +++ b/skills/shopify-development/README.md @@ -0,0 +1,60 @@ +# Shopify Development Skill + +Comprehensive skill for building on Shopify platform: apps, extensions, themes, and API integrations. + +## Features + +- **App Development** - OAuth authentication, GraphQL Admin API, webhooks, billing integration +- **UI Extensions** - Checkout, Admin, POS customizations with Polaris components +- **Theme Development** - Liquid templating, sections, snippets +- **Shopify Functions** - Custom discounts, payment, delivery rules + +## Structure + +``` +shopify-development/ +├── SKILL.md # Main skill file (AI-optimized) +├── README.md # This file +├── references/ +│ ├── app-development.md # OAuth, API, webhooks, billing +│ ├── extensions.md # UI extensions, Functions +│ └── themes.md # Liquid, theme architecture +└── scripts/ + ├── shopify_init.py # Interactive project scaffolding + ├── shopify_graphql.py # GraphQL utilities & templates + └── tests/ # Unit tests +``` + +## Validated GraphQL + +All GraphQL queries and mutations in this skill have been validated against Shopify Admin API 2026-01 schema using the official Shopify MCP. + +## Quick Start + +```bash +# Install Shopify CLI +npm install -g @shopify/cli@latest + +# Create new app +shopify app init + +# Start development +shopify app dev +``` + +## Usage Triggers + +This skill activates when the user mentions: + +- "shopify app", "shopify extension", "shopify theme" +- "checkout extension", "admin extension", "POS extension" +- "liquid template", "polaris", "shopify graphql" +- "shopify webhook", "shopify billing", "metafields" + +## API Version + +Current: **2026-01** (Quarterly releases with 12-month support) + +## License + +MIT diff --git a/skills/shopify-development/SKILL.md b/skills/shopify-development/SKILL.md new file mode 100644 index 00000000..ea89ab10 --- /dev/null +++ b/skills/shopify-development/SKILL.md @@ -0,0 +1,366 @@ +--- +name: shopify-development +description: | + Build Shopify apps, extensions, themes using GraphQL Admin API, Shopify CLI, Polaris UI, and Liquid. + TRIGGER: "shopify", "shopify app", "checkout extension", "admin extension", "POS extension", + "shopify theme", "liquid template", "polaris", "shopify graphql", "shopify webhook", + "shopify billing", "app subscription", "metafields", "shopify functions" +--- + +# Shopify Development Skill + +Use this skill when the user asks about: + +- Building Shopify apps or extensions +- Creating checkout/admin/POS UI customizations +- Developing themes with Liquid templating +- Integrating with Shopify GraphQL or REST APIs +- Implementing webhooks or billing +- Working with metafields or Shopify Functions + +--- + +## ROUTING: What to Build + +**IF user wants to integrate external services OR build merchant tools OR charge for features:** +→ Build an **App** (see `references/app-development.md`) + +**IF user wants to customize checkout OR add admin UI OR create POS actions OR implement discount rules:** +→ Build an **Extension** (see `references/extensions.md`) + +**IF user wants to customize storefront design OR modify product/collection pages:** +→ Build a **Theme** (see `references/themes.md`) + +**IF user needs both backend logic AND storefront UI:** +→ Build **App + Theme Extension** combination + +--- + +## Shopify CLI Commands + +Install CLI: + +```bash +npm install -g @shopify/cli@latest +``` + +Create and run app: + +```bash +shopify app init # Create new app +shopify app dev # Start dev server with tunnel +shopify app deploy # Build and upload to Shopify +``` + +Generate extension: + +```bash +shopify app generate extension --type checkout_ui_extension +shopify app generate extension --type admin_action +shopify app generate extension --type admin_block +shopify app generate extension --type pos_ui_extension +shopify app generate extension --type function +``` + +Theme development: + +```bash +shopify theme init # Create new theme +shopify theme dev # Start local preview at localhost:9292 +shopify theme pull --live # Pull live theme +shopify theme push --development # Push to dev theme +``` + +--- + +## Access Scopes + +Configure in `shopify.app.toml`: + +```toml +[access_scopes] +scopes = "read_products,write_products,read_orders,write_orders,read_customers" +``` + +Common scopes: + +- `read_products`, `write_products` - Product catalog access +- `read_orders`, `write_orders` - Order management +- `read_customers`, `write_customers` - Customer data +- `read_inventory`, `write_inventory` - Stock levels +- `read_fulfillments`, `write_fulfillments` - Order fulfillment + +--- + +## GraphQL Patterns (Validated against API 2026-01) + +### Query Products + +```graphql +query GetProducts($first: Int!, $query: String) { + products(first: $first, query: $query) { + edges { + node { + id + title + handle + status + variants(first: 5) { + edges { + node { + id + price + inventoryQuantity + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +### Query Orders + +```graphql +query GetOrders($first: Int!) { + orders(first: $first) { + edges { + node { + id + name + createdAt + displayFinancialStatus + totalPriceSet { + shopMoney { + amount + currencyCode + } + } + } + } + } +} +``` + +### Set Metafields + +```graphql +mutation SetMetafields($metafields: [MetafieldsSetInput!]!) { + metafieldsSet(metafields: $metafields) { + metafields { + id + namespace + key + value + } + userErrors { + field + message + } + } +} +``` + +Variables example: + +```json +{ + "metafields": [ + { + "ownerId": "gid://shopify/Product/123", + "namespace": "custom", + "key": "care_instructions", + "value": "Handle with care", + "type": "single_line_text_field" + } + ] +} +``` + +--- + +## Checkout Extension Example + +```tsx +import { + reactExtension, + BlockStack, + TextField, + Checkbox, + useApplyAttributeChange, +} from "@shopify/ui-extensions-react/checkout"; + +export default reactExtension("purchase.checkout.block.render", () => ( + +)); + +function GiftMessage() { + const [isGift, setIsGift] = useState(false); + const [message, setMessage] = useState(""); + const applyAttributeChange = useApplyAttributeChange(); + + useEffect(() => { + if (isGift && message) { + applyAttributeChange({ + type: "updateAttribute", + key: "gift_message", + value: message, + }); + } + }, [isGift, message]); + + return ( + + + This is a gift + + {isGift && ( + + )} + + ); +} +``` + +--- + +## Liquid Template Example + +```liquid +{% comment %} Product Card Snippet {% endcomment %} + +``` + +--- + +## Webhook Configuration + +In `shopify.app.toml`: + +```toml +[webhooks] +api_version = "2026-01" + +[[webhooks.subscriptions]] +topics = ["orders/create", "orders/updated"] +uri = "/webhooks/orders" + +[[webhooks.subscriptions]] +topics = ["products/update"] +uri = "/webhooks/products" + +# GDPR mandatory webhooks (required for app approval) +[webhooks.privacy_compliance] +customer_data_request_url = "/webhooks/gdpr/data-request" +customer_deletion_url = "/webhooks/gdpr/customer-deletion" +shop_deletion_url = "/webhooks/gdpr/shop-deletion" +``` + +--- + +## Best Practices + +### API Usage + +- Use GraphQL over REST for new development +- Request only fields you need (reduces query cost) +- Implement cursor-based pagination with `pageInfo.endCursor` +- Use bulk operations for processing more than 250 items +- Handle rate limits with exponential backoff + +### Security + +- Store API credentials in environment variables +- Always verify webhook HMAC signatures before processing +- Validate OAuth state parameter to prevent CSRF +- Request minimal access scopes +- Use session tokens for embedded apps + +### Performance + +- Cache API responses when data doesn't change frequently +- Use lazy loading in extensions +- Optimize images in themes using `img_url` filter +- Monitor GraphQL query costs via response headers + +--- + +## Troubleshooting + +**IF you see rate limit errors:** +→ Implement exponential backoff retry logic +→ Switch to bulk operations for large datasets +→ Monitor `X-Shopify-Shop-Api-Call-Limit` header + +**IF authentication fails:** +→ Verify the access token is still valid +→ Check that all required scopes were granted +→ Ensure OAuth flow completed successfully + +**IF extension is not appearing:** +→ Verify the extension target is correct +→ Check that extension is published via `shopify app deploy` +→ Confirm the app is installed on the test store + +**IF webhook is not receiving events:** +→ Verify the webhook URL is publicly accessible +→ Check HMAC signature validation logic +→ Review webhook logs in Partner Dashboard + +**IF GraphQL query fails:** +→ Validate query against schema (use GraphiQL explorer) +→ Check for deprecated fields in error message +→ Verify you have required access scopes + +--- + +## Reference Files + +For detailed implementation guides, read these files: + +- `references/app-development.md` - OAuth authentication flow, GraphQL mutations for products/orders/billing, webhook handlers, billing API integration +- `references/extensions.md` - Checkout UI components, Admin UI extensions, POS extensions, Shopify Functions for discounts/payment/delivery +- `references/themes.md` - Liquid syntax reference, theme directory structure, sections and snippets, common patterns + +--- + +## Scripts + +- `scripts/shopify_init.py` - Interactive project scaffolding. Run: `python scripts/shopify_init.py` +- `scripts/shopify_graphql.py` - GraphQL utilities with query templates, pagination, rate limiting. Import: `from shopify_graphql import ShopifyGraphQL` + +--- + +## Official Documentation Links + +- Shopify Developer Docs: https://shopify.dev/docs +- GraphQL Admin API Reference: https://shopify.dev/docs/api/admin-graphql +- Shopify CLI Reference: https://shopify.dev/docs/api/shopify-cli +- Polaris Design System: https://polaris.shopify.com + +API Version: 2026-01 (quarterly releases, 12-month deprecation window) diff --git a/skills/shopify-development/references/app-development.md b/skills/shopify-development/references/app-development.md new file mode 100644 index 00000000..d1134c81 --- /dev/null +++ b/skills/shopify-development/references/app-development.md @@ -0,0 +1,578 @@ +# App Development Reference + +Guide for building Shopify apps with OAuth, GraphQL/REST APIs, webhooks, and billing. + +## OAuth Authentication + +### OAuth 2.0 Flow + +**1. Redirect to Authorization URL:** + +``` +https://{shop}.myshopify.com/admin/oauth/authorize? + client_id={api_key}& + scope={scopes}& + redirect_uri={redirect_uri}& + state={nonce} +``` + +**2. Handle Callback:** + +```javascript +app.get("/auth/callback", async (req, res) => { + const { code, shop, state } = req.query; + + // Verify state to prevent CSRF + if (state !== storedState) { + return res.status(403).send("Invalid state"); + } + + // Exchange code for access token + const accessToken = await exchangeCodeForToken(shop, code); + + // Store token securely + await storeAccessToken(shop, accessToken); + + res.redirect(`https://${shop}/admin/apps/${appHandle}`); +}); +``` + +**3. Exchange Code for Token:** + +```javascript +async function exchangeCodeForToken(shop, code) { + const response = await fetch(`https://${shop}/admin/oauth/access_token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: process.env.SHOPIFY_API_KEY, + client_secret: process.env.SHOPIFY_API_SECRET, + code, + }), + }); + + const { access_token } = await response.json(); + return access_token; +} +``` + +### Access Scopes + +**Common Scopes:** + +- `read_products`, `write_products` - Product catalog +- `read_orders`, `write_orders` - Order management +- `read_customers`, `write_customers` - Customer data +- `read_inventory`, `write_inventory` - Stock levels +- `read_fulfillments`, `write_fulfillments` - Order fulfillment +- `read_shipping`, `write_shipping` - Shipping rates +- `read_analytics` - Store analytics +- `read_checkouts`, `write_checkouts` - Checkout data + +Full list: https://shopify.dev/api/usage/access-scopes + +### Session Tokens (Embedded Apps) + +For embedded apps using App Bridge: + +```javascript +import { getSessionToken } from '@shopify/app-bridge/utilities'; + +async function authenticatedFetch(url, options = {}) { + const app = createApp({ ... }); + const token = await getSessionToken(app); + + return fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${token}` + } + }); +} +``` + +## GraphQL Admin API + +### Making Requests + +```javascript +async function graphqlRequest(shop, accessToken, query, variables = {}) { + const response = await fetch( + `https://${shop}/admin/api/2026-01/graphql.json`, + { + method: "POST", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }, + ); + + const data = await response.json(); + + if (data.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`); + } + + return data.data; +} +``` + +### Product Operations + +**Create Product:** + +```graphql +mutation CreateProduct($input: ProductInput!) { + productCreate(input: $input) { + product { + id + title + handle + } + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "input": { + "title": "New Product", + "productType": "Apparel", + "vendor": "Brand", + "status": "ACTIVE", + "variants": [ + { "price": "29.99", "sku": "SKU-001", "inventoryQuantity": 100 } + ] + } +} +``` + +**Update Product:** + +```graphql +mutation UpdateProduct($input: ProductInput!) { + productUpdate(input: $input) { + product { + id + title + } + userErrors { + field + message + } + } +} +``` + +**Query Products:** + +```graphql +query GetProducts($first: Int!, $query: String) { + products(first: $first, query: $query) { + edges { + node { + id + title + status + variants(first: 5) { + edges { + node { + id + price + inventoryQuantity + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +### Order Operations + +**Query Orders:** + +```graphql +query GetOrders($first: Int!) { + orders(first: $first) { + edges { + node { + id + name + createdAt + displayFinancialStatus + totalPriceSet { + shopMoney { + amount + currencyCode + } + } + customer { + email + firstName + lastName + } + } + } + } +} +``` + +**Fulfill Order:** + +```graphql +mutation FulfillOrder($fulfillment: FulfillmentInput!) { + fulfillmentCreate(fulfillment: $fulfillment) { + fulfillment { + id + status + trackingInfo { + number + url + } + } + userErrors { + field + message + } + } +} +``` + +## Webhooks + +### Configuration + +In `shopify.app.toml`: + +```toml +[webhooks] +api_version = "2025-01" + +[[webhooks.subscriptions]] +topics = ["orders/create"] +uri = "/webhooks/orders/create" + +[[webhooks.subscriptions]] +topics = ["products/update"] +uri = "/webhooks/products/update" + +[[webhooks.subscriptions]] +topics = ["app/uninstalled"] +uri = "/webhooks/app/uninstalled" + +# GDPR mandatory webhooks +[webhooks.privacy_compliance] +customer_data_request_url = "/webhooks/gdpr/data-request" +customer_deletion_url = "/webhooks/gdpr/customer-deletion" +shop_deletion_url = "/webhooks/gdpr/shop-deletion" +``` + +### Webhook Handler + +```javascript +import crypto from "crypto"; + +function verifyWebhook(req) { + const hmac = req.headers["x-shopify-hmac-sha256"]; + const body = req.rawBody; // Raw body buffer + + const hash = crypto + .createHmac("sha256", process.env.SHOPIFY_API_SECRET) + .update(body, "utf8") + .digest("base64"); + + return hmac === hash; +} + +app.post("/webhooks/orders/create", async (req, res) => { + if (!verifyWebhook(req)) { + return res.status(401).send("Unauthorized"); + } + + const order = req.body; + console.log("New order:", order.id, order.name); + + // Process order... + + res.status(200).send("OK"); +}); +``` + +### Common Webhook Topics + +**Orders:** + +- `orders/create`, `orders/updated`, `orders/delete` +- `orders/paid`, `orders/cancelled`, `orders/fulfilled` + +**Products:** + +- `products/create`, `products/update`, `products/delete` + +**Customers:** + +- `customers/create`, `customers/update`, `customers/delete` + +**Inventory:** + +- `inventory_levels/update` + +**App:** + +- `app/uninstalled` (critical for cleanup) + +## Billing Integration + +### App Charges + +**One-time Charge:** + +```graphql +mutation CreateCharge($input: AppPurchaseOneTimeInput!) { + appPurchaseOneTimeCreate(input: $input) { + appPurchaseOneTime { + id + name + price { + amount + } + status + confirmationUrl + } + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "input": { + "name": "Premium Feature", + "price": { "amount": 49.99, "currencyCode": "USD" }, + "returnUrl": "https://your-app.com/billing/callback" + } +} +``` + +**Recurring Charge (Subscription):** + +```graphql +mutation CreateSubscription( + $name: String! + $returnUrl: URL! + $lineItems: [AppSubscriptionLineItemInput!]! + $trialDays: Int +) { + appSubscriptionCreate( + name: $name + returnUrl: $returnUrl + lineItems: $lineItems + trialDays: $trialDays + ) { + appSubscription { + id + name + status + } + confirmationUrl + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "name": "Monthly Subscription", + "returnUrl": "https://your-app.com/billing/callback", + "trialDays": 7, + "lineItems": [ + { + "plan": { + "appRecurringPricingDetails": { + "price": { "amount": 29.99, "currencyCode": "USD" }, + "interval": "EVERY_30_DAYS" + } + } + } + ] +} +``` + +**Usage-based Billing:** + +```graphql +mutation CreateUsageCharge( + $subscriptionLineItemId: ID! + $price: MoneyInput! + $description: String! +) { + appUsageRecordCreate( + subscriptionLineItemId: $subscriptionLineItemId + price: $price + description: $description + ) { + appUsageRecord { + id + price { + amount + currencyCode + } + description + } + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "subscriptionLineItemId": "gid://shopify/AppSubscriptionLineItem/123", + "price": { "amount": "5.00", "currencyCode": "USD" }, + "description": "100 API calls used" +} +``` + +## Metafields + +### Create/Update Metafields + +```graphql +mutation SetMetafields($metafields: [MetafieldsSetInput!]!) { + metafieldsSet(metafields: $metafields) { + metafields { + id + namespace + key + value + } + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "metafields": [ + { + "ownerId": "gid://shopify/Product/123", + "namespace": "custom", + "key": "instructions", + "value": "Handle with care", + "type": "single_line_text_field" + } + ] +} +``` + +**Metafield Types:** + +- `single_line_text_field`, `multi_line_text_field` +- `number_integer`, `number_decimal` +- `date`, `date_time` +- `url`, `json` +- `file_reference`, `product_reference` + +## Rate Limiting + +### GraphQL Cost-Based Limits + +**Limits:** + +- Available points: 2000 +- Restore rate: 100 points/second +- Max query cost: 2000 + +**Check Cost:** + +```javascript +const response = await graphqlRequest(shop, token, query); +const cost = response.extensions?.cost; + +console.log( + `Cost: ${cost.actualQueryCost}/${cost.throttleStatus.maximumAvailable}`, +); +``` + +**Handle Throttling:** + +```javascript +async function graphqlWithRetry(shop, token, query, retries = 3) { + for (let i = 0; i < retries; i++) { + try { + return await graphqlRequest(shop, token, query); + } catch (error) { + if (error.message.includes("Throttled") && i < retries - 1) { + await sleep(Math.pow(2, i) * 1000); // Exponential backoff + continue; + } + throw error; + } + } +} +``` + +## Best Practices + +**Security:** + +- Store credentials in environment variables +- Verify webhook HMAC signatures +- Validate OAuth state parameter +- Use HTTPS for all endpoints +- Implement rate limiting on your endpoints + +**Performance:** + +- Cache access tokens securely +- Use bulk operations for large datasets +- Implement pagination for queries +- Monitor GraphQL query costs + +**Reliability:** + +- Implement exponential backoff for retries +- Handle webhook delivery failures +- Log errors for debugging +- Monitor app health metrics + +**Compliance:** + +- Implement GDPR webhooks (mandatory) +- Handle customer data deletion requests +- Provide data export functionality +- Follow data retention policies diff --git a/skills/shopify-development/references/extensions.md b/skills/shopify-development/references/extensions.md new file mode 100644 index 00000000..8d42267f --- /dev/null +++ b/skills/shopify-development/references/extensions.md @@ -0,0 +1,555 @@ +# Extensions Reference + +Guide for building UI extensions and Shopify Functions. + +## Checkout UI Extensions + +Customize checkout and thank-you pages with native-rendered components. + +### Extension Points + +**Block Targets (Merchant-Configurable):** + +- `purchase.checkout.block.render` - Main checkout +- `purchase.thank-you.block.render` - Thank you page + +**Static Targets (Fixed Position):** + +- `purchase.checkout.header.render-after` +- `purchase.checkout.contact.render-before` +- `purchase.checkout.shipping-option-list.render-after` +- `purchase.checkout.payment-method-list.render-after` +- `purchase.checkout.footer.render-before` + +### Setup + +```bash +shopify app generate extension --type checkout_ui_extension +``` + +Configuration (`shopify.extension.toml`): + +```toml +api_version = "2026-01" +name = "gift-message" +type = "ui_extension" + +[[extensions.targeting]] +target = "purchase.checkout.block.render" + +[capabilities] +network_access = true +api_access = true +``` + +### Basic Example + +```javascript +import { + reactExtension, + BlockStack, + TextField, + Checkbox, + useApi, +} from "@shopify/ui-extensions-react/checkout"; + +export default reactExtension("purchase.checkout.block.render", () => ( + +)); + +function Extension() { + const [message, setMessage] = useState(""); + const [isGift, setIsGift] = useState(false); + const { applyAttributeChange } = useApi(); + + useEffect(() => { + if (isGift) { + applyAttributeChange({ + type: "updateAttribute", + key: "gift_message", + value: message, + }); + } + }, [message, isGift]); + + return ( + + + This is a gift + + {isGift && ( + + )} + + ); +} +``` + +### Common Hooks + +**useApi:** + +```javascript +const { extensionPoint, shop, storefront, i18n, sessionToken } = useApi(); +``` + +**useCartLines:** + +```javascript +const lines = useCartLines(); +lines.forEach((line) => { + console.log(line.merchandise.product.title, line.quantity); +}); +``` + +**useShippingAddress:** + +```javascript +const address = useShippingAddress(); +console.log(address.city, address.countryCode); +``` + +**useApplyCartLinesChange:** + +```javascript +const applyChange = useApplyCartLinesChange(); + +async function addItem() { + await applyChange({ + type: "addCartLine", + merchandiseId: "gid://shopify/ProductVariant/123", + quantity: 1, + }); +} +``` + +### Core Components + +**Layout:** + +- `BlockStack` - Vertical stacking +- `InlineStack` - Horizontal layout +- `Grid`, `GridItem` - Grid layout +- `View` - Container +- `Divider` - Separator + +**Input:** + +- `TextField` - Text input +- `Checkbox` - Boolean +- `Select` - Dropdown +- `DatePicker` - Date selection +- `Form` - Form wrapper + +**Display:** + +- `Text`, `Heading` - Typography +- `Banner` - Messages +- `Badge` - Status +- `Image` - Images +- `Link` - Hyperlinks +- `List`, `ListItem` - Lists + +**Interactive:** + +- `Button` - Actions +- `Modal` - Overlays +- `Pressable` - Click areas + +## Admin UI Extensions + +Extend Shopify admin interface. + +### Admin Action + +Custom actions on resource pages. + +```bash +shopify app generate extension --type admin_action +``` + +```javascript +import { + reactExtension, + AdminAction, + Button, +} from "@shopify/ui-extensions-react/admin"; + +export default reactExtension("admin.product-details.action.render", () => ( + +)); + +function Extension() { + const { data } = useData(); + + async function handleExport() { + const response = await fetch("/api/export", { + method: "POST", + body: JSON.stringify({ productId: data.product.id }), + }); + console.log("Exported:", await response.json()); + } + + return ( + Export} + /> + ); +} +``` + +**Targets:** + +- `admin.product-details.action.render` +- `admin.order-details.action.render` +- `admin.customer-details.action.render` + +### Admin Block + +Embedded content in admin pages. + +```javascript +import { + reactExtension, + BlockStack, + Text, + Badge, +} from "@shopify/ui-extensions-react/admin"; + +export default reactExtension("admin.product-details.block.render", () => ( + +)); + +function Extension() { + const { data } = useData(); + const [analytics, setAnalytics] = useState(null); + + useEffect(() => { + fetchAnalytics(data.product.id).then(setAnalytics); + }, []); + + return ( + + Product Analytics + Views: {analytics?.views || 0} + Conversions: {analytics?.conversions || 0} + + {analytics?.trending ? "Trending" : "Normal"} + + + ); +} +``` + +**Targets:** + +- `admin.product-details.block.render` +- `admin.order-details.block.render` +- `admin.customer-details.block.render` + +## POS UI Extensions + +Customize Point of Sale experience. + +### Smart Grid Tile + +Quick access action on POS home screen. + +```javascript +import { + reactExtension, + SmartGridTile, +} from "@shopify/ui-extensions-react/pos"; + +export default reactExtension("pos.home.tile.render", () => ); + +function Extension() { + function handlePress() { + // Navigate to custom workflow + } + + return ( + + ); +} +``` + +### POS Modal + +Full-screen workflow. + +```javascript +import { + reactExtension, + Screen, + BlockStack, + Button, + TextField, +} from "@shopify/ui-extensions-react/pos"; + +export default reactExtension("pos.home.modal.render", () => ); + +function Extension() { + const { navigation } = useApi(); + const [amount, setAmount] = useState(""); + + function handleIssue() { + // Issue gift card + navigation.pop(); + } + + return ( + + + + + + + + ); +} +``` + +## Customer Account Extensions + +Customize customer account pages. + +### Order Status Extension + +```javascript +import { + reactExtension, + BlockStack, + Text, + Button, +} from "@shopify/ui-extensions-react/customer-account"; + +export default reactExtension( + "customer-account.order-status.block.render", + () => , +); + +function Extension() { + const { order } = useApi(); + + function handleReturn() { + // Initiate return + } + + return ( + + Need to return? + Start return for order {order.name} + + + ); +} +``` + +**Targets:** + +- `customer-account.order-status.block.render` +- `customer-account.order-index.block.render` +- `customer-account.profile.block.render` + +## Shopify Functions + +Serverless backend customization. + +### Function Types + +**Discounts:** + +- `order_discount` - Order-level discounts +- `product_discount` - Product-specific discounts +- `shipping_discount` - Shipping discounts + +**Payment Customization:** + +- Hide/rename/reorder payment methods + +**Delivery Customization:** + +- Custom shipping options +- Delivery rules + +**Validation:** + +- Cart validation rules +- Checkout validation + +### Create Function + +```bash +shopify app generate extension --type function +``` + +### Order Discount Function + +```javascript +// input.graphql +query Input { + cart { + lines { + quantity + merchandise { + ... on ProductVariant { + product { + hasTag(tag: "bulk-discount") + } + } + } + } + } +} + +// function.js +export default function orderDiscount(input) { + const targets = input.cart.lines + .filter(line => line.merchandise.product.hasTag) + .map(line => ({ + productVariant: { id: line.merchandise.id } + })); + + if (targets.length === 0) { + return { discounts: [] }; + } + + return { + discounts: [{ + targets, + value: { + percentage: { + value: 10 // 10% discount + } + } + }] + }; +} +``` + +### Payment Customization Function + +```javascript +export default function paymentCustomization(input) { + const hidePaymentMethods = input.cart.lines.some( + (line) => line.merchandise.product.hasTag, + ); + + if (!hidePaymentMethods) { + return { operations: [] }; + } + + return { + operations: [ + { + hide: { + paymentMethodId: "gid://shopify/PaymentMethod/123", + }, + }, + ], + }; +} +``` + +### Validation Function + +```javascript +export default function cartValidation(input) { + const errors = []; + + // Max 5 items per cart + if (input.cart.lines.length > 5) { + errors.push({ + localizedMessage: "Maximum 5 items allowed per order", + target: "cart", + }); + } + + // Min $50 for wholesale + const isWholesale = input.cart.lines.some( + (line) => line.merchandise.product.hasTag, + ); + + if (isWholesale && input.cart.cost.totalAmount.amount < 50) { + errors.push({ + localizedMessage: "Wholesale orders require $50 minimum", + target: "cart", + }); + } + + return { errors }; +} +``` + +## Network Requests + +Extensions can call external APIs. + +```javascript +import { useApi } from "@shopify/ui-extensions-react/checkout"; + +function Extension() { + const { sessionToken } = useApi(); + + async function fetchData() { + const token = await sessionToken.get(); + + const response = await fetch("https://your-app.com/api/data", { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + return await response.json(); + } +} +``` + +## Best Practices + +**Performance:** + +- Lazy load data +- Memoize expensive computations +- Use loading states +- Minimize re-renders + +**UX:** + +- Provide clear error messages +- Show loading indicators +- Validate inputs +- Support keyboard navigation + +**Security:** + +- Verify session tokens on backend +- Sanitize user input +- Use HTTPS for all requests +- Don't expose sensitive data + +**Testing:** + +- Test on development stores +- Verify mobile/desktop +- Check accessibility +- Test edge cases + +## Resources + +- Checkout Extensions: https://shopify.dev/docs/api/checkout-extensions +- Admin Extensions: https://shopify.dev/docs/apps/admin/extensions +- Functions: https://shopify.dev/docs/apps/functions +- Components: https://shopify.dev/docs/api/checkout-ui-extensions/components diff --git a/skills/shopify-development/references/themes.md b/skills/shopify-development/references/themes.md new file mode 100644 index 00000000..2fc1c2b9 --- /dev/null +++ b/skills/shopify-development/references/themes.md @@ -0,0 +1,498 @@ +# Themes Reference + +Guide for developing Shopify themes with Liquid templating. + +## Liquid Templating + +### Syntax Basics + +**Objects (Output):** +```liquid +{{ product.title }} +{{ product.price | money }} +{{ customer.email }} +``` + +**Tags (Logic):** +```liquid +{% if product.available %} + +{% else %} +

Sold Out

+{% endif %} + +{% for product in collection.products %} + {{ product.title }} +{% endfor %} + +{% case product.type %} + {% when 'Clothing' %} + Apparel + {% when 'Shoes' %} + Footwear + {% else %} + Other +{% endcase %} +``` + +**Filters (Transform):** +```liquid +{{ product.title | upcase }} +{{ product.price | money }} +{{ product.description | strip_html | truncate: 100 }} +{{ product.image | img_url: 'medium' }} +{{ 'now' | date: '%B %d, %Y' }} +``` + +### Common Objects + +**Product:** +```liquid +{{ product.id }} +{{ product.title }} +{{ product.handle }} +{{ product.description }} +{{ product.price }} +{{ product.compare_at_price }} +{{ product.available }} +{{ product.type }} +{{ product.vendor }} +{{ product.tags }} +{{ product.images }} +{{ product.variants }} +{{ product.featured_image }} +{{ product.url }} +``` + +**Collection:** +```liquid +{{ collection.title }} +{{ collection.handle }} +{{ collection.description }} +{{ collection.products }} +{{ collection.products_count }} +{{ collection.image }} +{{ collection.url }} +``` + +**Cart:** +```liquid +{{ cart.item_count }} +{{ cart.total_price }} +{{ cart.items }} +{{ cart.note }} +{{ cart.attributes }} +``` + +**Customer:** +```liquid +{{ customer.email }} +{{ customer.first_name }} +{{ customer.last_name }} +{{ customer.orders_count }} +{{ customer.total_spent }} +{{ customer.addresses }} +{{ customer.default_address }} +``` + +**Shop:** +```liquid +{{ shop.name }} +{{ shop.email }} +{{ shop.domain }} +{{ shop.currency }} +{{ shop.money_format }} +{{ shop.enabled_payment_types }} +``` + +### Common Filters + +**String:** +- `upcase`, `downcase`, `capitalize` +- `strip_html`, `strip_newlines` +- `truncate: 100`, `truncatewords: 20` +- `replace: 'old', 'new'` + +**Number:** +- `money` - Format currency +- `round`, `ceil`, `floor` +- `times`, `divided_by`, `plus`, `minus` + +**Array:** +- `join: ', '` +- `first`, `last` +- `size` +- `map: 'property'` +- `where: 'property', 'value'` + +**URL:** +- `img_url: 'size'` - Image URL +- `url_for_type`, `url_for_vendor` +- `link_to`, `link_to_type` + +**Date:** +- `date: '%B %d, %Y'` + +## Theme Architecture + +### Directory Structure + +``` +theme/ +├── assets/ # CSS, JS, images +├── config/ # Theme settings +│ ├── settings_schema.json +│ └── settings_data.json +├── layout/ # Base templates +│ └── theme.liquid +├── locales/ # Translations +│ └── en.default.json +├── sections/ # Reusable blocks +│ ├── header.liquid +│ ├── footer.liquid +│ └── product-grid.liquid +├── snippets/ # Small components +│ ├── product-card.liquid +│ └── icon.liquid +└── templates/ # Page templates + ├── index.json + ├── product.json + ├── collection.json + └── cart.liquid +``` + +### Layout + +Base template wrapping all pages (`layout/theme.liquid`): + +```liquid + + + + + + {{ page_title }} + + {{ content_for_header }} + + + + + {% section 'header' %} + +
+ {{ content_for_layout }} +
+ + {% section 'footer' %} + + + + +``` + +### Templates + +Page-specific structures (`templates/product.json`): + +```json +{ + "sections": { + "main": { + "type": "product-template", + "settings": { + "show_vendor": true, + "show_quantity_selector": true + } + }, + "recommendations": { + "type": "product-recommendations" + } + }, + "order": ["main", "recommendations"] +} +``` + +Legacy format (`templates/product.liquid`): +```liquid +
+
+ {{ product.title }} +
+ +
+

{{ product.title }}

+

{{ product.price | money }}

+ + {% form 'product', product %} + + + + {% endform %} +
+
+``` + +### Sections + +Reusable content blocks (`sections/product-grid.liquid`): + +```liquid +
+ {% for product in section.settings.collection.products %} + + {% endfor %} +
+ +{% schema %} +{ + "name": "Product Grid", + "settings": [ + { + "type": "collection", + "id": "collection", + "label": "Collection" + }, + { + "type": "range", + "id": "products_per_row", + "min": 2, + "max": 5, + "step": 1, + "default": 4, + "label": "Products per row" + } + ], + "presets": [ + { + "name": "Product Grid" + } + ] +} +{% endschema %} +``` + +### Snippets + +Small reusable components (`snippets/product-card.liquid`): + +```liquid + +``` + +Include snippet: +```liquid +{% render 'product-card', product: product %} +``` + +## Development Workflow + +### Setup + +```bash +# Initialize new theme +shopify theme init + +# Choose Dawn (reference theme) or blank +``` + +### Local Development + +```bash +# Start local server +shopify theme dev + +# Preview at http://localhost:9292 +# Changes auto-sync to development theme +``` + +### Pull Theme + +```bash +# Pull live theme +shopify theme pull --live + +# Pull specific theme +shopify theme pull --theme=123456789 + +# Pull only templates +shopify theme pull --only=templates +``` + +### Push Theme + +```bash +# Push to development theme +shopify theme push --development + +# Create new unpublished theme +shopify theme push --unpublished + +# Push specific files +shopify theme push --only=sections,snippets +``` + +### Theme Check + +Lint theme code: +```bash +shopify theme check +shopify theme check --auto-correct +``` + +## Common Patterns + +### Product Form with Variants + +```liquid +{% form 'product', product %} + {% unless product.has_only_default_variant %} + {% for option in product.options_with_values %} +
+ + +
+ {% endfor %} + {% endunless %} + + + + + +{% endform %} +``` + +### Pagination + +```liquid +{% paginate collection.products by 12 %} + {% for product in collection.products %} + {% render 'product-card', product: product %} + {% endfor %} + + {% if paginate.pages > 1 %} + + {% endif %} +{% endpaginate %} +``` + +### Cart AJAX + +```javascript +// Add to cart +fetch('/cart/add.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: variantId, + quantity: 1 + }) +}) +.then(res => res.json()) +.then(item => console.log('Added:', item)); + +// Get cart +fetch('/cart.js') + .then(res => res.json()) + .then(cart => console.log('Cart:', cart)); + +// Update cart +fetch('/cart/change.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: lineItemKey, + quantity: 2 + }) +}) +.then(res => res.json()); +``` + +## Metafields in Themes + +Access custom data: + +```liquid +{{ product.metafields.custom.care_instructions }} +{{ product.metafields.custom.material.value }} + +{% if product.metafields.custom.featured %} + Featured +{% endif %} +``` + +## Best Practices + +**Performance:** +- Optimize images (use appropriate sizes) +- Minimize Liquid logic complexity +- Use lazy loading for images +- Defer non-critical JavaScript + +**Accessibility:** +- Use semantic HTML +- Include alt text for images +- Support keyboard navigation +- Ensure sufficient color contrast + +**SEO:** +- Use descriptive page titles +- Include meta descriptions +- Structure content with headings +- Implement schema markup + +**Code Quality:** +- Follow Shopify theme guidelines +- Use consistent naming conventions +- Comment complex logic +- Keep sections focused and reusable + +## Resources + +- Theme Development: https://shopify.dev/docs/themes +- Liquid Reference: https://shopify.dev/docs/api/liquid +- Dawn Theme: https://github.com/Shopify/dawn +- Theme Check: https://shopify.dev/docs/themes/tools/theme-check diff --git a/skills/shopify-development/scripts/.gitignore b/skills/shopify-development/scripts/.gitignore new file mode 100644 index 00000000..8abb6f18 --- /dev/null +++ b/skills/shopify-development/scripts/.gitignore @@ -0,0 +1,49 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Testing +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover +*.py,cover + +# Environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/skills/shopify-development/scripts/requirements.txt b/skills/shopify-development/scripts/requirements.txt new file mode 100644 index 00000000..4613a2ba --- /dev/null +++ b/skills/shopify-development/scripts/requirements.txt @@ -0,0 +1,19 @@ +# Shopify Skill Dependencies +# Python 3.10+ required + +# No Python package dependencies - uses only standard library + +# Testing dependencies (dev) +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 + +# Note: This script requires the Shopify CLI tool +# Install Shopify CLI: +# npm install -g @shopify/cli @shopify/theme +# or via Homebrew (macOS): +# brew tap shopify/shopify +# brew install shopify-cli +# +# Authenticate with: +# shopify auth login diff --git a/skills/shopify-development/scripts/shopify_graphql.py b/skills/shopify-development/scripts/shopify_graphql.py new file mode 100644 index 00000000..ec8af3cb --- /dev/null +++ b/skills/shopify-development/scripts/shopify_graphql.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +""" +Shopify GraphQL Utilities + +Helper functions for common Shopify GraphQL operations. +Provides query templates, pagination helpers, and rate limit handling. + +Usage: + from shopify_graphql import ShopifyGraphQL + + client = ShopifyGraphQL(shop_domain, access_token) + products = client.get_products(first=10) +""" + +import os +import time +import json +from typing import Dict, List, Optional, Any, Generator +from dataclasses import dataclass +from urllib.request import Request, urlopen +from urllib.error import HTTPError + + +# API Configuration +API_VERSION = "2026-01" +MAX_RETRIES = 3 +RETRY_DELAY = 1.0 # seconds + + +@dataclass +class GraphQLResponse: + """Container for GraphQL response data.""" + data: Optional[Dict[str, Any]] = None + errors: Optional[List[Dict[str, Any]]] = None + extensions: Optional[Dict[str, Any]] = None + + @property + def is_success(self) -> bool: + return self.errors is None or len(self.errors) == 0 + + @property + def query_cost(self) -> Optional[int]: + """Get the actual query cost from extensions.""" + if self.extensions and 'cost' in self.extensions: + return self.extensions['cost'].get('actualQueryCost') + return None + + +class ShopifyGraphQL: + """ + Shopify GraphQL API client with built-in utilities. + + Features: + - Query templates for common operations + - Automatic pagination + - Rate limit handling with exponential backoff + - Response parsing helpers + """ + + def __init__(self, shop_domain: str, access_token: str): + """ + Initialize the GraphQL client. + + Args: + shop_domain: Store domain (e.g., 'my-store.myshopify.com') + access_token: Admin API access token + """ + self.shop_domain = shop_domain.replace('https://', '').replace('http://', '') + self.access_token = access_token + self.base_url = f"https://{self.shop_domain}/admin/api/{API_VERSION}/graphql.json" + + def execute(self, query: str, variables: Optional[Dict] = None) -> GraphQLResponse: + """ + Execute a GraphQL query/mutation. + + Args: + query: GraphQL query string + variables: Query variables + + Returns: + GraphQLResponse object + """ + payload = {"query": query} + if variables: + payload["variables"] = variables + + headers = { + "Content-Type": "application/json", + "X-Shopify-Access-Token": self.access_token + } + + for attempt in range(MAX_RETRIES): + try: + request = Request( + self.base_url, + data=json.dumps(payload).encode('utf-8'), + headers=headers, + method='POST' + ) + + with urlopen(request, timeout=30) as response: + result = json.loads(response.read().decode('utf-8')) + return GraphQLResponse( + data=result.get('data'), + errors=result.get('errors'), + extensions=result.get('extensions') + ) + + except HTTPError as e: + if e.code == 429: # Rate limited + delay = RETRY_DELAY * (2 ** attempt) + print(f"Rate limited. Retrying in {delay}s...") + time.sleep(delay) + continue + raise + except Exception as e: + if attempt == MAX_RETRIES - 1: + raise + time.sleep(RETRY_DELAY) + + return GraphQLResponse(errors=[{"message": "Max retries exceeded"}]) + + # ==================== Query Templates ==================== + + def get_products( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query products with pagination. + + Args: + first: Number of products to fetch (max 250) + query: Optional search query + after: Cursor for pagination + """ + gql = """ + query GetProducts($first: Int!, $query: String, $after: String) { + products(first: $first, query: $query, after: $after) { + edges { + node { + id + title + handle + status + totalInventory + variants(first: 5) { + edges { + node { + id + title + price + inventoryQuantity + sku + } + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def get_orders( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query orders with pagination. + + Args: + first: Number of orders to fetch (max 250) + query: Optional search query (e.g., "financial_status:paid") + after: Cursor for pagination + """ + gql = """ + query GetOrders($first: Int!, $query: String, $after: String) { + orders(first: $first, query: $query, after: $after) { + edges { + node { + id + name + createdAt + displayFinancialStatus + displayFulfillmentStatus + totalPriceSet { + shopMoney { amount currencyCode } + } + customer { + id + firstName + lastName + } + lineItems(first: 5) { + edges { + node { + title + quantity + } + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def get_customers( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query customers with pagination. + + Args: + first: Number of customers to fetch (max 250) + query: Optional search query + after: Cursor for pagination + """ + gql = """ + query GetCustomers($first: Int!, $query: String, $after: String) { + customers(first: $first, query: $query, after: $after) { + edges { + node { + id + firstName + lastName + displayName + defaultEmailAddress { + emailAddress + } + numberOfOrders + amountSpent { + amount + currencyCode + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def set_metafields(self, metafields: List[Dict]) -> GraphQLResponse: + """ + Set metafields on resources. + + Args: + metafields: List of metafield inputs, each containing: + - ownerId: Resource GID + - namespace: Metafield namespace + - key: Metafield key + - value: Metafield value + - type: Metafield type + """ + gql = """ + mutation SetMetafields($metafields: [MetafieldsSetInput!]!) { + metafieldsSet(metafields: $metafields) { + metafields { + id + namespace + key + value + } + userErrors { + field + message + } + } + } + """ + return self.execute(gql, {"metafields": metafields}) + + # ==================== Pagination Helpers ==================== + + def paginate_products( + self, + batch_size: int = 50, + query: Optional[str] = None + ) -> Generator[Dict, None, None]: + """ + Generator that yields all products with automatic pagination. + + Args: + batch_size: Products per request (max 250) + query: Optional search query + + Yields: + Product dictionaries + """ + cursor = None + while True: + response = self.get_products(first=batch_size, query=query, after=cursor) + + if not response.is_success or not response.data: + break + + products = response.data.get('products', {}) + edges = products.get('edges', []) + + for edge in edges: + yield edge['node'] + + page_info = products.get('pageInfo', {}) + if not page_info.get('hasNextPage'): + break + + cursor = page_info.get('endCursor') + + def paginate_orders( + self, + batch_size: int = 50, + query: Optional[str] = None + ) -> Generator[Dict, None, None]: + """ + Generator that yields all orders with automatic pagination. + + Args: + batch_size: Orders per request (max 250) + query: Optional search query + + Yields: + Order dictionaries + """ + cursor = None + while True: + response = self.get_orders(first=batch_size, query=query, after=cursor) + + if not response.is_success or not response.data: + break + + orders = response.data.get('orders', {}) + edges = orders.get('edges', []) + + for edge in edges: + yield edge['node'] + + page_info = orders.get('pageInfo', {}) + if not page_info.get('hasNextPage'): + break + + cursor = page_info.get('endCursor') + + +# ==================== Utility Functions ==================== + +def extract_id(gid: str) -> str: + """ + Extract numeric ID from Shopify GID. + + Args: + gid: Global ID (e.g., 'gid://shopify/Product/123') + + Returns: + Numeric ID string (e.g., '123') + """ + return gid.split('/')[-1] if gid else '' + + +def build_gid(resource_type: str, id: str) -> str: + """ + Build Shopify GID from resource type and ID. + + Args: + resource_type: Resource type (e.g., 'Product', 'Order') + id: Numeric ID + + Returns: + Global ID (e.g., 'gid://shopify/Product/123') + """ + return f"gid://shopify/{resource_type}/{id}" + + +# ==================== Example Usage ==================== + +def main(): + """Example usage of ShopifyGraphQL client.""" + import os + + # Load from environment + shop = os.environ.get('SHOP_DOMAIN', 'your-store.myshopify.com') + token = os.environ.get('SHOPIFY_ACCESS_TOKEN', '') + + if not token: + print("Set SHOPIFY_ACCESS_TOKEN environment variable") + return + + client = ShopifyGraphQL(shop, token) + + # Example: Get first 5 products + print("Fetching products...") + response = client.get_products(first=5) + + if response.is_success: + products = response.data['products']['edges'] + for edge in products: + product = edge['node'] + print(f" - {product['title']} ({product['status']})") + print(f"\nQuery cost: {response.query_cost}") + else: + print(f"Errors: {response.errors}") + + +if __name__ == '__main__': + main() diff --git a/skills/shopify-development/scripts/shopify_init.py b/skills/shopify-development/scripts/shopify_init.py new file mode 100644 index 00000000..f0c664e1 --- /dev/null +++ b/skills/shopify-development/scripts/shopify_init.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Shopify Project Initialization Script + +Interactive script to scaffold Shopify apps, extensions, or themes. +Supports environment variable loading from multiple locations. +""" + +import os +import sys +import json +import subprocess +from pathlib import Path +from typing import Dict, Optional, List +from dataclasses import dataclass + + +@dataclass +class EnvConfig: + """Environment configuration container.""" + shopify_api_key: Optional[str] = None + shopify_api_secret: Optional[str] = None + shop_domain: Optional[str] = None + scopes: Optional[str] = None + + +class EnvLoader: + """Load environment variables from multiple sources in priority order.""" + + @staticmethod + def load_env_file(filepath: Path) -> Dict[str, str]: + """ + Load environment variables from .env file. + + Args: + filepath: Path to .env file + + Returns: + Dictionary of environment variables + """ + env_vars = {} + if not filepath.exists(): + return env_vars + + try: + with open(filepath, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key.strip()] = value.strip().strip('"').strip("'") + except Exception as e: + print(f"Warning: Failed to load {filepath}: {e}") + + return env_vars + + @staticmethod + def get_env_paths(skill_dir: Path) -> List[Path]: + """ + Get list of .env file paths in priority order. + + Works with any AI tool directory structure: + - .agent/skills/ (universal) + - .claude/skills/ (Claude Code) + - .gemini/skills/ (Gemini CLI) + - .cursor/skills/ (Cursor) + + Priority: process.env > skill/.env > skills/.env > agent_dir/.env + + Args: + skill_dir: Path to skill directory + + Returns: + List of .env file paths + """ + paths = [] + + # skill/.env + skill_env = skill_dir / '.env' + if skill_env.exists(): + paths.append(skill_env) + + # skills/.env + skills_env = skill_dir.parent / '.env' + if skills_env.exists(): + paths.append(skills_env) + + # agent_dir/.env (e.g., .agent, .claude, .gemini, .cursor) + agent_env = skill_dir.parent.parent / '.env' + if agent_env.exists(): + paths.append(agent_env) + + return paths + + @staticmethod + def load_config(skill_dir: Path) -> EnvConfig: + """ + Load configuration from environment variables. + + Works with any AI tool directory structure. + Priority: process.env > skill/.env > skills/.env > agent_dir/.env + + Args: + skill_dir: Path to skill directory + + Returns: + EnvConfig object + """ + config = EnvConfig() + + # Load from .env files (reverse priority order) + for env_path in reversed(EnvLoader.get_env_paths(skill_dir)): + env_vars = EnvLoader.load_env_file(env_path) + if 'SHOPIFY_API_KEY' in env_vars: + config.shopify_api_key = env_vars['SHOPIFY_API_KEY'] + if 'SHOPIFY_API_SECRET' in env_vars: + config.shopify_api_secret = env_vars['SHOPIFY_API_SECRET'] + if 'SHOP_DOMAIN' in env_vars: + config.shop_domain = env_vars['SHOP_DOMAIN'] + if 'SCOPES' in env_vars: + config.scopes = env_vars['SCOPES'] + + # Override with process environment (highest priority) + if 'SHOPIFY_API_KEY' in os.environ: + config.shopify_api_key = os.environ['SHOPIFY_API_KEY'] + if 'SHOPIFY_API_SECRET' in os.environ: + config.shopify_api_secret = os.environ['SHOPIFY_API_SECRET'] + if 'SHOP_DOMAIN' in os.environ: + config.shop_domain = os.environ['SHOP_DOMAIN'] + if 'SCOPES' in os.environ: + config.scopes = os.environ['SCOPES'] + + return config + + +class ShopifyInitializer: + """Initialize Shopify projects.""" + + def __init__(self, config: EnvConfig): + """ + Initialize ShopifyInitializer. + + Args: + config: Environment configuration + """ + self.config = config + + def prompt(self, message: str, default: Optional[str] = None) -> str: + """ + Prompt user for input. + + Args: + message: Prompt message + default: Default value + + Returns: + User input or default + """ + if default: + message = f"{message} [{default}]" + user_input = input(f"{message}: ").strip() + return user_input if user_input else (default or '') + + def select_option(self, message: str, options: List[str]) -> str: + """ + Prompt user to select from options. + + Args: + message: Prompt message + options: List of options + + Returns: + Selected option + """ + print(f"\n{message}") + for i, option in enumerate(options, 1): + print(f"{i}. {option}") + + while True: + try: + choice = int(input("Select option: ").strip()) + if 1 <= choice <= len(options): + return options[choice - 1] + print(f"Please select 1-{len(options)}") + except (ValueError, KeyboardInterrupt): + print("Invalid input") + + def check_cli_installed(self) -> bool: + """ + Check if Shopify CLI is installed. + + Returns: + True if installed, False otherwise + """ + try: + result = subprocess.run( + ['shopify', 'version'], + capture_output=True, + text=True, + timeout=5 + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + def create_app_config(self, project_dir: Path, app_name: str, scopes: str) -> None: + """ + Create shopify.app.toml configuration file. + + Args: + project_dir: Project directory + app_name: Application name + scopes: Access scopes + """ + config_content = f"""# Shopify App Configuration +name = "{app_name}" +client_id = "{self.config.shopify_api_key or 'YOUR_API_KEY'}" +application_url = "https://your-app.com" +embedded = true + +[build] +automatically_update_urls_on_dev = true +dev_store_url = "{self.config.shop_domain or 'your-store.myshopify.com'}" + +[access_scopes] +scopes = "{scopes}" + +[webhooks] +api_version = "2026-01" + +[[webhooks.subscriptions]] +topics = ["app/uninstalled"] +uri = "/webhooks/app/uninstalled" + +[webhooks.privacy_compliance] +customer_data_request_url = "/webhooks/gdpr/data-request" +customer_deletion_url = "/webhooks/gdpr/customer-deletion" +shop_deletion_url = "/webhooks/gdpr/shop-deletion" +""" + config_path = project_dir / 'shopify.app.toml' + config_path.write_text(config_content) + print(f"✓ Created {config_path}") + + def create_extension_config(self, project_dir: Path, extension_name: str, extension_type: str) -> None: + """ + Create shopify.extension.toml configuration file. + + Args: + project_dir: Project directory + extension_name: Extension name + extension_type: Extension type + """ + target_map = { + 'checkout': 'purchase.checkout.block.render', + 'admin_action': 'admin.product-details.action.render', + 'admin_block': 'admin.product-details.block.render', + 'pos': 'pos.home.tile.render', + 'function': 'function', + 'customer_account': 'customer-account.order-status.block.render', + 'theme_app': 'theme-app-extension' + } + + config_content = f"""name = "{extension_name}" +type = "ui_extension" +handle = "{extension_name.lower().replace(' ', '-')}" + +[extension_points] +api_version = "2026-01" + +[[extension_points.targets]] +target = "{target_map.get(extension_type, 'purchase.checkout.block.render')}" + +[capabilities] +network_access = true +api_access = true +""" + config_path = project_dir / 'shopify.extension.toml' + config_path.write_text(config_content) + print(f"✓ Created {config_path}") + + def create_readme(self, project_dir: Path, project_type: str, project_name: str) -> None: + """ + Create README.md file. + + Args: + project_dir: Project directory + project_type: Project type (app/extension/theme) + project_name: Project name + """ + content = f"""# {project_name} + +Shopify {project_type.capitalize()} project. + +## Setup + +```bash +# Install dependencies +npm install + +# Start development +shopify {project_type} dev +``` + +## Deployment + +```bash +# Deploy to Shopify +shopify {project_type} deploy +``` + +## Resources + +- [Shopify Documentation](https://shopify.dev/docs) +- [Shopify CLI](https://shopify.dev/docs/api/shopify-cli) +""" + readme_path = project_dir / 'README.md' + readme_path.write_text(content) + print(f"✓ Created {readme_path}") + + def init_app(self) -> None: + """Initialize Shopify app project.""" + print("\n=== Shopify App Initialization ===\n") + + app_name = self.prompt("App name", "my-shopify-app") + scopes = self.prompt("Access scopes", self.config.scopes or "read_products,write_products") + + project_dir = Path.cwd() / app_name + project_dir.mkdir(exist_ok=True) + + print(f"\nCreating app in {project_dir}...") + + self.create_app_config(project_dir, app_name, scopes) + self.create_readme(project_dir, "app", app_name) + + # Create basic package.json + package_json = { + "name": app_name.lower().replace(' ', '-'), + "version": "1.0.0", + "scripts": { + "dev": "shopify app dev", + "deploy": "shopify app deploy" + } + } + (project_dir / 'package.json').write_text(json.dumps(package_json, indent=2)) + print(f"✓ Created package.json") + + print(f"\n✓ App '{app_name}' initialized successfully!") + print(f"\nNext steps:") + print(f" cd {app_name}") + print(f" npm install") + print(f" shopify app dev") + + def init_extension(self) -> None: + """Initialize Shopify extension project.""" + print("\n=== Shopify Extension Initialization ===\n") + + extension_types = [ + 'checkout', + 'admin_action', + 'admin_block', + 'pos', + 'function', + 'customer_account', + 'theme_app' + ] + extension_type = self.select_option("Select extension type", extension_types) + + extension_name = self.prompt("Extension name", "my-extension") + + project_dir = Path.cwd() / extension_name + project_dir.mkdir(exist_ok=True) + + print(f"\nCreating extension in {project_dir}...") + + self.create_extension_config(project_dir, extension_name, extension_type) + self.create_readme(project_dir, "extension", extension_name) + + print(f"\n✓ Extension '{extension_name}' initialized successfully!") + print(f"\nNext steps:") + print(f" cd {extension_name}") + print(f" shopify app dev") + + def init_theme(self) -> None: + """Initialize Shopify theme project.""" + print("\n=== Shopify Theme Initialization ===\n") + + theme_name = self.prompt("Theme name", "my-theme") + + print(f"\nInitializing theme '{theme_name}'...") + print("\nRecommended: Use 'shopify theme init' for full theme scaffolding") + print(f"\nRun: shopify theme init {theme_name}") + + def run(self) -> None: + """Run interactive initialization.""" + print("=" * 60) + print("Shopify Project Initializer") + print("=" * 60) + + # Check CLI + if not self.check_cli_installed(): + print("\n⚠ Shopify CLI not found!") + print("Install: npm install -g @shopify/cli@latest") + sys.exit(1) + + # Select project type + project_types = ['app', 'extension', 'theme'] + project_type = self.select_option("Select project type", project_types) + + # Initialize based on type + if project_type == 'app': + self.init_app() + elif project_type == 'extension': + self.init_extension() + elif project_type == 'theme': + self.init_theme() + + +def main() -> None: + """Main entry point.""" + try: + # Get skill directory + script_dir = Path(__file__).parent + skill_dir = script_dir.parent + + # Load configuration + config = EnvLoader.load_config(skill_dir) + + # Initialize project + initializer = ShopifyInitializer(config) + initializer.run() + + except KeyboardInterrupt: + print("\n\nAborted.") + sys.exit(0) + except Exception as e: + print(f"\n✗ Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/skills/shopify-development/scripts/tests/test_shopify_init.py b/skills/shopify-development/scripts/tests/test_shopify_init.py new file mode 100644 index 00000000..bcebb790 --- /dev/null +++ b/skills/shopify-development/scripts/tests/test_shopify_init.py @@ -0,0 +1,379 @@ +""" +Tests for shopify_init.py + +Run with: pytest test_shopify_init.py -v --cov=shopify_init --cov-report=term-missing +""" + +import os +import sys +import json +import pytest +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch, mock_open, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from shopify_init import EnvLoader, EnvConfig, ShopifyInitializer + + +class TestEnvLoader: + """Test EnvLoader class.""" + + def test_load_env_file_success(self, tmp_path): + """Test loading valid .env file.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +SHOPIFY_API_KEY=test_key +SHOPIFY_API_SECRET=test_secret +SHOP_DOMAIN=test.myshopify.com +# Comment line +SCOPES=read_products,write_products +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['SHOPIFY_API_KEY'] == 'test_key' + assert result['SHOPIFY_API_SECRET'] == 'test_secret' + assert result['SHOP_DOMAIN'] == 'test.myshopify.com' + assert result['SCOPES'] == 'read_products,write_products' + + def test_load_env_file_with_quotes(self, tmp_path): + """Test loading .env file with quoted values.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +SHOPIFY_API_KEY="test_key" +SHOPIFY_API_SECRET='test_secret' +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['SHOPIFY_API_KEY'] == 'test_key' + assert result['SHOPIFY_API_SECRET'] == 'test_secret' + + def test_load_env_file_nonexistent(self, tmp_path): + """Test loading non-existent .env file.""" + result = EnvLoader.load_env_file(tmp_path / "nonexistent.env") + assert result == {} + + def test_load_env_file_invalid_format(self, tmp_path): + """Test loading .env file with invalid lines.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +VALID_KEY=value +INVALID_LINE_NO_EQUALS +ANOTHER_VALID=test +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['VALID_KEY'] == 'value' + assert result['ANOTHER_VALID'] == 'test' + assert 'INVALID_LINE_NO_EQUALS' not in result + + def test_get_env_paths(self, tmp_path): + """Test getting .env file paths from universal directory structure.""" + # Create directory structure (works with .agent, .claude, .gemini, .cursor) + agent_dir = tmp_path / ".agent" + skills_dir = agent_dir / "skills" + skill_dir = skills_dir / "shopify" + + skill_dir.mkdir(parents=True) + + # Create .env files at each level + (skill_dir / ".env").write_text("SKILL=1") + (skills_dir / ".env").write_text("SKILLS=1") + (agent_dir / ".env").write_text("AGENT=1") + + paths = EnvLoader.get_env_paths(skill_dir) + + assert len(paths) == 3 + assert skill_dir / ".env" in paths + assert skills_dir / ".env" in paths + assert agent_dir / ".env" in paths + + def test_load_config_priority(self, tmp_path, monkeypatch): + """Test configuration loading priority across different AI tool directories.""" + skill_dir = tmp_path / "skill" + skills_dir = tmp_path + agent_dir = tmp_path.parent # Could be .agent, .claude, .gemini, .cursor + + skill_dir.mkdir(parents=True) + + (skill_dir / ".env").write_text("SHOPIFY_API_KEY=skill_key") + (skills_dir / ".env").write_text("SHOPIFY_API_KEY=skills_key\nSHOP_DOMAIN=skills.myshopify.com") + + monkeypatch.setenv("SHOPIFY_API_KEY", "process_key") + + config = EnvLoader.load_config(skill_dir) + + assert config.shopify_api_key == "process_key" + # Shop domain from skills/.env + assert config.shop_domain == "skills.myshopify.com" + + def test_load_config_no_files(self, tmp_path): + """Test configuration loading with no .env files.""" + config = EnvLoader.load_config(tmp_path) + + assert config.shopify_api_key is None + assert config.shopify_api_secret is None + assert config.shop_domain is None + assert config.scopes is None + + +class TestShopifyInitializer: + """Test ShopifyInitializer class.""" + + @pytest.fixture + def config(self): + """Create test config.""" + return EnvConfig( + shopify_api_key="test_key", + shopify_api_secret="test_secret", + shop_domain="test.myshopify.com", + scopes="read_products,write_products" + ) + + @pytest.fixture + def initializer(self, config): + """Create initializer instance.""" + return ShopifyInitializer(config) + + def test_prompt_with_default(self, initializer): + """Test prompt with default value.""" + with patch('builtins.input', return_value=''): + result = initializer.prompt("Test", "default_value") + assert result == "default_value" + + def test_prompt_with_input(self, initializer): + """Test prompt with user input.""" + with patch('builtins.input', return_value='user_input'): + result = initializer.prompt("Test", "default_value") + assert result == "user_input" + + def test_select_option_valid(self, initializer): + """Test select option with valid choice.""" + options = ['app', 'extension', 'theme'] + with patch('builtins.input', return_value='2'): + result = initializer.select_option("Choose", options) + assert result == 'extension' + + def test_select_option_invalid_then_valid(self, initializer): + """Test select option with invalid then valid choice.""" + options = ['app', 'extension'] + with patch('builtins.input', side_effect=['5', 'invalid', '1']): + result = initializer.select_option("Choose", options) + assert result == 'app' + + def test_check_cli_installed_success(self, initializer): + """Test CLI installed check - success.""" + mock_result = Mock() + mock_result.returncode = 0 + + with patch('subprocess.run', return_value=mock_result): + assert initializer.check_cli_installed() is True + + def test_check_cli_installed_failure(self, initializer): + """Test CLI installed check - failure.""" + with patch('subprocess.run', side_effect=FileNotFoundError): + assert initializer.check_cli_installed() is False + + def test_create_app_config(self, initializer, tmp_path): + """Test creating app configuration file.""" + initializer.create_app_config(tmp_path, "test-app", "read_products") + + config_file = tmp_path / "shopify.app.toml" + assert config_file.exists() + + content = config_file.read_text() + assert 'name = "test-app"' in content + assert 'scopes = "read_products"' in content + assert 'client_id = "test_key"' in content + + def test_create_extension_config(self, initializer, tmp_path): + """Test creating extension configuration file.""" + initializer.create_extension_config(tmp_path, "test-ext", "checkout") + + config_file = tmp_path / "shopify.extension.toml" + assert config_file.exists() + + content = config_file.read_text() + assert 'name = "test-ext"' in content + assert 'purchase.checkout.block.render' in content + + def test_create_extension_config_admin_action(self, initializer, tmp_path): + """Test creating admin action extension config.""" + initializer.create_extension_config(tmp_path, "admin-ext", "admin_action") + + config_file = tmp_path / "shopify.extension.toml" + content = config_file.read_text() + assert 'admin.product-details.action.render' in content + + def test_create_readme(self, initializer, tmp_path): + """Test creating README file.""" + initializer.create_readme(tmp_path, "app", "Test App") + + readme_file = tmp_path / "README.md" + assert readme_file.exists() + + content = readme_file.read_text() + assert '# Test App' in content + assert 'shopify app dev' in content + + @patch('builtins.input') + @patch('builtins.print') + def test_init_app(self, mock_print, mock_input, initializer, tmp_path, monkeypatch): + """Test app initialization.""" + monkeypatch.chdir(tmp_path) + + # Mock user inputs + mock_input.side_effect = ['my-app', 'read_products,write_products'] + + initializer.init_app() + + # Check directory created + app_dir = tmp_path / "my-app" + assert app_dir.exists() + + # Check files created + assert (app_dir / "shopify.app.toml").exists() + assert (app_dir / "README.md").exists() + assert (app_dir / "package.json").exists() + + # Check package.json content + package_json = json.loads((app_dir / "package.json").read_text()) + assert package_json['name'] == 'my-app' + assert 'dev' in package_json['scripts'] + + @patch('builtins.input') + @patch('builtins.print') + def test_init_extension(self, mock_print, mock_input, initializer, tmp_path, monkeypatch): + """Test extension initialization.""" + monkeypatch.chdir(tmp_path) + + # Mock user inputs: type selection (1 = checkout), name + mock_input.side_effect = ['1', 'my-extension'] + + initializer.init_extension() + + # Check directory and files created + ext_dir = tmp_path / "my-extension" + assert ext_dir.exists() + assert (ext_dir / "shopify.extension.toml").exists() + assert (ext_dir / "README.md").exists() + + @patch('builtins.input') + @patch('builtins.print') + def test_init_theme(self, mock_print, mock_input, initializer): + """Test theme initialization.""" + mock_input.return_value = 'my-theme' + + initializer.init_theme() + + assert mock_print.called + + @patch('builtins.print') + def test_run_no_cli(self, mock_print, initializer): + """Test run when CLI not installed.""" + with patch.object(initializer, 'check_cli_installed', return_value=False): + with pytest.raises(SystemExit) as exc_info: + initializer.run() + assert exc_info.value.code == 1 + + @patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True) + @patch.object(ShopifyInitializer, 'init_app') + @patch('builtins.input') + @patch('builtins.print') + def test_run_app_selected(self, mock_print, mock_input, mock_init_app, mock_cli_check, initializer): + """Test run with app selection.""" + mock_input.return_value = '1' # Select app + + initializer.run() + + mock_init_app.assert_called_once() + + @patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True) + @patch.object(ShopifyInitializer, 'init_extension') + @patch('builtins.input') + @patch('builtins.print') + def test_run_extension_selected(self, mock_print, mock_input, mock_init_ext, mock_cli_check, initializer): + """Test run with extension selection.""" + mock_input.return_value = '2' # Select extension + + initializer.run() + + mock_init_ext.assert_called_once() + + +class TestMain: + """Test main function.""" + + @patch('shopify_init.ShopifyInitializer') + @patch('shopify_init.EnvLoader') + def test_main_success(self, mock_loader, mock_initializer): + """Test main function success path.""" + from shopify_init import main + + mock_config = Mock() + mock_loader.load_config.return_value = mock_config + + mock_init_instance = Mock() + mock_initializer.return_value = mock_init_instance + + with patch('builtins.print'): + main() + + mock_init_instance.run.assert_called_once() + + @patch('shopify_init.ShopifyInitializer') + @patch('sys.exit') + def test_main_keyboard_interrupt(self, mock_exit, mock_initializer): + """Test main function with keyboard interrupt.""" + from shopify_init import main + + mock_initializer.return_value.run.side_effect = KeyboardInterrupt + + with patch('builtins.print'): + main() + + mock_exit.assert_called_with(0) + + @patch('shopify_init.ShopifyInitializer') + @patch('sys.exit') + def test_main_exception(self, mock_exit, mock_initializer): + """Test main function with exception.""" + from shopify_init import main + + mock_initializer.return_value.run.side_effect = Exception("Test error") + + with patch('builtins.print'): + main() + + mock_exit.assert_called_with(1) + + +class TestEnvConfig: + """Test EnvConfig dataclass.""" + + def test_env_config_defaults(self): + """Test EnvConfig default values.""" + config = EnvConfig() + + assert config.shopify_api_key is None + assert config.shopify_api_secret is None + assert config.shop_domain is None + assert config.scopes is None + + def test_env_config_with_values(self): + """Test EnvConfig with values.""" + config = EnvConfig( + shopify_api_key="key", + shopify_api_secret="secret", + shop_domain="test.myshopify.com", + scopes="read_products" + ) + + assert config.shopify_api_key == "key" + assert config.shopify_api_secret == "secret" + assert config.shop_domain == "test.myshopify.com" + assert config.scopes == "read_products"