@cli4ai/shopify 1.0.2 → 1.0.4
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/cli4ai.json +99 -22
- package/dist/run.d.ts +2 -0
- package/dist/run.js +253 -0
- package/package.json +13 -5
- package/run.ts +0 -277
package/cli4ai.json
CHANGED
|
@@ -1,45 +1,88 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopify",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Shopify management tools for orders, products, inventory, and content",
|
|
5
5
|
"author": "cliforai",
|
|
6
|
-
"license": "
|
|
7
|
-
"entry": "run.
|
|
6
|
+
"license": "BUSL-1.1",
|
|
7
|
+
"entry": "dist/run.js",
|
|
8
8
|
"runtime": "node",
|
|
9
|
-
"keywords": [
|
|
9
|
+
"keywords": [
|
|
10
|
+
"shopify",
|
|
11
|
+
"ecommerce",
|
|
12
|
+
"orders",
|
|
13
|
+
"inventory",
|
|
14
|
+
"seo",
|
|
15
|
+
"blog"
|
|
16
|
+
],
|
|
10
17
|
"commands": {
|
|
11
18
|
"orders": {
|
|
12
19
|
"description": "List recent orders",
|
|
13
20
|
"args": [
|
|
14
|
-
{
|
|
15
|
-
|
|
21
|
+
{
|
|
22
|
+
"name": "limit",
|
|
23
|
+
"description": "Number of orders (default: 10)",
|
|
24
|
+
"required": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "status",
|
|
28
|
+
"description": "Order status (open, closed, any)",
|
|
29
|
+
"required": false
|
|
30
|
+
}
|
|
16
31
|
]
|
|
17
32
|
},
|
|
18
33
|
"products": {
|
|
19
34
|
"description": "List products",
|
|
20
35
|
"args": [
|
|
21
|
-
{
|
|
22
|
-
|
|
36
|
+
{
|
|
37
|
+
"name": "limit",
|
|
38
|
+
"description": "Number of products (default: 10)",
|
|
39
|
+
"required": false
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "query",
|
|
43
|
+
"description": "Search query",
|
|
44
|
+
"required": false
|
|
45
|
+
}
|
|
23
46
|
]
|
|
24
47
|
},
|
|
25
48
|
"inventory": {
|
|
26
49
|
"description": "Check inventory levels",
|
|
27
50
|
"args": [
|
|
28
|
-
{
|
|
29
|
-
|
|
51
|
+
{
|
|
52
|
+
"name": "ids",
|
|
53
|
+
"description": "Comma-separated variant IDs",
|
|
54
|
+
"required": false
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "limit",
|
|
58
|
+
"description": "Limit results if no IDs (default: 10)",
|
|
59
|
+
"required": false
|
|
60
|
+
}
|
|
30
61
|
]
|
|
31
62
|
},
|
|
32
63
|
"reports": {
|
|
33
64
|
"description": "Get sales summary",
|
|
34
65
|
"args": [
|
|
35
|
-
{
|
|
66
|
+
{
|
|
67
|
+
"name": "since",
|
|
68
|
+
"description": "Start date (YYYY-MM-DD)",
|
|
69
|
+
"required": false
|
|
70
|
+
}
|
|
36
71
|
]
|
|
37
72
|
},
|
|
38
73
|
"seo_audit": {
|
|
39
74
|
"description": "Check SEO data for a resource",
|
|
40
75
|
"args": [
|
|
41
|
-
{
|
|
42
|
-
|
|
76
|
+
{
|
|
77
|
+
"name": "handle",
|
|
78
|
+
"description": "Resource handle",
|
|
79
|
+
"required": true
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "type",
|
|
83
|
+
"description": "Resource type (products, pages, articles)",
|
|
84
|
+
"required": true
|
|
85
|
+
}
|
|
43
86
|
]
|
|
44
87
|
},
|
|
45
88
|
"blogs": {
|
|
@@ -49,24 +92,58 @@
|
|
|
49
92
|
"articles": {
|
|
50
93
|
"description": "List articles in a blog",
|
|
51
94
|
"args": [
|
|
52
|
-
{
|
|
53
|
-
|
|
95
|
+
{
|
|
96
|
+
"name": "blog_id",
|
|
97
|
+
"description": "ID of the blog",
|
|
98
|
+
"required": true
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "limit",
|
|
102
|
+
"description": "Number of articles (default: 10)",
|
|
103
|
+
"required": false
|
|
104
|
+
}
|
|
54
105
|
]
|
|
55
106
|
},
|
|
56
107
|
"article_create": {
|
|
57
108
|
"description": "Create a new article",
|
|
58
109
|
"args": [
|
|
59
|
-
{
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
110
|
+
{
|
|
111
|
+
"name": "blog_id",
|
|
112
|
+
"description": "ID of the blog",
|
|
113
|
+
"required": true
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "title",
|
|
117
|
+
"description": "Article title",
|
|
118
|
+
"required": true
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "author",
|
|
122
|
+
"description": "Author name",
|
|
123
|
+
"required": true
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "tags",
|
|
127
|
+
"description": "Comma-separated tags",
|
|
128
|
+
"required": false
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "summary_html",
|
|
132
|
+
"description": "Summary HTML",
|
|
133
|
+
"required": false
|
|
134
|
+
}
|
|
64
135
|
]
|
|
65
136
|
}
|
|
66
137
|
},
|
|
67
138
|
"env": {
|
|
68
|
-
"SHOPIFY_SHOP": {
|
|
69
|
-
|
|
139
|
+
"SHOPIFY_SHOP": {
|
|
140
|
+
"required": true,
|
|
141
|
+
"description": "Shopify shop domain (e.g. my-shop.myshopify.com)"
|
|
142
|
+
},
|
|
143
|
+
"SHOPIFY_ACCESS_TOKEN": {
|
|
144
|
+
"required": true,
|
|
145
|
+
"description": "Admin API Access Token"
|
|
146
|
+
}
|
|
70
147
|
},
|
|
71
148
|
"dependencies": {
|
|
72
149
|
"@cli4ai/lib": "^1.0.0",
|
package/dist/run.d.ts
ADDED
package/dist/run.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cli, output, withErrorHandling } from '@cli4ai/lib';
|
|
3
|
+
const SHOP = process.env.SHOPIFY_SHOP;
|
|
4
|
+
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
|
|
5
|
+
const API_VERSION = '2024-01';
|
|
6
|
+
if (!SHOP || !TOKEN) {
|
|
7
|
+
// We rely on the MCP server or user to provide these
|
|
8
|
+
// But for direct CLI usage, we warn if missing, though @cli4ai/lib might handle envs?
|
|
9
|
+
// Actually, standard practice here is to fail if they are needed and not present.
|
|
10
|
+
// But we will let the individual commands fail or check at runtime.
|
|
11
|
+
}
|
|
12
|
+
async function shopifyFetch(endpoint, options = {}) {
|
|
13
|
+
if (!SHOP || !TOKEN) {
|
|
14
|
+
throw new Error('Missing SHOPIFY_SHOP or SHOPIFY_ACCESS_TOKEN environment variables');
|
|
15
|
+
}
|
|
16
|
+
const url = `https://${SHOP}/admin/api/${API_VERSION}/${endpoint}`;
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
...options,
|
|
19
|
+
headers: {
|
|
20
|
+
'X-Shopify-Access-Token': TOKEN,
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
...options.headers,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
const errorText = await response.text();
|
|
27
|
+
throw new Error(`Shopify API Error (${response.status}): ${errorText}`);
|
|
28
|
+
}
|
|
29
|
+
return response.json();
|
|
30
|
+
}
|
|
31
|
+
const program = cli('shopify', '1.0.0', 'Shopify CLI wrapper');
|
|
32
|
+
program
|
|
33
|
+
.command('orders')
|
|
34
|
+
.description('List recent orders')
|
|
35
|
+
.option('-l, --limit <number>', 'Number of orders', '10')
|
|
36
|
+
.option('-s, --status <status>', 'Order status', 'any')
|
|
37
|
+
.action(withErrorHandling(async (options) => {
|
|
38
|
+
const data = await shopifyFetch(`orders.json?limit=${options.limit}&status=${options.status}`);
|
|
39
|
+
const formatted = data.orders.map(o => ({
|
|
40
|
+
id: o.id,
|
|
41
|
+
order_number: o.order_number,
|
|
42
|
+
created_at: o.created_at,
|
|
43
|
+
total_price: o.total_price,
|
|
44
|
+
currency: o.currency,
|
|
45
|
+
customer: o.customer ? `${o.customer.first_name} ${o.customer.last_name}` : 'No Customer',
|
|
46
|
+
financial_status: o.financial_status,
|
|
47
|
+
fulfillment_status: o.fulfillment_status
|
|
48
|
+
}));
|
|
49
|
+
output(formatted);
|
|
50
|
+
}));
|
|
51
|
+
program
|
|
52
|
+
.command('products')
|
|
53
|
+
.description('List products')
|
|
54
|
+
.option('-l, --limit <number>', 'Number of products', '10')
|
|
55
|
+
.option('-q, --query <query>', 'Search query') // Basic title search if needed, or just list
|
|
56
|
+
.action(withErrorHandling(async (options) => {
|
|
57
|
+
let url = `products.json?limit=${options.limit}`;
|
|
58
|
+
if (options.query) {
|
|
59
|
+
// Shopify products endpoint doesn't support generic 'query' well in REST without specific fields
|
|
60
|
+
// but we can use 'title' if intended, or just fetch and filter.
|
|
61
|
+
// Better: Just fetch recent. If query is needed, use GraphQL or other endpoints.
|
|
62
|
+
// For now, ignoring query in REST url unless we map it to title or similar.
|
|
63
|
+
// Actually, standard REST has no fuzzy search. We'll just list.
|
|
64
|
+
// Or we can rely on `title` param if exact match? No.
|
|
65
|
+
// Let's just list recent.
|
|
66
|
+
if (options.query)
|
|
67
|
+
console.warn('Note: "query" parameter is limited in REST API, listing recent products.');
|
|
68
|
+
}
|
|
69
|
+
const data = await shopifyFetch(url);
|
|
70
|
+
const formatted = data.products.map(p => ({
|
|
71
|
+
id: p.id,
|
|
72
|
+
title: p.title,
|
|
73
|
+
handle: p.handle,
|
|
74
|
+
status: p.status,
|
|
75
|
+
variants: p.variants.length,
|
|
76
|
+
inventory: p.variants.reduce((sum, v) => sum + (v.inventory_quantity || 0), 0)
|
|
77
|
+
}));
|
|
78
|
+
output(formatted);
|
|
79
|
+
}));
|
|
80
|
+
program
|
|
81
|
+
.command('inventory')
|
|
82
|
+
.description('Check inventory')
|
|
83
|
+
.option('-i, --ids <ids>', 'Variant IDs')
|
|
84
|
+
.option('-l, --limit <number>', 'Limit', '10')
|
|
85
|
+
.action(withErrorHandling(async (options) => {
|
|
86
|
+
if (options.ids) {
|
|
87
|
+
const data = await shopifyFetch(`variants.json?ids=${options.ids}`);
|
|
88
|
+
output(data.variants.map(v => ({
|
|
89
|
+
id: v.id,
|
|
90
|
+
product_id: v.product_id,
|
|
91
|
+
title: v.title, // Variant title
|
|
92
|
+
inventory: v.inventory_quantity
|
|
93
|
+
})));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// Fetch products and show inventory
|
|
97
|
+
const data = await shopifyFetch(`products.json?limit=${options.limit}`);
|
|
98
|
+
const inventory = data.products.flatMap(p => p.variants.map((v) => ({
|
|
99
|
+
product: p.title,
|
|
100
|
+
variant: v.title,
|
|
101
|
+
sku: v.sku,
|
|
102
|
+
inventory: v.inventory_quantity
|
|
103
|
+
})));
|
|
104
|
+
output(inventory);
|
|
105
|
+
}
|
|
106
|
+
}));
|
|
107
|
+
program
|
|
108
|
+
.command('reports')
|
|
109
|
+
.description('Sales summary')
|
|
110
|
+
.option('--since <date>', 'Start date YYYY-MM-DD')
|
|
111
|
+
.action(withErrorHandling(async (options) => {
|
|
112
|
+
let url = 'orders.json?status=any&limit=250'; // 250 is max
|
|
113
|
+
if (options.since) {
|
|
114
|
+
url += `&created_at_min=${options.since}T00:00:00`;
|
|
115
|
+
}
|
|
116
|
+
const data = await shopifyFetch(url);
|
|
117
|
+
let totalSales = 0;
|
|
118
|
+
let orderCount = data.orders.length;
|
|
119
|
+
let currency = data.orders[0]?.currency || 'USD';
|
|
120
|
+
for (const order of data.orders) {
|
|
121
|
+
// Very basic: sum total_price of valid orders
|
|
122
|
+
// Exclude cancelled/voided if strictly sales?
|
|
123
|
+
// For summary, let's just sum total_price.
|
|
124
|
+
totalSales += parseFloat(order.total_price);
|
|
125
|
+
}
|
|
126
|
+
output({
|
|
127
|
+
period: options.since ? `Since ${options.since}` : 'Recent 250 orders',
|
|
128
|
+
order_count: orderCount,
|
|
129
|
+
total_sales: totalSales.toFixed(2),
|
|
130
|
+
currency
|
|
131
|
+
});
|
|
132
|
+
}));
|
|
133
|
+
program
|
|
134
|
+
.command('seo_audit')
|
|
135
|
+
.description('Check SEO data')
|
|
136
|
+
.argument('<handle>', 'Resource handle')
|
|
137
|
+
.argument('<type>', 'Resource type (products, pages, articles)')
|
|
138
|
+
.action(withErrorHandling(async (handle, type) => {
|
|
139
|
+
// We need to find ID from handle? REST API often requires ID.
|
|
140
|
+
// Except `products` can be filtered by handle? No, REST `products` endpoint filters are limited.
|
|
141
|
+
// But `products.json?handle=...` exists? No.
|
|
142
|
+
// Wait, checking Shopify API docs... `GET /admin/api/2024-01/products.json?handle=...` DOES work to find by handle.
|
|
143
|
+
let endpoint = '';
|
|
144
|
+
let resourceKey = '';
|
|
145
|
+
if (type === 'products' || type === 'product') {
|
|
146
|
+
endpoint = `products.json?handle=${handle}`;
|
|
147
|
+
resourceKey = 'products';
|
|
148
|
+
}
|
|
149
|
+
else if (type === 'pages' || type === 'page') {
|
|
150
|
+
endpoint = `pages.json?handle=${handle}`;
|
|
151
|
+
resourceKey = 'pages';
|
|
152
|
+
}
|
|
153
|
+
else if (type === 'articles' || type === 'article') {
|
|
154
|
+
// Articles need blog_id usually, handle is not unique globally?
|
|
155
|
+
// Actually `articles` endpoint exists but scoping is tricky.
|
|
156
|
+
// Let's assume user provides handle, but finding it might be hard without blog_id.
|
|
157
|
+
// For simplicity, let's fail or ask for blog_id.
|
|
158
|
+
// Or simpler: just support products/pages for now as they are global.
|
|
159
|
+
// But wait, user asked for "seo".
|
|
160
|
+
throw new Error('For articles, use "articles" command to find ID then inspect. "seo_audit" currently supports: products, pages.');
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
throw new Error('Unknown type. Use: products, pages');
|
|
164
|
+
}
|
|
165
|
+
const data = await shopifyFetch(endpoint);
|
|
166
|
+
const resources = data[resourceKey];
|
|
167
|
+
if (!resources || resources.length === 0) {
|
|
168
|
+
throw new Error(`No ${type} found with handle "${handle}"`);
|
|
169
|
+
}
|
|
170
|
+
const item = resources[0];
|
|
171
|
+
// Analyze
|
|
172
|
+
const analysis = {
|
|
173
|
+
id: item.id,
|
|
174
|
+
title: item.title,
|
|
175
|
+
handle: item.handle,
|
|
176
|
+
meta_title: item.metafields_global_title_tag || 'Not set (uses title)',
|
|
177
|
+
meta_description: item.metafields_global_description_tag || 'Not set (uses body excerpt)',
|
|
178
|
+
body_length: item.body_html ? item.body_html.length : 0,
|
|
179
|
+
created_at: item.created_at,
|
|
180
|
+
warnings: []
|
|
181
|
+
};
|
|
182
|
+
if (!analysis.meta_title)
|
|
183
|
+
analysis.warnings.push('Missing explicit meta title');
|
|
184
|
+
if (!analysis.meta_description)
|
|
185
|
+
analysis.warnings.push('Missing explicit meta description');
|
|
186
|
+
if (analysis.title.length > 70)
|
|
187
|
+
analysis.warnings.push('Title too long (>70 chars)');
|
|
188
|
+
output(analysis);
|
|
189
|
+
}));
|
|
190
|
+
program
|
|
191
|
+
.command('blogs')
|
|
192
|
+
.description('List blogs')
|
|
193
|
+
.action(withErrorHandling(async () => {
|
|
194
|
+
const data = await shopifyFetch('blogs.json');
|
|
195
|
+
output(data.blogs.map(b => ({
|
|
196
|
+
id: b.id,
|
|
197
|
+
title: b.title,
|
|
198
|
+
handle: b.handle,
|
|
199
|
+
commentable: b.commentable
|
|
200
|
+
})));
|
|
201
|
+
}));
|
|
202
|
+
program
|
|
203
|
+
.command('articles')
|
|
204
|
+
.description('List articles')
|
|
205
|
+
.argument('<blog_id>', 'Blog ID')
|
|
206
|
+
.option('-l, --limit <number>', 'Limit', '10')
|
|
207
|
+
.action(withErrorHandling(async (blogId, options) => {
|
|
208
|
+
const data = await shopifyFetch(`blogs/${blogId}/articles.json?limit=${options.limit}`);
|
|
209
|
+
output(data.articles.map(a => ({
|
|
210
|
+
id: a.id,
|
|
211
|
+
title: a.title,
|
|
212
|
+
author: a.author,
|
|
213
|
+
created_at: a.created_at,
|
|
214
|
+
tags: a.tags,
|
|
215
|
+
published: a.published
|
|
216
|
+
})));
|
|
217
|
+
}));
|
|
218
|
+
program
|
|
219
|
+
.command('article_create')
|
|
220
|
+
.description('Create article')
|
|
221
|
+
.argument('<blog_id>', 'Blog ID')
|
|
222
|
+
.argument('<title>', 'Title')
|
|
223
|
+
.argument('<author>', 'Author')
|
|
224
|
+
.argument('[tags]', 'Tags (comma separated)')
|
|
225
|
+
.argument('[summary_html]', 'Summary HTML')
|
|
226
|
+
.action(withErrorHandling(async (blogId, title, author, tags, summaryHtml) => {
|
|
227
|
+
// We create a basic article.
|
|
228
|
+
// Note: User might want to set body_html.
|
|
229
|
+
// Since args are limited in CLI, maybe we just set summary or basic body.
|
|
230
|
+
// Real usage would likely involve piping content or a more complex object.
|
|
231
|
+
// For now, we set body_html = summary_html to ensure it has content if provided.
|
|
232
|
+
const payload = {
|
|
233
|
+
article: {
|
|
234
|
+
title,
|
|
235
|
+
author,
|
|
236
|
+
tags: tags || '',
|
|
237
|
+
summary_html: summaryHtml || '',
|
|
238
|
+
body_html: summaryHtml || '<p>Content pending...</p>',
|
|
239
|
+
published: true
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const data = await shopifyFetch(`blogs/${blogId}/articles.json`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
body: JSON.stringify(payload)
|
|
245
|
+
});
|
|
246
|
+
output({
|
|
247
|
+
id: data.article.id,
|
|
248
|
+
title: data.article.title,
|
|
249
|
+
url: `.../blogs/${blogId}/${data.article.id}`, // Construct url if possible
|
|
250
|
+
status: 'Created'
|
|
251
|
+
});
|
|
252
|
+
}));
|
|
253
|
+
program.parse();
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cli4ai/shopify",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Shopify management tools for orders, products, inventory, and content",
|
|
5
5
|
"author": "cliforai",
|
|
6
|
-
"license": "
|
|
7
|
-
"main": "run.
|
|
6
|
+
"license": "BUSL-1.1",
|
|
7
|
+
"main": "dist/run.js",
|
|
8
8
|
"bin": {
|
|
9
|
-
"shopify": "./run.
|
|
9
|
+
"shopify": "./dist/run.js"
|
|
10
10
|
},
|
|
11
11
|
"type": "module",
|
|
12
12
|
"keywords": [
|
|
@@ -34,12 +34,20 @@
|
|
|
34
34
|
"commander": "^14.0.0"
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
|
-
"
|
|
37
|
+
"dist",
|
|
38
38
|
"cli4ai.json",
|
|
39
39
|
"README.md",
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc",
|
|
47
|
+
"prepublishOnly": "npm run build"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"typescript": "^5.0.0",
|
|
51
|
+
"@types/node": "^22.0.0"
|
|
44
52
|
}
|
|
45
53
|
}
|
package/run.ts
DELETED
|
@@ -1,277 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
|
-
import { cli, output, outputError, withErrorHandling, parseJson } from '@cli4ai/lib/cli.ts';
|
|
3
|
-
|
|
4
|
-
const SHOP = process.env.SHOPIFY_SHOP;
|
|
5
|
-
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
|
|
6
|
-
const API_VERSION = '2024-01';
|
|
7
|
-
|
|
8
|
-
if (!SHOP || !TOKEN) {
|
|
9
|
-
// We rely on the MCP server or user to provide these
|
|
10
|
-
// But for direct CLI usage, we warn if missing, though @cli4ai/lib might handle envs?
|
|
11
|
-
// Actually, standard practice here is to fail if they are needed and not present.
|
|
12
|
-
// But we will let the individual commands fail or check at runtime.
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async function shopifyFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
16
|
-
if (!SHOP || !TOKEN) {
|
|
17
|
-
throw new Error('Missing SHOPIFY_SHOP or SHOPIFY_ACCESS_TOKEN environment variables');
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const url = `https://${SHOP}/admin/api/${API_VERSION}/${endpoint}`;
|
|
21
|
-
const response = await fetch(url, {
|
|
22
|
-
...options,
|
|
23
|
-
headers: {
|
|
24
|
-
'X-Shopify-Access-Token': TOKEN,
|
|
25
|
-
'Content-Type': 'application/json',
|
|
26
|
-
...options.headers,
|
|
27
|
-
},
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
if (!response.ok) {
|
|
31
|
-
const errorText = await response.text();
|
|
32
|
-
throw new Error(`Shopify API Error (${response.status}): ${errorText}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return response.json() as Promise<T>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const program = cli('shopify', '1.0.0', 'Shopify CLI wrapper');
|
|
39
|
-
|
|
40
|
-
program
|
|
41
|
-
.command('orders')
|
|
42
|
-
.description('List recent orders')
|
|
43
|
-
.option('-l, --limit <number>', 'Number of orders', '10')
|
|
44
|
-
.option('-s, --status <status>', 'Order status', 'any')
|
|
45
|
-
.action(withErrorHandling(async (options: { limit: string; status: string }) => {
|
|
46
|
-
const data = await shopifyFetch<{ orders: any[] }>(`orders.json?limit=${options.limit}&status=${options.status}`);
|
|
47
|
-
const formatted = data.orders.map(o => ({
|
|
48
|
-
id: o.id,
|
|
49
|
-
order_number: o.order_number,
|
|
50
|
-
created_at: o.created_at,
|
|
51
|
-
total_price: o.total_price,
|
|
52
|
-
currency: o.currency,
|
|
53
|
-
customer: o.customer ? `${o.customer.first_name} ${o.customer.last_name}` : 'No Customer',
|
|
54
|
-
financial_status: o.financial_status,
|
|
55
|
-
fulfillment_status: o.fulfillment_status
|
|
56
|
-
}));
|
|
57
|
-
output(formatted);
|
|
58
|
-
}));
|
|
59
|
-
|
|
60
|
-
program
|
|
61
|
-
.command('products')
|
|
62
|
-
.description('List products')
|
|
63
|
-
.option('-l, --limit <number>', 'Number of products', '10')
|
|
64
|
-
.option('-q, --query <query>', 'Search query') // Basic title search if needed, or just list
|
|
65
|
-
.action(withErrorHandling(async (options: { limit: string; query?: string }) => {
|
|
66
|
-
let url = `products.json?limit=${options.limit}`;
|
|
67
|
-
if (options.query) {
|
|
68
|
-
// Shopify products endpoint doesn't support generic 'query' well in REST without specific fields
|
|
69
|
-
// but we can use 'title' if intended, or just fetch and filter.
|
|
70
|
-
// Better: Just fetch recent. If query is needed, use GraphQL or other endpoints.
|
|
71
|
-
// For now, ignoring query in REST url unless we map it to title or similar.
|
|
72
|
-
// Actually, standard REST has no fuzzy search. We'll just list.
|
|
73
|
-
// Or we can rely on `title` param if exact match? No.
|
|
74
|
-
// Let's just list recent.
|
|
75
|
-
if (options.query) console.warn('Note: "query" parameter is limited in REST API, listing recent products.');
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const data = await shopifyFetch<{ products: any[] }>(url);
|
|
79
|
-
const formatted = data.products.map(p => ({
|
|
80
|
-
id: p.id,
|
|
81
|
-
title: p.title,
|
|
82
|
-
handle: p.handle,
|
|
83
|
-
status: p.status,
|
|
84
|
-
variants: p.variants.length,
|
|
85
|
-
inventory: p.variants.reduce((sum: number, v: any) => sum + (v.inventory_quantity || 0), 0)
|
|
86
|
-
}));
|
|
87
|
-
output(formatted);
|
|
88
|
-
}));
|
|
89
|
-
|
|
90
|
-
program
|
|
91
|
-
.command('inventory')
|
|
92
|
-
.description('Check inventory')
|
|
93
|
-
.option('-i, --ids <ids>', 'Variant IDs')
|
|
94
|
-
.option('-l, --limit <number>', 'Limit', '10')
|
|
95
|
-
.action(withErrorHandling(async (options: { ids?: string; limit: string }) => {
|
|
96
|
-
if (options.ids) {
|
|
97
|
-
const data = await shopifyFetch<{ variants: any[] }>(`variants.json?ids=${options.ids}`);
|
|
98
|
-
output(data.variants.map(v => ({
|
|
99
|
-
id: v.id,
|
|
100
|
-
product_id: v.product_id,
|
|
101
|
-
title: v.title, // Variant title
|
|
102
|
-
inventory: v.inventory_quantity
|
|
103
|
-
})));
|
|
104
|
-
} else {
|
|
105
|
-
// Fetch products and show inventory
|
|
106
|
-
const data = await shopifyFetch<{ products: any[] }>(`products.json?limit=${options.limit}`);
|
|
107
|
-
const inventory = data.products.flatMap(p => p.variants.map((v: any) => ({
|
|
108
|
-
product: p.title,
|
|
109
|
-
variant: v.title,
|
|
110
|
-
sku: v.sku,
|
|
111
|
-
inventory: v.inventory_quantity
|
|
112
|
-
})));
|
|
113
|
-
output(inventory);
|
|
114
|
-
}
|
|
115
|
-
}));
|
|
116
|
-
|
|
117
|
-
program
|
|
118
|
-
.command('reports')
|
|
119
|
-
.description('Sales summary')
|
|
120
|
-
.option('--since <date>', 'Start date YYYY-MM-DD')
|
|
121
|
-
.action(withErrorHandling(async (options: { since?: string }) => {
|
|
122
|
-
let url = 'orders.json?status=any&limit=250'; // 250 is max
|
|
123
|
-
if (options.since) {
|
|
124
|
-
url += `&created_at_min=${options.since}T00:00:00`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const data = await shopifyFetch<{ orders: any[] }>(url);
|
|
128
|
-
|
|
129
|
-
let totalSales = 0;
|
|
130
|
-
let orderCount = data.orders.length;
|
|
131
|
-
let currency = data.orders[0]?.currency || 'USD';
|
|
132
|
-
|
|
133
|
-
for (const order of data.orders) {
|
|
134
|
-
// Very basic: sum total_price of valid orders
|
|
135
|
-
// Exclude cancelled/voided if strictly sales?
|
|
136
|
-
// For summary, let's just sum total_price.
|
|
137
|
-
totalSales += parseFloat(order.total_price);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
output({
|
|
141
|
-
period: options.since ? `Since ${options.since}` : 'Recent 250 orders',
|
|
142
|
-
order_count: orderCount,
|
|
143
|
-
total_sales: totalSales.toFixed(2),
|
|
144
|
-
currency
|
|
145
|
-
});
|
|
146
|
-
}));
|
|
147
|
-
|
|
148
|
-
program
|
|
149
|
-
.command('seo_audit')
|
|
150
|
-
.description('Check SEO data')
|
|
151
|
-
.argument('<handle>', 'Resource handle')
|
|
152
|
-
.argument('<type>', 'Resource type (products, pages, articles)')
|
|
153
|
-
.action(withErrorHandling(async (handle: string, type: string) => {
|
|
154
|
-
// We need to find ID from handle? REST API often requires ID.
|
|
155
|
-
// Except `products` can be filtered by handle? No, REST `products` endpoint filters are limited.
|
|
156
|
-
// But `products.json?handle=...` exists? No.
|
|
157
|
-
// Wait, checking Shopify API docs... `GET /admin/api/2024-01/products.json?handle=...` DOES work to find by handle.
|
|
158
|
-
|
|
159
|
-
let endpoint = '';
|
|
160
|
-
let resourceKey = '';
|
|
161
|
-
|
|
162
|
-
if (type === 'products' || type === 'product') {
|
|
163
|
-
endpoint = `products.json?handle=${handle}`;
|
|
164
|
-
resourceKey = 'products';
|
|
165
|
-
} else if (type === 'pages' || type === 'page') {
|
|
166
|
-
endpoint = `pages.json?handle=${handle}`;
|
|
167
|
-
resourceKey = 'pages';
|
|
168
|
-
} else if (type === 'articles' || type === 'article') {
|
|
169
|
-
// Articles need blog_id usually, handle is not unique globally?
|
|
170
|
-
// Actually `articles` endpoint exists but scoping is tricky.
|
|
171
|
-
// Let's assume user provides handle, but finding it might be hard without blog_id.
|
|
172
|
-
// For simplicity, let's fail or ask for blog_id.
|
|
173
|
-
// Or simpler: just support products/pages for now as they are global.
|
|
174
|
-
// But wait, user asked for "seo".
|
|
175
|
-
throw new Error('For articles, use "articles" command to find ID then inspect. "seo_audit" currently supports: products, pages.');
|
|
176
|
-
} else {
|
|
177
|
-
throw new Error('Unknown type. Use: products, pages');
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const data = await shopifyFetch<any>(endpoint);
|
|
181
|
-
const resources = data[resourceKey];
|
|
182
|
-
|
|
183
|
-
if (!resources || resources.length === 0) {
|
|
184
|
-
throw new Error(`No ${type} found with handle "${handle}"`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const item = resources[0];
|
|
188
|
-
|
|
189
|
-
// Analyze
|
|
190
|
-
const analysis = {
|
|
191
|
-
id: item.id,
|
|
192
|
-
title: item.title,
|
|
193
|
-
handle: item.handle,
|
|
194
|
-
meta_title: item.metafields_global_title_tag || 'Not set (uses title)',
|
|
195
|
-
meta_description: item.metafields_global_description_tag || 'Not set (uses body excerpt)',
|
|
196
|
-
body_length: item.body_html ? item.body_html.length : 0,
|
|
197
|
-
created_at: item.created_at,
|
|
198
|
-
warnings: [] as string[]
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
if (!analysis.meta_title) analysis.warnings.push('Missing explicit meta title');
|
|
202
|
-
if (!analysis.meta_description) analysis.warnings.push('Missing explicit meta description');
|
|
203
|
-
if (analysis.title.length > 70) analysis.warnings.push('Title too long (>70 chars)');
|
|
204
|
-
|
|
205
|
-
output(analysis);
|
|
206
|
-
}));
|
|
207
|
-
|
|
208
|
-
program
|
|
209
|
-
.command('blogs')
|
|
210
|
-
.description('List blogs')
|
|
211
|
-
.action(withErrorHandling(async () => {
|
|
212
|
-
const data = await shopifyFetch<{ blogs: any[] }>('blogs.json');
|
|
213
|
-
output(data.blogs.map(b => ({
|
|
214
|
-
id: b.id,
|
|
215
|
-
title: b.title,
|
|
216
|
-
handle: b.handle,
|
|
217
|
-
commentable: b.commentable
|
|
218
|
-
})));
|
|
219
|
-
}));
|
|
220
|
-
|
|
221
|
-
program
|
|
222
|
-
.command('articles')
|
|
223
|
-
.description('List articles')
|
|
224
|
-
.argument('<blog_id>', 'Blog ID')
|
|
225
|
-
.option('-l, --limit <number>', 'Limit', '10')
|
|
226
|
-
.action(withErrorHandling(async (blogId: string, options: { limit: string }) => {
|
|
227
|
-
const data = await shopifyFetch<{ articles: any[] }>(`blogs/${blogId}/articles.json?limit=${options.limit}`);
|
|
228
|
-
output(data.articles.map(a => ({
|
|
229
|
-
id: a.id,
|
|
230
|
-
title: a.title,
|
|
231
|
-
author: a.author,
|
|
232
|
-
created_at: a.created_at,
|
|
233
|
-
tags: a.tags,
|
|
234
|
-
published: a.published
|
|
235
|
-
})));
|
|
236
|
-
}));
|
|
237
|
-
|
|
238
|
-
program
|
|
239
|
-
.command('article_create')
|
|
240
|
-
.description('Create article')
|
|
241
|
-
.argument('<blog_id>', 'Blog ID')
|
|
242
|
-
.argument('<title>', 'Title')
|
|
243
|
-
.argument('<author>', 'Author')
|
|
244
|
-
.argument('[tags]', 'Tags (comma separated)')
|
|
245
|
-
.argument('[summary_html]', 'Summary HTML')
|
|
246
|
-
.action(withErrorHandling(async (blogId: string, title: string, author: string, tags?: string, summaryHtml?: string) => {
|
|
247
|
-
// We create a basic article.
|
|
248
|
-
// Note: User might want to set body_html.
|
|
249
|
-
// Since args are limited in CLI, maybe we just set summary or basic body.
|
|
250
|
-
// Real usage would likely involve piping content or a more complex object.
|
|
251
|
-
// For now, we set body_html = summary_html to ensure it has content if provided.
|
|
252
|
-
|
|
253
|
-
const payload = {
|
|
254
|
-
article: {
|
|
255
|
-
title,
|
|
256
|
-
author,
|
|
257
|
-
tags: tags || '',
|
|
258
|
-
summary_html: summaryHtml || '',
|
|
259
|
-
body_html: summaryHtml || '<p>Content pending...</p>',
|
|
260
|
-
published: true
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const data = await shopifyFetch<{ article: any }>(`blogs/${blogId}/articles.json`, {
|
|
265
|
-
method: 'POST',
|
|
266
|
-
body: JSON.stringify(payload)
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
output({
|
|
270
|
-
id: data.article.id,
|
|
271
|
-
title: data.article.title,
|
|
272
|
-
url: `.../blogs/${blogId}/${data.article.id}`, // Construct url if possible
|
|
273
|
-
status: 'Created'
|
|
274
|
-
});
|
|
275
|
-
}));
|
|
276
|
-
|
|
277
|
-
program.parse();
|