shopify-starter-kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/skills/shopify-apps/SKILL.md +47 -0
- package/.agent/skills/shopify-automation/SKILL.md +172 -0
- package/.agent/skills/shopify-development/README.md +60 -0
- package/.agent/skills/shopify-development/SKILL.md +368 -0
- package/.agent/skills/shopify-development/references/app-development.md +578 -0
- package/.agent/skills/shopify-development/references/extensions.md +555 -0
- package/.agent/skills/shopify-development/references/themes.md +498 -0
- package/.agent/skills/shopify-development/scripts/requirements.txt +19 -0
- package/.agent/skills/shopify-development/scripts/shopify_graphql.py +428 -0
- package/.agent/skills/shopify-development/scripts/shopify_init.py +441 -0
- package/.agent/skills/shopify-development/scripts/tests/test_shopify_init.py +379 -0
- package/bin/cli.js +3 -0
- package/package.json +32 -0
- package/src/index.js +116 -0
- package/templates/.agent/skills/shopify-apps/SKILL.md +47 -0
- package/templates/.agent/skills/shopify-automation/SKILL.md +172 -0
- package/templates/.agent/skills/shopify-development/README.md +60 -0
- package/templates/.agent/skills/shopify-development/SKILL.md +368 -0
- package/templates/.agent/skills/shopify-development/references/app-development.md +578 -0
- package/templates/.agent/skills/shopify-development/references/extensions.md +555 -0
- package/templates/.agent/skills/shopify-development/references/themes.md +498 -0
- package/templates/.agent/skills/shopify-development/scripts/requirements.txt +19 -0
- package/templates/.agent/skills/shopify-development/scripts/shopify_graphql.py +428 -0
- package/templates/.agent/skills/shopify-development/scripts/shopify_init.py +441 -0
- package/templates/.agent/skills/shopify-development/scripts/tests/test_shopify_init.py +379 -0
- package/templates/.devcontainer/devcontainer.json +27 -0
- package/templates/tests/playwright.config.ts +26 -0
- package/templates/tests/vitest.config.ts +9 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shopify GraphQL Utilities
|
|
4
|
+
|
|
5
|
+
Helper functions for common Shopify GraphQL operations.
|
|
6
|
+
Provides query templates, pagination helpers, and rate limit handling.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from shopify_graphql import ShopifyGraphQL
|
|
10
|
+
|
|
11
|
+
client = ShopifyGraphQL(shop_domain, access_token)
|
|
12
|
+
products = client.get_products(first=10)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
import json
|
|
18
|
+
from typing import Dict, List, Optional, Any, Generator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from urllib.request import Request, urlopen
|
|
21
|
+
from urllib.error import HTTPError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# API Configuration
|
|
25
|
+
API_VERSION = "2026-01"
|
|
26
|
+
MAX_RETRIES = 3
|
|
27
|
+
RETRY_DELAY = 1.0 # seconds
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class GraphQLResponse:
|
|
32
|
+
"""Container for GraphQL response data."""
|
|
33
|
+
data: Optional[Dict[str, Any]] = None
|
|
34
|
+
errors: Optional[List[Dict[str, Any]]] = None
|
|
35
|
+
extensions: Optional[Dict[str, Any]] = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_success(self) -> bool:
|
|
39
|
+
return self.errors is None or len(self.errors) == 0
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def query_cost(self) -> Optional[int]:
|
|
43
|
+
"""Get the actual query cost from extensions."""
|
|
44
|
+
if self.extensions and 'cost' in self.extensions:
|
|
45
|
+
return self.extensions['cost'].get('actualQueryCost')
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ShopifyGraphQL:
|
|
50
|
+
"""
|
|
51
|
+
Shopify GraphQL API client with built-in utilities.
|
|
52
|
+
|
|
53
|
+
Features:
|
|
54
|
+
- Query templates for common operations
|
|
55
|
+
- Automatic pagination
|
|
56
|
+
- Rate limit handling with exponential backoff
|
|
57
|
+
- Response parsing helpers
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, shop_domain: str, access_token: str):
|
|
61
|
+
"""
|
|
62
|
+
Initialize the GraphQL client.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
shop_domain: Store domain (e.g., 'my-store.myshopify.com')
|
|
66
|
+
access_token: Admin API access token
|
|
67
|
+
"""
|
|
68
|
+
self.shop_domain = shop_domain.replace('https://', '').replace('http://', '')
|
|
69
|
+
self.access_token = access_token
|
|
70
|
+
self.base_url = f"https://{self.shop_domain}/admin/api/{API_VERSION}/graphql.json"
|
|
71
|
+
|
|
72
|
+
def execute(self, query: str, variables: Optional[Dict] = None) -> GraphQLResponse:
|
|
73
|
+
"""
|
|
74
|
+
Execute a GraphQL query/mutation.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
query: GraphQL query string
|
|
78
|
+
variables: Query variables
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
GraphQLResponse object
|
|
82
|
+
"""
|
|
83
|
+
payload = {"query": query}
|
|
84
|
+
if variables:
|
|
85
|
+
payload["variables"] = variables
|
|
86
|
+
|
|
87
|
+
headers = {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"X-Shopify-Access-Token": self.access_token
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for attempt in range(MAX_RETRIES):
|
|
93
|
+
try:
|
|
94
|
+
request = Request(
|
|
95
|
+
self.base_url,
|
|
96
|
+
data=json.dumps(payload).encode('utf-8'),
|
|
97
|
+
headers=headers,
|
|
98
|
+
method='POST'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
with urlopen(request, timeout=30) as response:
|
|
102
|
+
result = json.loads(response.read().decode('utf-8'))
|
|
103
|
+
return GraphQLResponse(
|
|
104
|
+
data=result.get('data'),
|
|
105
|
+
errors=result.get('errors'),
|
|
106
|
+
extensions=result.get('extensions')
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
except HTTPError as e:
|
|
110
|
+
if e.code == 429: # Rate limited
|
|
111
|
+
delay = RETRY_DELAY * (2 ** attempt)
|
|
112
|
+
print(f"Rate limited. Retrying in {delay}s...")
|
|
113
|
+
time.sleep(delay)
|
|
114
|
+
continue
|
|
115
|
+
raise
|
|
116
|
+
except Exception as e:
|
|
117
|
+
if attempt == MAX_RETRIES - 1:
|
|
118
|
+
raise
|
|
119
|
+
time.sleep(RETRY_DELAY)
|
|
120
|
+
|
|
121
|
+
return GraphQLResponse(errors=[{"message": "Max retries exceeded"}])
|
|
122
|
+
|
|
123
|
+
# ==================== Query Templates ====================
|
|
124
|
+
|
|
125
|
+
def get_products(
|
|
126
|
+
self,
|
|
127
|
+
first: int = 10,
|
|
128
|
+
query: Optional[str] = None,
|
|
129
|
+
after: Optional[str] = None
|
|
130
|
+
) -> GraphQLResponse:
|
|
131
|
+
"""
|
|
132
|
+
Query products with pagination.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
first: Number of products to fetch (max 250)
|
|
136
|
+
query: Optional search query
|
|
137
|
+
after: Cursor for pagination
|
|
138
|
+
"""
|
|
139
|
+
gql = """
|
|
140
|
+
query GetProducts($first: Int!, $query: String, $after: String) {
|
|
141
|
+
products(first: $first, query: $query, after: $after) {
|
|
142
|
+
edges {
|
|
143
|
+
node {
|
|
144
|
+
id
|
|
145
|
+
title
|
|
146
|
+
handle
|
|
147
|
+
status
|
|
148
|
+
totalInventory
|
|
149
|
+
variants(first: 5) {
|
|
150
|
+
edges {
|
|
151
|
+
node {
|
|
152
|
+
id
|
|
153
|
+
title
|
|
154
|
+
price
|
|
155
|
+
inventoryQuantity
|
|
156
|
+
sku
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
cursor
|
|
162
|
+
}
|
|
163
|
+
pageInfo {
|
|
164
|
+
hasNextPage
|
|
165
|
+
endCursor
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
"""
|
|
170
|
+
return self.execute(gql, {"first": first, "query": query, "after": after})
|
|
171
|
+
|
|
172
|
+
def get_orders(
|
|
173
|
+
self,
|
|
174
|
+
first: int = 10,
|
|
175
|
+
query: Optional[str] = None,
|
|
176
|
+
after: Optional[str] = None
|
|
177
|
+
) -> GraphQLResponse:
|
|
178
|
+
"""
|
|
179
|
+
Query orders with pagination.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
first: Number of orders to fetch (max 250)
|
|
183
|
+
query: Optional search query (e.g., "financial_status:paid")
|
|
184
|
+
after: Cursor for pagination
|
|
185
|
+
"""
|
|
186
|
+
gql = """
|
|
187
|
+
query GetOrders($first: Int!, $query: String, $after: String) {
|
|
188
|
+
orders(first: $first, query: $query, after: $after) {
|
|
189
|
+
edges {
|
|
190
|
+
node {
|
|
191
|
+
id
|
|
192
|
+
name
|
|
193
|
+
createdAt
|
|
194
|
+
displayFinancialStatus
|
|
195
|
+
displayFulfillmentStatus
|
|
196
|
+
totalPriceSet {
|
|
197
|
+
shopMoney { amount currencyCode }
|
|
198
|
+
}
|
|
199
|
+
customer {
|
|
200
|
+
id
|
|
201
|
+
firstName
|
|
202
|
+
lastName
|
|
203
|
+
}
|
|
204
|
+
lineItems(first: 5) {
|
|
205
|
+
edges {
|
|
206
|
+
node {
|
|
207
|
+
title
|
|
208
|
+
quantity
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
cursor
|
|
214
|
+
}
|
|
215
|
+
pageInfo {
|
|
216
|
+
hasNextPage
|
|
217
|
+
endCursor
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
"""
|
|
222
|
+
return self.execute(gql, {"first": first, "query": query, "after": after})
|
|
223
|
+
|
|
224
|
+
def get_customers(
|
|
225
|
+
self,
|
|
226
|
+
first: int = 10,
|
|
227
|
+
query: Optional[str] = None,
|
|
228
|
+
after: Optional[str] = None
|
|
229
|
+
) -> GraphQLResponse:
|
|
230
|
+
"""
|
|
231
|
+
Query customers with pagination.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
first: Number of customers to fetch (max 250)
|
|
235
|
+
query: Optional search query
|
|
236
|
+
after: Cursor for pagination
|
|
237
|
+
"""
|
|
238
|
+
gql = """
|
|
239
|
+
query GetCustomers($first: Int!, $query: String, $after: String) {
|
|
240
|
+
customers(first: $first, query: $query, after: $after) {
|
|
241
|
+
edges {
|
|
242
|
+
node {
|
|
243
|
+
id
|
|
244
|
+
firstName
|
|
245
|
+
lastName
|
|
246
|
+
displayName
|
|
247
|
+
defaultEmailAddress {
|
|
248
|
+
emailAddress
|
|
249
|
+
}
|
|
250
|
+
numberOfOrders
|
|
251
|
+
amountSpent {
|
|
252
|
+
amount
|
|
253
|
+
currencyCode
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
cursor
|
|
257
|
+
}
|
|
258
|
+
pageInfo {
|
|
259
|
+
hasNextPage
|
|
260
|
+
endCursor
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
"""
|
|
265
|
+
return self.execute(gql, {"first": first, "query": query, "after": after})
|
|
266
|
+
|
|
267
|
+
def set_metafields(self, metafields: List[Dict]) -> GraphQLResponse:
|
|
268
|
+
"""
|
|
269
|
+
Set metafields on resources.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
metafields: List of metafield inputs, each containing:
|
|
273
|
+
- ownerId: Resource GID
|
|
274
|
+
- namespace: Metafield namespace
|
|
275
|
+
- key: Metafield key
|
|
276
|
+
- value: Metafield value
|
|
277
|
+
- type: Metafield type
|
|
278
|
+
"""
|
|
279
|
+
gql = """
|
|
280
|
+
mutation SetMetafields($metafields: [MetafieldsSetInput!]!) {
|
|
281
|
+
metafieldsSet(metafields: $metafields) {
|
|
282
|
+
metafields {
|
|
283
|
+
id
|
|
284
|
+
namespace
|
|
285
|
+
key
|
|
286
|
+
value
|
|
287
|
+
}
|
|
288
|
+
userErrors {
|
|
289
|
+
field
|
|
290
|
+
message
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
"""
|
|
295
|
+
return self.execute(gql, {"metafields": metafields})
|
|
296
|
+
|
|
297
|
+
# ==================== Pagination Helpers ====================
|
|
298
|
+
|
|
299
|
+
def paginate_products(
|
|
300
|
+
self,
|
|
301
|
+
batch_size: int = 50,
|
|
302
|
+
query: Optional[str] = None
|
|
303
|
+
) -> Generator[Dict, None, None]:
|
|
304
|
+
"""
|
|
305
|
+
Generator that yields all products with automatic pagination.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
batch_size: Products per request (max 250)
|
|
309
|
+
query: Optional search query
|
|
310
|
+
|
|
311
|
+
Yields:
|
|
312
|
+
Product dictionaries
|
|
313
|
+
"""
|
|
314
|
+
cursor = None
|
|
315
|
+
while True:
|
|
316
|
+
response = self.get_products(first=batch_size, query=query, after=cursor)
|
|
317
|
+
|
|
318
|
+
if not response.is_success or not response.data:
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
products = response.data.get('products', {})
|
|
322
|
+
edges = products.get('edges', [])
|
|
323
|
+
|
|
324
|
+
for edge in edges:
|
|
325
|
+
yield edge['node']
|
|
326
|
+
|
|
327
|
+
page_info = products.get('pageInfo', {})
|
|
328
|
+
if not page_info.get('hasNextPage'):
|
|
329
|
+
break
|
|
330
|
+
|
|
331
|
+
cursor = page_info.get('endCursor')
|
|
332
|
+
|
|
333
|
+
def paginate_orders(
|
|
334
|
+
self,
|
|
335
|
+
batch_size: int = 50,
|
|
336
|
+
query: Optional[str] = None
|
|
337
|
+
) -> Generator[Dict, None, None]:
|
|
338
|
+
"""
|
|
339
|
+
Generator that yields all orders with automatic pagination.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
batch_size: Orders per request (max 250)
|
|
343
|
+
query: Optional search query
|
|
344
|
+
|
|
345
|
+
Yields:
|
|
346
|
+
Order dictionaries
|
|
347
|
+
"""
|
|
348
|
+
cursor = None
|
|
349
|
+
while True:
|
|
350
|
+
response = self.get_orders(first=batch_size, query=query, after=cursor)
|
|
351
|
+
|
|
352
|
+
if not response.is_success or not response.data:
|
|
353
|
+
break
|
|
354
|
+
|
|
355
|
+
orders = response.data.get('orders', {})
|
|
356
|
+
edges = orders.get('edges', [])
|
|
357
|
+
|
|
358
|
+
for edge in edges:
|
|
359
|
+
yield edge['node']
|
|
360
|
+
|
|
361
|
+
page_info = orders.get('pageInfo', {})
|
|
362
|
+
if not page_info.get('hasNextPage'):
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
cursor = page_info.get('endCursor')
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ==================== Utility Functions ====================
|
|
369
|
+
|
|
370
|
+
def extract_id(gid: str) -> str:
|
|
371
|
+
"""
|
|
372
|
+
Extract numeric ID from Shopify GID.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
gid: Global ID (e.g., 'gid://shopify/Product/123')
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Numeric ID string (e.g., '123')
|
|
379
|
+
"""
|
|
380
|
+
return gid.split('/')[-1] if gid else ''
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def build_gid(resource_type: str, id: str) -> str:
|
|
384
|
+
"""
|
|
385
|
+
Build Shopify GID from resource type and ID.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
resource_type: Resource type (e.g., 'Product', 'Order')
|
|
389
|
+
id: Numeric ID
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Global ID (e.g., 'gid://shopify/Product/123')
|
|
393
|
+
"""
|
|
394
|
+
return f"gid://shopify/{resource_type}/{id}"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ==================== Example Usage ====================
|
|
398
|
+
|
|
399
|
+
def main():
|
|
400
|
+
"""Example usage of ShopifyGraphQL client."""
|
|
401
|
+
import os
|
|
402
|
+
|
|
403
|
+
# Load from environment
|
|
404
|
+
shop = os.environ.get('SHOP_DOMAIN', 'your-store.myshopify.com')
|
|
405
|
+
token = os.environ.get('SHOPIFY_ACCESS_TOKEN', '')
|
|
406
|
+
|
|
407
|
+
if not token:
|
|
408
|
+
print("Set SHOPIFY_ACCESS_TOKEN environment variable")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
client = ShopifyGraphQL(shop, token)
|
|
412
|
+
|
|
413
|
+
# Example: Get first 5 products
|
|
414
|
+
print("Fetching products...")
|
|
415
|
+
response = client.get_products(first=5)
|
|
416
|
+
|
|
417
|
+
if response.is_success:
|
|
418
|
+
products = response.data['products']['edges']
|
|
419
|
+
for edge in products:
|
|
420
|
+
product = edge['node']
|
|
421
|
+
print(f" - {product['title']} ({product['status']})")
|
|
422
|
+
print(f"\nQuery cost: {response.query_cost}")
|
|
423
|
+
else:
|
|
424
|
+
print(f"Errors: {response.errors}")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == '__main__':
|
|
428
|
+
main()
|