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
This commit is contained in:
49
skills/shopify-development/scripts/.gitignore
vendored
Normal file
49
skills/shopify-development/scripts/.gitignore
vendored
Normal file
@@ -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
|
||||
19
skills/shopify-development/scripts/requirements.txt
Normal file
19
skills/shopify-development/scripts/requirements.txt
Normal file
@@ -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
|
||||
428
skills/shopify-development/scripts/shopify_graphql.py
Normal file
428
skills/shopify-development/scripts/shopify_graphql.py
Normal file
@@ -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()
|
||||
441
skills/shopify-development/scripts/shopify_init.py
Normal file
441
skills/shopify-development/scripts/shopify_init.py
Normal file
@@ -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()
|
||||
379
skills/shopify-development/scripts/tests/test_shopify_init.py
Normal file
379
skills/shopify-development/scripts/tests/test_shopify_init.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user