@ktmcp-cli/akeneo 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.md +47 -0
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/bin/akeneo.js +2 -0
- package/package.json +27 -0
- package/src/api.js +213 -0
- package/src/config.js +19 -0
- package/src/index.js +430 -0
package/AGENT.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Akeneo PIM CLI - AI Agent Guide
|
|
2
|
+
|
|
3
|
+
This CLI provides programmatic access to the Akeneo Product Information Management (PIM) API.
|
|
4
|
+
|
|
5
|
+
## Quick Start for AI Agents
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
akeneo config set --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET \
|
|
9
|
+
--username YOUR_USERNAME --password YOUR_PASSWORD
|
|
10
|
+
akeneo config set --base-url https://your-instance.akeneo.com/api/rest/v1
|
|
11
|
+
akeneo products list
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Available Commands
|
|
15
|
+
|
|
16
|
+
### config
|
|
17
|
+
- `akeneo config set --client-id <id> --client-secret <secret> --username <user> --password <pass>` - Set credentials
|
|
18
|
+
- `akeneo config set --base-url <url>` - Set API base URL
|
|
19
|
+
- `akeneo config get <key>` - Get a config value
|
|
20
|
+
- `akeneo config list` - List all config values
|
|
21
|
+
|
|
22
|
+
### products
|
|
23
|
+
- `akeneo products list` - List products
|
|
24
|
+
- `akeneo products list --limit <n>` - List with limit
|
|
25
|
+
- `akeneo products get <identifier>` - Get product details
|
|
26
|
+
- `akeneo products create --identifier <sku> --family <family>` - Create a product
|
|
27
|
+
|
|
28
|
+
### categories
|
|
29
|
+
- `akeneo categories list` - List categories
|
|
30
|
+
- `akeneo categories get <code>` - Get category details
|
|
31
|
+
- `akeneo categories create --code <code> --labels <json>` - Create a category
|
|
32
|
+
|
|
33
|
+
### attributes
|
|
34
|
+
- `akeneo attributes list` - List attributes
|
|
35
|
+
- `akeneo attributes list --type <type>` - List by type
|
|
36
|
+
- `akeneo attributes get <code>` - Get attribute details
|
|
37
|
+
- `akeneo attributes create --code <code> --type <type>` - Create attribute
|
|
38
|
+
|
|
39
|
+
## Output Format
|
|
40
|
+
|
|
41
|
+
All commands output formatted tables by default. Use `--json` flag for machine-readable JSON output.
|
|
42
|
+
|
|
43
|
+
## Authentication
|
|
44
|
+
|
|
45
|
+
This CLI uses Akeneo's OAuth2 password grant flow. You need client credentials from your Akeneo PIM instance
|
|
46
|
+
(Settings > API Connections) plus a valid username and password.
|
|
47
|
+
Access tokens are automatically refreshed when they expire.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 KTMCP
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
> "Six months ago, everyone was talking about MCPs. And I was like, screw MCPs. Every MCP would be better as a CLI."
|
|
2
|
+
>
|
|
3
|
+
> — [Peter Steinberger](https://twitter.com/steipete), Founder of OpenClaw
|
|
4
|
+
> [Watch on YouTube (~2:39:00)](https://www.youtube.com/@lexfridman) | [Lex Fridman Podcast #491](https://lexfridman.com/peter-steinberger/)
|
|
5
|
+
|
|
6
|
+
# Akeneo PIM CLI
|
|
7
|
+
|
|
8
|
+
Production-ready CLI for Akeneo PIM (Product Information Management) API.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g @ktmcp-cli/akeneo
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Configuration
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
akeneo config set --client-id YOUR_CLIENT_ID \
|
|
20
|
+
--client-secret YOUR_CLIENT_SECRET \
|
|
21
|
+
--username YOUR_USERNAME \
|
|
22
|
+
--password YOUR_PASSWORD
|
|
23
|
+
akeneo config set --base-url https://your-akeneo-instance.com/api/rest/v1
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Get API credentials from your Akeneo PIM instance under Settings > API Connections.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Products
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# List products
|
|
34
|
+
akeneo products list
|
|
35
|
+
akeneo products list --limit 50
|
|
36
|
+
|
|
37
|
+
# Get product details
|
|
38
|
+
akeneo products get my-product-sku
|
|
39
|
+
|
|
40
|
+
# Create a product
|
|
41
|
+
akeneo products create --identifier new-sku-001 \
|
|
42
|
+
--family clothing \
|
|
43
|
+
--categories "summer,t-shirts" \
|
|
44
|
+
--values '{"name":[{"locale":"en_US","scope":null,"data":"My Product"}]}'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Categories
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# List categories
|
|
51
|
+
akeneo categories list
|
|
52
|
+
akeneo categories list --limit 100
|
|
53
|
+
|
|
54
|
+
# Get category details
|
|
55
|
+
akeneo categories get electronics
|
|
56
|
+
|
|
57
|
+
# Create a category
|
|
58
|
+
akeneo categories create --code summer-collection \
|
|
59
|
+
--parent clothing \
|
|
60
|
+
--labels '{"en_US":"Summer Collection","fr_FR":"Collection Été"}'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Attributes
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# List attributes
|
|
67
|
+
akeneo attributes list
|
|
68
|
+
akeneo attributes list --type pim_catalog_text
|
|
69
|
+
|
|
70
|
+
# Get attribute details
|
|
71
|
+
akeneo attributes get color
|
|
72
|
+
|
|
73
|
+
# Create an attribute
|
|
74
|
+
akeneo attributes create \
|
|
75
|
+
--code material \
|
|
76
|
+
--type pim_catalog_text \
|
|
77
|
+
--group product_info \
|
|
78
|
+
--localizable \
|
|
79
|
+
--labels '{"en_US":"Material","fr_FR":"Matériau"}'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Configuration
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
akeneo config set --client-id <id>
|
|
86
|
+
akeneo config get username
|
|
87
|
+
akeneo config list
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Attribute Types
|
|
91
|
+
|
|
92
|
+
- `pim_catalog_text` - Single-line text
|
|
93
|
+
- `pim_catalog_textarea` - Multi-line text
|
|
94
|
+
- `pim_catalog_number` - Numeric value
|
|
95
|
+
- `pim_catalog_boolean` - Yes/No toggle
|
|
96
|
+
- `pim_catalog_simpleselect` - Single choice from options
|
|
97
|
+
- `pim_catalog_multiselect` - Multiple choices
|
|
98
|
+
- `pim_catalog_date` - Date picker
|
|
99
|
+
- `pim_catalog_image` - Image file
|
|
100
|
+
- `pim_catalog_price_collection` - Price in multiple currencies
|
|
101
|
+
- `pim_catalog_metric` - Measurement with unit
|
|
102
|
+
|
|
103
|
+
## JSON Output
|
|
104
|
+
|
|
105
|
+
All commands support `--json` flag for machine-readable output:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
akeneo products list --json
|
|
109
|
+
akeneo attributes get color --json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
package/bin/akeneo.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ktmcp-cli/akeneo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Production-ready CLI for Akeneo PIM (Product Information Management) API - Kill The MCP",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"akeneo": "bin/akeneo.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": ["akeneo", "pim", "cli", "api", "ktmcp", "product-management", "ecommerce"],
|
|
11
|
+
"author": "KTMCP",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"commander": "^12.0.0",
|
|
15
|
+
"axios": "^1.6.7",
|
|
16
|
+
"chalk": "^5.3.0",
|
|
17
|
+
"ora": "^8.0.1",
|
|
18
|
+
"conf": "^12.0.0"
|
|
19
|
+
},
|
|
20
|
+
"engines": { "node": ">=18.0.0" },
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/ktmcp-cli/akeneo.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://killthemcp.com/akeneo-cli",
|
|
26
|
+
"bugs": { "url": "https://github.com/ktmcp-cli/akeneo/issues" }
|
|
27
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { getConfig, setConfig } from './config.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BASE_URL = 'https://demo.akeneo.com/api/rest/v1';
|
|
5
|
+
|
|
6
|
+
async function getAccessToken() {
|
|
7
|
+
const baseUrl = getConfig('baseUrl') || DEFAULT_BASE_URL;
|
|
8
|
+
const clientId = getConfig('clientId');
|
|
9
|
+
const clientSecret = getConfig('clientSecret');
|
|
10
|
+
const username = getConfig('username');
|
|
11
|
+
const password = getConfig('password');
|
|
12
|
+
|
|
13
|
+
// Check if we have a valid cached token
|
|
14
|
+
const cachedToken = getConfig('accessToken');
|
|
15
|
+
const tokenExpiry = getConfig('tokenExpiry');
|
|
16
|
+
if (cachedToken && tokenExpiry && Date.now() < tokenExpiry) {
|
|
17
|
+
return cachedToken;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Get the base URL without /api/rest/v1
|
|
21
|
+
const authBaseUrl = baseUrl.replace('/api/rest/v1', '');
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const response = await axios.post(`${authBaseUrl}/api/oauth/v1/token`, {
|
|
25
|
+
grant_type: 'password',
|
|
26
|
+
client_id: clientId,
|
|
27
|
+
client_secret: clientSecret,
|
|
28
|
+
username,
|
|
29
|
+
password
|
|
30
|
+
}, {
|
|
31
|
+
headers: { 'Content-Type': 'application/json' }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const { access_token, expires_in } = response.data;
|
|
35
|
+
setConfig('accessToken', access_token);
|
|
36
|
+
setConfig('tokenExpiry', Date.now() + (expires_in * 1000) - 60000);
|
|
37
|
+
return access_token;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.response) {
|
|
40
|
+
const msg = error.response.data?.message || JSON.stringify(error.response.data);
|
|
41
|
+
throw new Error(`Authentication failed: ${msg}`);
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Cannot connect to Akeneo at ${authBaseUrl}. Check your base URL.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function getClient() {
|
|
48
|
+
const token = await getAccessToken();
|
|
49
|
+
const baseURL = getConfig('baseUrl') || DEFAULT_BASE_URL;
|
|
50
|
+
|
|
51
|
+
return axios.create({
|
|
52
|
+
baseURL,
|
|
53
|
+
headers: {
|
|
54
|
+
'Authorization': `Bearer ${token}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'Accept': 'application/json'
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleApiError(error) {
|
|
62
|
+
if (error.response) {
|
|
63
|
+
const status = error.response.status;
|
|
64
|
+
const data = error.response.data;
|
|
65
|
+
if (status === 401) throw new Error('Authentication failed. Run: akeneo config set --client-id ... --client-secret ... --username ... --password ...');
|
|
66
|
+
if (status === 403) throw new Error('Access forbidden. Check your Akeneo user permissions.');
|
|
67
|
+
if (status === 404) throw new Error('Resource not found in Akeneo.');
|
|
68
|
+
if (status === 422) throw new Error(`Validation error: ${JSON.stringify(data?.errors || data)}`);
|
|
69
|
+
if (status === 429) throw new Error('Rate limit exceeded. Please wait before retrying.');
|
|
70
|
+
const message = data?.message || JSON.stringify(data);
|
|
71
|
+
throw new Error(`API Error (${status}): ${message}`);
|
|
72
|
+
} else if (error.request) {
|
|
73
|
+
const baseURL = getConfig('baseUrl') || DEFAULT_BASE_URL;
|
|
74
|
+
throw new Error(`No response from Akeneo at ${baseURL}. Check your instance URL.`);
|
|
75
|
+
} else {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseAkeneoList(response) {
|
|
81
|
+
// Akeneo returns paginated results in HAL format
|
|
82
|
+
const data = response.data;
|
|
83
|
+
if (data._embedded && data._embedded.items) {
|
|
84
|
+
return data._embedded.items;
|
|
85
|
+
}
|
|
86
|
+
return Array.isArray(data) ? data : [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ============================================================
|
|
90
|
+
// PRODUCTS
|
|
91
|
+
// ============================================================
|
|
92
|
+
|
|
93
|
+
export async function listProducts({ limit = 20, page = 1, search } = {}) {
|
|
94
|
+
try {
|
|
95
|
+
const client = await getClient();
|
|
96
|
+
const params = { limit, page };
|
|
97
|
+
if (search) params.search = search;
|
|
98
|
+
const response = await client.get('/products', { params });
|
|
99
|
+
return parseAkeneoList(response);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
handleApiError(error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function getProduct(code) {
|
|
106
|
+
try {
|
|
107
|
+
const client = await getClient();
|
|
108
|
+
const response = await client.get(`/products/${code}`);
|
|
109
|
+
return response.data;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
handleApiError(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function createProduct({ identifier, family, categories, values }) {
|
|
116
|
+
try {
|
|
117
|
+
const client = await getClient();
|
|
118
|
+
const body = {
|
|
119
|
+
identifier,
|
|
120
|
+
...(family && { family }),
|
|
121
|
+
...(categories && { categories }),
|
|
122
|
+
...(values && { values })
|
|
123
|
+
};
|
|
124
|
+
await client.post('/products', body);
|
|
125
|
+
return { identifier, family, categories };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
handleApiError(error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================
|
|
132
|
+
// CATEGORIES
|
|
133
|
+
// ============================================================
|
|
134
|
+
|
|
135
|
+
export async function listCategories({ limit = 20, page = 1 } = {}) {
|
|
136
|
+
try {
|
|
137
|
+
const client = await getClient();
|
|
138
|
+
const params = { limit, page };
|
|
139
|
+
const response = await client.get('/categories', { params });
|
|
140
|
+
return parseAkeneoList(response);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
handleApiError(error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function getCategory(code) {
|
|
147
|
+
try {
|
|
148
|
+
const client = await getClient();
|
|
149
|
+
const response = await client.get(`/categories/${code}`);
|
|
150
|
+
return response.data;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
handleApiError(error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function createCategory({ code, parent, labels }) {
|
|
157
|
+
try {
|
|
158
|
+
const client = await getClient();
|
|
159
|
+
const body = {
|
|
160
|
+
code,
|
|
161
|
+
...(parent && { parent }),
|
|
162
|
+
...(labels && { labels })
|
|
163
|
+
};
|
|
164
|
+
await client.post('/categories', body);
|
|
165
|
+
return { code, parent, labels };
|
|
166
|
+
} catch (error) {
|
|
167
|
+
handleApiError(error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================
|
|
172
|
+
// ATTRIBUTES
|
|
173
|
+
// ============================================================
|
|
174
|
+
|
|
175
|
+
export async function listAttributes({ limit = 20, page = 1, type } = {}) {
|
|
176
|
+
try {
|
|
177
|
+
const client = await getClient();
|
|
178
|
+
const params = { limit, page };
|
|
179
|
+
if (type) params.search = JSON.stringify({ type: [{ operator: '=', value: type }] });
|
|
180
|
+
const response = await client.get('/attributes', { params });
|
|
181
|
+
return parseAkeneoList(response);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
handleApiError(error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function getAttribute(code) {
|
|
188
|
+
try {
|
|
189
|
+
const client = await getClient();
|
|
190
|
+
const response = await client.get(`/attributes/${code}`);
|
|
191
|
+
return response.data;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
handleApiError(error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function createAttribute({ code, type, group, labels, scopable = false, localizable = false }) {
|
|
198
|
+
try {
|
|
199
|
+
const client = await getClient();
|
|
200
|
+
const body = {
|
|
201
|
+
code,
|
|
202
|
+
type,
|
|
203
|
+
group: group || 'other',
|
|
204
|
+
scopable,
|
|
205
|
+
localizable,
|
|
206
|
+
...(labels && { labels })
|
|
207
|
+
};
|
|
208
|
+
await client.post('/attributes', body);
|
|
209
|
+
return { code, type, group: group || 'other' };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
handleApiError(error);
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
|
|
3
|
+
const config = new Conf({ projectName: '@ktmcp-cli/akeneo' });
|
|
4
|
+
|
|
5
|
+
export function getConfig(key) {
|
|
6
|
+
return config.get(key);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function setConfig(key, value) {
|
|
10
|
+
config.set(key, value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isConfigured() {
|
|
14
|
+
return !!config.get('clientId') && !!config.get('clientSecret') && !!config.get('username') && !!config.get('password');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getAllConfig() {
|
|
18
|
+
return config.store;
|
|
19
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { getConfig, setConfig, isConfigured, getAllConfig } from './config.js';
|
|
5
|
+
import {
|
|
6
|
+
listProducts, getProduct, createProduct,
|
|
7
|
+
listCategories, getCategory, createCategory,
|
|
8
|
+
listAttributes, getAttribute, createAttribute
|
|
9
|
+
} from './api.js';
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
// ============================================================
|
|
14
|
+
// Helpers
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
function printSuccess(message) {
|
|
18
|
+
console.log(chalk.green('✓') + ' ' + message);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printError(message) {
|
|
22
|
+
console.error(chalk.red('✗') + ' ' + message);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function printTable(data, columns) {
|
|
26
|
+
if (!data || data.length === 0) {
|
|
27
|
+
console.log(chalk.yellow('No results found.'));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const widths = {};
|
|
31
|
+
columns.forEach(col => {
|
|
32
|
+
widths[col.key] = col.label.length;
|
|
33
|
+
data.forEach(row => {
|
|
34
|
+
const val = String(col.format ? col.format(row[col.key], row) : (row[col.key] ?? ''));
|
|
35
|
+
if (val.length > widths[col.key]) widths[col.key] = val.length;
|
|
36
|
+
});
|
|
37
|
+
widths[col.key] = Math.min(widths[col.key], 45);
|
|
38
|
+
});
|
|
39
|
+
const header = columns.map(col => col.label.padEnd(widths[col.key])).join(' ');
|
|
40
|
+
console.log(chalk.bold(chalk.cyan(header)));
|
|
41
|
+
console.log(chalk.dim('─'.repeat(header.length)));
|
|
42
|
+
data.forEach(row => {
|
|
43
|
+
const line = columns.map(col => {
|
|
44
|
+
const val = String(col.format ? col.format(row[col.key], row) : (row[col.key] ?? ''));
|
|
45
|
+
return val.substring(0, widths[col.key]).padEnd(widths[col.key]);
|
|
46
|
+
}).join(' ');
|
|
47
|
+
console.log(line);
|
|
48
|
+
});
|
|
49
|
+
console.log(chalk.dim(`\n${data.length} result(s)`));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function printJson(data) {
|
|
53
|
+
console.log(JSON.stringify(data, null, 2));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function withSpinner(message, fn) {
|
|
57
|
+
const spinner = ora(message).start();
|
|
58
|
+
try {
|
|
59
|
+
const result = await fn();
|
|
60
|
+
spinner.stop();
|
|
61
|
+
return result;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
spinner.stop();
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requireAuth() {
|
|
69
|
+
if (!isConfigured()) {
|
|
70
|
+
printError('Akeneo credentials not configured.');
|
|
71
|
+
console.log('\nRun the following to configure:');
|
|
72
|
+
console.log(chalk.cyan(' akeneo config set --client-id <id> --client-secret <secret> --username <user> --password <pass>'));
|
|
73
|
+
console.log(chalk.cyan(' akeneo config set --base-url https://your-akeneo-instance.com/api/rest/v1'));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================
|
|
79
|
+
// Program metadata
|
|
80
|
+
// ============================================================
|
|
81
|
+
|
|
82
|
+
program
|
|
83
|
+
.name('akeneo')
|
|
84
|
+
.description(chalk.bold('Akeneo PIM CLI') + ' - Product information management from your terminal')
|
|
85
|
+
.version('1.0.0');
|
|
86
|
+
|
|
87
|
+
// ============================================================
|
|
88
|
+
// CONFIG
|
|
89
|
+
// ============================================================
|
|
90
|
+
|
|
91
|
+
const configCmd = program.command('config').description('Manage CLI configuration');
|
|
92
|
+
|
|
93
|
+
configCmd
|
|
94
|
+
.command('set')
|
|
95
|
+
.description('Set configuration values')
|
|
96
|
+
.option('--client-id <id>', 'Akeneo OAuth2 client ID')
|
|
97
|
+
.option('--client-secret <secret>', 'Akeneo OAuth2 client secret')
|
|
98
|
+
.option('--username <user>', 'Akeneo username')
|
|
99
|
+
.option('--password <pass>', 'Akeneo password')
|
|
100
|
+
.option('--base-url <url>', 'Akeneo API base URL (default: https://demo.akeneo.com/api/rest/v1)')
|
|
101
|
+
.action((options) => {
|
|
102
|
+
if (options.clientId) { setConfig('clientId', options.clientId); printSuccess('Client ID set'); }
|
|
103
|
+
if (options.clientSecret) { setConfig('clientSecret', options.clientSecret); printSuccess('Client secret set'); }
|
|
104
|
+
if (options.username) { setConfig('username', options.username); printSuccess(`Username set: ${options.username}`); }
|
|
105
|
+
if (options.password) { setConfig('password', options.password); printSuccess('Password set'); }
|
|
106
|
+
if (options.baseUrl) { setConfig('baseUrl', options.baseUrl); printSuccess(`Base URL set: ${options.baseUrl}`); }
|
|
107
|
+
if (!options.clientId && !options.clientSecret && !options.username && !options.password && !options.baseUrl) {
|
|
108
|
+
printError('No options provided. Use --client-id, --client-secret, --username, --password, or --base-url');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
configCmd
|
|
113
|
+
.command('get <key>')
|
|
114
|
+
.description('Get a configuration value')
|
|
115
|
+
.action((key) => {
|
|
116
|
+
const value = getConfig(key);
|
|
117
|
+
if (value === undefined) {
|
|
118
|
+
printError(`Key "${key}" not found`);
|
|
119
|
+
} else {
|
|
120
|
+
const sensitive = ['clientSecret', 'password', 'accessToken'];
|
|
121
|
+
console.log(sensitive.includes(key) ? '****' : value);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
configCmd
|
|
126
|
+
.command('list')
|
|
127
|
+
.description('List all configuration values')
|
|
128
|
+
.action(() => {
|
|
129
|
+
const all = getAllConfig();
|
|
130
|
+
console.log(chalk.bold('\nAkeneo PIM CLI Configuration\n'));
|
|
131
|
+
console.log('Client ID: ', all.clientId ? chalk.green(all.clientId) : chalk.red('not set'));
|
|
132
|
+
console.log('Client Secret: ', all.clientSecret ? chalk.green('****') : chalk.red('not set'));
|
|
133
|
+
console.log('Username: ', all.username ? chalk.green(all.username) : chalk.red('not set'));
|
|
134
|
+
console.log('Password: ', all.password ? chalk.green('****') : chalk.red('not set'));
|
|
135
|
+
console.log('Base URL: ', all.baseUrl ? chalk.green(all.baseUrl) : chalk.yellow('using default: https://demo.akeneo.com/api/rest/v1'));
|
|
136
|
+
console.log('');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ============================================================
|
|
140
|
+
// PRODUCTS
|
|
141
|
+
// ============================================================
|
|
142
|
+
|
|
143
|
+
const productsCmd = program.command('products').description('Manage PIM products');
|
|
144
|
+
|
|
145
|
+
productsCmd
|
|
146
|
+
.command('list')
|
|
147
|
+
.description('List products')
|
|
148
|
+
.option('--limit <n>', 'Maximum number of results', '20')
|
|
149
|
+
.option('--page <n>', 'Page number', '1')
|
|
150
|
+
.option('--search <query>', 'Search filter (JSON format)')
|
|
151
|
+
.option('--json', 'Output as JSON')
|
|
152
|
+
.action(async (options) => {
|
|
153
|
+
requireAuth();
|
|
154
|
+
try {
|
|
155
|
+
const products = await withSpinner('Fetching products...', () =>
|
|
156
|
+
listProducts({ limit: parseInt(options.limit), page: parseInt(options.page), search: options.search })
|
|
157
|
+
);
|
|
158
|
+
if (options.json) { printJson(products); return; }
|
|
159
|
+
printTable(products, [
|
|
160
|
+
{ key: 'identifier', label: 'Identifier' },
|
|
161
|
+
{ key: 'family', label: 'Family' },
|
|
162
|
+
{ key: 'enabled', label: 'Enabled', format: (v) => v ? chalk.green('Yes') : chalk.red('No') },
|
|
163
|
+
{ key: 'categories', label: 'Categories', format: (v) => Array.isArray(v) ? v.join(', ') : v || 'N/A' },
|
|
164
|
+
{ key: 'created', label: 'Created', format: (v) => v ? v.substring(0, 10) : 'N/A' },
|
|
165
|
+
{ key: 'updated', label: 'Updated', format: (v) => v ? v.substring(0, 10) : 'N/A' }
|
|
166
|
+
]);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
printError(error.message);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
productsCmd
|
|
174
|
+
.command('get <identifier>')
|
|
175
|
+
.description('Get details of a specific product')
|
|
176
|
+
.option('--json', 'Output as JSON')
|
|
177
|
+
.action(async (identifier, options) => {
|
|
178
|
+
requireAuth();
|
|
179
|
+
try {
|
|
180
|
+
const product = await withSpinner('Fetching product...', () => getProduct(identifier));
|
|
181
|
+
if (options.json) { printJson(product); return; }
|
|
182
|
+
console.log(chalk.bold('\nProduct Details\n'));
|
|
183
|
+
console.log('Identifier: ', chalk.cyan(product.identifier));
|
|
184
|
+
console.log('Family: ', product.family || 'N/A');
|
|
185
|
+
console.log('Enabled: ', product.enabled ? chalk.green('Yes') : chalk.red('No'));
|
|
186
|
+
console.log('Categories: ', Array.isArray(product.categories) ? product.categories.join(', ') : 'N/A');
|
|
187
|
+
console.log('Groups: ', Array.isArray(product.groups) ? product.groups.join(', ') : 'N/A');
|
|
188
|
+
console.log('Created: ', product.created || 'N/A');
|
|
189
|
+
console.log('Updated: ', product.updated || 'N/A');
|
|
190
|
+
if (product.values && Object.keys(product.values).length > 0) {
|
|
191
|
+
console.log(chalk.bold('\nAttributes (first 10):\n'));
|
|
192
|
+
const entries = Object.entries(product.values).slice(0, 10);
|
|
193
|
+
entries.forEach(([attr, vals]) => {
|
|
194
|
+
const firstVal = vals?.[0];
|
|
195
|
+
const displayVal = firstVal?.data !== undefined ?
|
|
196
|
+
(typeof firstVal.data === 'object' ? JSON.stringify(firstVal.data) : String(firstVal.data)) : 'N/A';
|
|
197
|
+
console.log(` ${chalk.cyan(attr.padEnd(25))} ${displayVal.substring(0, 50)}`);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
console.log('');
|
|
201
|
+
} catch (error) {
|
|
202
|
+
printError(error.message);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
productsCmd
|
|
208
|
+
.command('create')
|
|
209
|
+
.description('Create a new product')
|
|
210
|
+
.requiredOption('--identifier <id>', 'Product identifier (SKU)')
|
|
211
|
+
.option('--family <family>', 'Product family code')
|
|
212
|
+
.option('--categories <cats>', 'Comma-separated category codes')
|
|
213
|
+
.option('--values <json>', 'Product attribute values as JSON')
|
|
214
|
+
.option('--json', 'Output as JSON')
|
|
215
|
+
.action(async (options) => {
|
|
216
|
+
requireAuth();
|
|
217
|
+
let values;
|
|
218
|
+
if (options.values) {
|
|
219
|
+
try { values = JSON.parse(options.values); } catch {
|
|
220
|
+
printError('Invalid JSON for --values'); process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const categories = options.categories ? options.categories.split(',').map(c => c.trim()) : undefined;
|
|
224
|
+
try {
|
|
225
|
+
const product = await withSpinner('Creating product...', () =>
|
|
226
|
+
createProduct({ identifier: options.identifier, family: options.family, categories, values })
|
|
227
|
+
);
|
|
228
|
+
if (options.json) { printJson(product); return; }
|
|
229
|
+
printSuccess(`Product created: ${chalk.bold(options.identifier)}`);
|
|
230
|
+
if (options.family) console.log('Family: ', options.family);
|
|
231
|
+
if (categories) console.log('Categories: ', categories.join(', '));
|
|
232
|
+
} catch (error) {
|
|
233
|
+
printError(error.message);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ============================================================
|
|
239
|
+
// CATEGORIES
|
|
240
|
+
// ============================================================
|
|
241
|
+
|
|
242
|
+
const categoriesCmd = program.command('categories').description('Manage PIM categories');
|
|
243
|
+
|
|
244
|
+
categoriesCmd
|
|
245
|
+
.command('list')
|
|
246
|
+
.description('List categories')
|
|
247
|
+
.option('--limit <n>', 'Maximum number of results', '20')
|
|
248
|
+
.option('--json', 'Output as JSON')
|
|
249
|
+
.action(async (options) => {
|
|
250
|
+
requireAuth();
|
|
251
|
+
try {
|
|
252
|
+
const categories = await withSpinner('Fetching categories...', () =>
|
|
253
|
+
listCategories({ limit: parseInt(options.limit) })
|
|
254
|
+
);
|
|
255
|
+
if (options.json) { printJson(categories); return; }
|
|
256
|
+
printTable(categories, [
|
|
257
|
+
{ key: 'code', label: 'Code' },
|
|
258
|
+
{ key: 'parent', label: 'Parent', format: (v) => v || '(root)' },
|
|
259
|
+
{ key: 'labels', label: 'Label (en)', format: (v) => v?.en_US || Object.values(v || {})[0] || 'N/A' }
|
|
260
|
+
]);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
printError(error.message);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
categoriesCmd
|
|
268
|
+
.command('get <code>')
|
|
269
|
+
.description('Get details of a specific category')
|
|
270
|
+
.option('--json', 'Output as JSON')
|
|
271
|
+
.action(async (code, options) => {
|
|
272
|
+
requireAuth();
|
|
273
|
+
try {
|
|
274
|
+
const category = await withSpinner('Fetching category...', () => getCategory(code));
|
|
275
|
+
if (options.json) { printJson(category); return; }
|
|
276
|
+
console.log(chalk.bold('\nCategory Details\n'));
|
|
277
|
+
console.log('Code: ', chalk.cyan(category.code));
|
|
278
|
+
console.log('Parent: ', category.parent || '(root)');
|
|
279
|
+
if (category.labels) {
|
|
280
|
+
console.log('Labels:');
|
|
281
|
+
Object.entries(category.labels).forEach(([locale, label]) => {
|
|
282
|
+
console.log(` ${locale}: ${label}`);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
console.log('');
|
|
286
|
+
} catch (error) {
|
|
287
|
+
printError(error.message);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
categoriesCmd
|
|
293
|
+
.command('create')
|
|
294
|
+
.description('Create a new category')
|
|
295
|
+
.requiredOption('--code <code>', 'Category code')
|
|
296
|
+
.option('--parent <parent>', 'Parent category code')
|
|
297
|
+
.option('--labels <json>', 'Labels as JSON, e.g. \'{"en_US":"Electronics","fr_FR":"Électronique"}\'')
|
|
298
|
+
.option('--json', 'Output as JSON')
|
|
299
|
+
.action(async (options) => {
|
|
300
|
+
requireAuth();
|
|
301
|
+
let labels;
|
|
302
|
+
if (options.labels) {
|
|
303
|
+
try { labels = JSON.parse(options.labels); } catch {
|
|
304
|
+
printError('Invalid JSON for --labels'); process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const category = await withSpinner('Creating category...', () =>
|
|
309
|
+
createCategory({ code: options.code, parent: options.parent, labels })
|
|
310
|
+
);
|
|
311
|
+
if (options.json) { printJson(category); return; }
|
|
312
|
+
printSuccess(`Category created: ${chalk.bold(options.code)}`);
|
|
313
|
+
if (options.parent) console.log('Parent: ', options.parent);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
printError(error.message);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ============================================================
|
|
321
|
+
// ATTRIBUTES
|
|
322
|
+
// ============================================================
|
|
323
|
+
|
|
324
|
+
const attributesCmd = program.command('attributes').description('Manage PIM attributes');
|
|
325
|
+
|
|
326
|
+
attributesCmd
|
|
327
|
+
.command('list')
|
|
328
|
+
.description('List attributes')
|
|
329
|
+
.option('--limit <n>', 'Maximum number of results', '20')
|
|
330
|
+
.option('--type <type>', 'Filter by attribute type (pim_catalog_text|pim_catalog_number|pim_catalog_boolean|etc)')
|
|
331
|
+
.option('--json', 'Output as JSON')
|
|
332
|
+
.action(async (options) => {
|
|
333
|
+
requireAuth();
|
|
334
|
+
try {
|
|
335
|
+
const attributes = await withSpinner('Fetching attributes...', () =>
|
|
336
|
+
listAttributes({ limit: parseInt(options.limit), type: options.type })
|
|
337
|
+
);
|
|
338
|
+
if (options.json) { printJson(attributes); return; }
|
|
339
|
+
printTable(attributes, [
|
|
340
|
+
{ key: 'code', label: 'Code' },
|
|
341
|
+
{ key: 'type', label: 'Type' },
|
|
342
|
+
{ key: 'group', label: 'Group' },
|
|
343
|
+
{ key: 'localizable', label: 'Localizable', format: (v) => v ? 'Yes' : 'No' },
|
|
344
|
+
{ key: 'scopable', label: 'Scopable', format: (v) => v ? 'Yes' : 'No' },
|
|
345
|
+
{ key: 'labels', label: 'Label (en)', format: (v) => v?.en_US || Object.values(v || {})[0] || 'N/A' }
|
|
346
|
+
]);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
printError(error.message);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
attributesCmd
|
|
354
|
+
.command('get <code>')
|
|
355
|
+
.description('Get details of a specific attribute')
|
|
356
|
+
.option('--json', 'Output as JSON')
|
|
357
|
+
.action(async (code, options) => {
|
|
358
|
+
requireAuth();
|
|
359
|
+
try {
|
|
360
|
+
const attr = await withSpinner('Fetching attribute...', () => getAttribute(code));
|
|
361
|
+
if (options.json) { printJson(attr); return; }
|
|
362
|
+
console.log(chalk.bold('\nAttribute Details\n'));
|
|
363
|
+
console.log('Code: ', chalk.cyan(attr.code));
|
|
364
|
+
console.log('Type: ', chalk.bold(attr.type));
|
|
365
|
+
console.log('Group: ', attr.group || 'N/A');
|
|
366
|
+
console.log('Localizable: ', attr.localizable ? chalk.green('Yes') : 'No');
|
|
367
|
+
console.log('Scopable: ', attr.scopable ? chalk.green('Yes') : 'No');
|
|
368
|
+
console.log('Unique: ', attr.unique ? chalk.yellow('Yes') : 'No');
|
|
369
|
+
console.log('Required: ', attr.is_required ? chalk.yellow('Yes') : 'No');
|
|
370
|
+
if (attr.labels) {
|
|
371
|
+
console.log('Labels:');
|
|
372
|
+
Object.entries(attr.labels).forEach(([locale, label]) => {
|
|
373
|
+
console.log(` ${locale}: ${label}`);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
console.log('');
|
|
377
|
+
} catch (error) {
|
|
378
|
+
printError(error.message);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
attributesCmd
|
|
384
|
+
.command('create')
|
|
385
|
+
.description('Create a new attribute')
|
|
386
|
+
.requiredOption('--code <code>', 'Attribute code')
|
|
387
|
+
.requiredOption('--type <type>', 'Attribute type (pim_catalog_text|pim_catalog_number|pim_catalog_boolean|pim_catalog_textarea|pim_catalog_simpleselect|pim_catalog_multiselect|pim_catalog_date|pim_catalog_file|pim_catalog_image|pim_catalog_price_collection|pim_catalog_metric)')
|
|
388
|
+
.option('--group <group>', 'Attribute group code', 'other')
|
|
389
|
+
.option('--localizable', 'Make attribute localizable')
|
|
390
|
+
.option('--scopable', 'Make attribute scopable')
|
|
391
|
+
.option('--labels <json>', 'Labels as JSON, e.g. \'{"en_US":"Color","fr_FR":"Couleur"}\'')
|
|
392
|
+
.option('--json', 'Output as JSON')
|
|
393
|
+
.action(async (options) => {
|
|
394
|
+
requireAuth();
|
|
395
|
+
let labels;
|
|
396
|
+
if (options.labels) {
|
|
397
|
+
try { labels = JSON.parse(options.labels); } catch {
|
|
398
|
+
printError('Invalid JSON for --labels'); process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const attr = await withSpinner('Creating attribute...', () =>
|
|
403
|
+
createAttribute({
|
|
404
|
+
code: options.code,
|
|
405
|
+
type: options.type,
|
|
406
|
+
group: options.group,
|
|
407
|
+
localizable: !!options.localizable,
|
|
408
|
+
scopable: !!options.scopable,
|
|
409
|
+
labels
|
|
410
|
+
})
|
|
411
|
+
);
|
|
412
|
+
if (options.json) { printJson(attr); return; }
|
|
413
|
+
printSuccess(`Attribute created: ${chalk.bold(options.code)}`);
|
|
414
|
+
console.log('Type: ', options.type);
|
|
415
|
+
console.log('Group: ', options.group);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
printError(error.message);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// ============================================================
|
|
423
|
+
// Parse
|
|
424
|
+
// ============================================================
|
|
425
|
+
|
|
426
|
+
program.parse(process.argv);
|
|
427
|
+
|
|
428
|
+
if (process.argv.length <= 2) {
|
|
429
|
+
program.help();
|
|
430
|
+
}
|