@cloudcommerce/cli 2.37.4 → 2.38.1
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/lib/cli.js +6 -2
- package/lib/ext/import-feed.js +357 -0
- package/lib/login.js +6 -4
- package/package.json +5 -3
package/lib/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import login from './login.js';
|
|
|
9
9
|
import build, { prepareCodebases } from './build.js';
|
|
10
10
|
import { siginGcloudAndSetIAM, createServiceAccountKey } from './setup-gcloud.js';
|
|
11
11
|
import createGhSecrets from './setup-gh.js';
|
|
12
|
+
import importFeed from './ext/import-feed.js';
|
|
12
13
|
|
|
13
14
|
if (!process.env.FIREBASE_PROJECT_ID && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
14
15
|
const pwd = process.cwd();
|
|
@@ -151,7 +152,7 @@ ECOM_STORE_ID=${storeId}
|
|
|
151
152
|
}
|
|
152
153
|
if (hasCreatedAllSecrets) {
|
|
153
154
|
return echo`
|
|
154
|
-
|
|
155
|
+
****
|
|
155
156
|
|
|
156
157
|
CloudCommerce setup completed successfully.
|
|
157
158
|
Your store repository on GitHub is ready, the first deploy will automatically start with GH Actions.
|
|
@@ -160,7 +161,7 @@ Your store repository on GitHub is ready, the first deploy will automatically st
|
|
|
160
161
|
`;
|
|
161
162
|
}
|
|
162
163
|
return echo`
|
|
163
|
-
|
|
164
|
+
****
|
|
164
165
|
|
|
165
166
|
Finish by saving the following secrets to your GitHub repository:
|
|
166
167
|
|
|
@@ -185,6 +186,9 @@ Finish by saving the following secrets to your GitHub repository:
|
|
|
185
186
|
const port = typeof argv.port === 'string' || typeof argv.port === 'number' ? argv.port : '';
|
|
186
187
|
return $`npm --prefix "${prefix}" run dev -- --host ${host} --port ${port}`;
|
|
187
188
|
}
|
|
189
|
+
if (argv._.includes('import')) {
|
|
190
|
+
return importFeed();
|
|
191
|
+
}
|
|
188
192
|
return $`echo 'Hello from @cloudcommerce/cli'`;
|
|
189
193
|
};
|
|
190
194
|
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import {
|
|
2
|
+
argv, fs, echo, sleep,
|
|
3
|
+
} from 'zx';
|
|
4
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
5
|
+
import { imageSize } from 'image-size';
|
|
6
|
+
import api from '@cloudcommerce/api';
|
|
7
|
+
|
|
8
|
+
const clearAccents = (str) => {
|
|
9
|
+
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
10
|
+
};
|
|
11
|
+
const slugify = (name) => {
|
|
12
|
+
return clearAccents(name).toLocaleLowerCase()
|
|
13
|
+
.replace(/[\s\n/]/g, '-')
|
|
14
|
+
.replace(/[^a-z0-9_-]/ig, '');
|
|
15
|
+
};
|
|
16
|
+
const uploadPicture = async (downloadUrl, authHeaders) => {
|
|
17
|
+
const downloadRes = await fetch(downloadUrl);
|
|
18
|
+
if (!downloadRes.ok) {
|
|
19
|
+
throw new Error(`Failed downloading ${downloadUrl} with status ${downloadRes.status}`);
|
|
20
|
+
}
|
|
21
|
+
const contentType = downloadRes.headers.get('content-type');
|
|
22
|
+
if (!contentType) {
|
|
23
|
+
throw new Error(`No mime type returned for ${downloadUrl}`);
|
|
24
|
+
}
|
|
25
|
+
const imageBuffer = await downloadRes.arrayBuffer();
|
|
26
|
+
const imageBlob = new Blob([imageBuffer], { type: contentType });
|
|
27
|
+
const formData = new FormData();
|
|
28
|
+
const fileName = downloadUrl.split('/').pop() || 'image.jpg';
|
|
29
|
+
formData.append('file', imageBlob, fileName);
|
|
30
|
+
const uploadRes = await fetch('https://ecomplus.app/api/storage/upload.json', {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
body: formData,
|
|
33
|
+
headers: authHeaders,
|
|
34
|
+
});
|
|
35
|
+
if (!uploadRes.ok) {
|
|
36
|
+
const err = new Error(`Failed uploading ${downloadUrl} with ${uploadRes.status}`);
|
|
37
|
+
err.statusCode = uploadRes.status;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
const data = await uploadRes.json();
|
|
41
|
+
const { width, height } = imageSize(Buffer.from(imageBuffer));
|
|
42
|
+
const { picture } = data;
|
|
43
|
+
if (width && height) {
|
|
44
|
+
picture.zoom.size = `${width}x${height}`;
|
|
45
|
+
Object.keys(picture).forEach((thumb) => {
|
|
46
|
+
if (thumb === 'zoom' || !picture[thumb]) return;
|
|
47
|
+
const px = parseInt(picture[thumb].size, 10);
|
|
48
|
+
if (px) {
|
|
49
|
+
if (px >= Math.max(width, height)) {
|
|
50
|
+
picture[thumb].size = picture.zoom.size;
|
|
51
|
+
} else {
|
|
52
|
+
picture[thumb].size = width > height
|
|
53
|
+
? px + 'x' + Math.round((height * px) / width)
|
|
54
|
+
: Math.round((width * px) / height) + 'x' + px;
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
delete picture[thumb].size;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
Object.keys(picture).forEach((thumb) => {
|
|
62
|
+
if (!picture[thumb]) return;
|
|
63
|
+
delete picture[thumb].size;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (fileName) {
|
|
67
|
+
const alt = fileName.replace(/\.[^.]+$/, '');
|
|
68
|
+
Object.keys(picture).forEach((thumb) => {
|
|
69
|
+
if (!picture[thumb]) return;
|
|
70
|
+
picture[thumb].alt = alt;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return picture;
|
|
74
|
+
};
|
|
75
|
+
const importFeed = async () => {
|
|
76
|
+
const feedFilepath = typeof argv.feed === 'string' ? argv.feed : '';
|
|
77
|
+
if (!feedFilepath) {
|
|
78
|
+
await echo`You must specify XML file to import with --feed= argument`;
|
|
79
|
+
return process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const parser = new XMLParser({
|
|
82
|
+
ignoreAttributes: false,
|
|
83
|
+
attributeNamePrefix: '',
|
|
84
|
+
});
|
|
85
|
+
const json = parser.parse(fs.readFileSync(argv.feed, 'utf8'));
|
|
86
|
+
const items = json.rss?.channel?.item?.filter?.((item) => {
|
|
87
|
+
return item?.['g:id'] && item['g:title'];
|
|
88
|
+
});
|
|
89
|
+
if (!items?.[0] || typeof items?.[0] !== 'object') {
|
|
90
|
+
await echo`The XML file does not appear to be a valid RSS 2.0 product feed`;
|
|
91
|
+
return process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const ecomAuthHeaders = {
|
|
94
|
+
'X-Store-ID': process.env.ECOM_STORE_ID,
|
|
95
|
+
'X-My-ID': process.env.ECOM_AUTHENTICATION_ID,
|
|
96
|
+
};
|
|
97
|
+
const { ECOM_ACCESS_TOKEN, ECOM_API_KEY } = process.env;
|
|
98
|
+
if (ECOM_ACCESS_TOKEN) {
|
|
99
|
+
ecomAuthHeaders['X-Access-Token'] = ECOM_ACCESS_TOKEN;
|
|
100
|
+
} else {
|
|
101
|
+
const { data } = await api.post('authenticate', {
|
|
102
|
+
_id: process.env.ECOM_AUTHENTICATION_ID,
|
|
103
|
+
api_key: ECOM_API_KEY,
|
|
104
|
+
});
|
|
105
|
+
ecomAuthHeaders['X-Access-Token'] = data.access_token;
|
|
106
|
+
}
|
|
107
|
+
const categoryNames = [];
|
|
108
|
+
const brandNames = [];
|
|
109
|
+
items.forEach(async (item) => {
|
|
110
|
+
const categoryName = item['g:product_type']?.split('>').pop()?.trim();
|
|
111
|
+
if (categoryName && !categoryNames.includes(categoryName)) {
|
|
112
|
+
categoryNames.push(categoryName);
|
|
113
|
+
}
|
|
114
|
+
const brandName = item['g:brand']?.trim();
|
|
115
|
+
if (brandName && !brandNames.includes(brandName)) {
|
|
116
|
+
brandNames.push(brandName);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const categories = [];
|
|
120
|
+
/* eslint-disable no-await-in-loop */
|
|
121
|
+
for (let i = 0; i < categoryNames.length; i++) {
|
|
122
|
+
const categoryName = categoryNames[i];
|
|
123
|
+
const { data: { result } } = await api.get('categories', {
|
|
124
|
+
params: { name: categoryName },
|
|
125
|
+
fields: [
|
|
126
|
+
'name',
|
|
127
|
+
'i18n',
|
|
128
|
+
'slug',
|
|
129
|
+
'parent',
|
|
130
|
+
'ml_category_id',
|
|
131
|
+
'google_product_category_id',
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
if (result[0]) {
|
|
135
|
+
categories.push(result[0]);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const category = {
|
|
139
|
+
name: categoryName,
|
|
140
|
+
slug: slugify(categoryName),
|
|
141
|
+
};
|
|
142
|
+
const { data: { _id } } = await api.post('categories', category);
|
|
143
|
+
categories.push({ ...category, _id });
|
|
144
|
+
}
|
|
145
|
+
const brands = [];
|
|
146
|
+
for (let i = 0; i < brandNames.length; i++) {
|
|
147
|
+
const brandName = brandNames[i];
|
|
148
|
+
const { data: { result } } = await api.get('brands', {
|
|
149
|
+
params: { name: brandName },
|
|
150
|
+
fields: [
|
|
151
|
+
'name',
|
|
152
|
+
'i18n',
|
|
153
|
+
'slug',
|
|
154
|
+
'logo',
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
if (result[0]) {
|
|
158
|
+
brands.push(result[0]);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const brand = {
|
|
162
|
+
name: brandName,
|
|
163
|
+
slug: slugify(brandName),
|
|
164
|
+
};
|
|
165
|
+
const { data: { _id } } = await api.post('brands', brand);
|
|
166
|
+
brands.push({ ...brand, _id });
|
|
167
|
+
}
|
|
168
|
+
await echo`\nCategories:`;
|
|
169
|
+
for (let i = 0; i < categories.length; i++) {
|
|
170
|
+
await echo`${categories[i].name} (${categories[i].slug})`;
|
|
171
|
+
}
|
|
172
|
+
await echo`\nBrands:`;
|
|
173
|
+
for (let i = 0; i < brands.length; i++) {
|
|
174
|
+
await echo`${brands[i].name} (${brands[i].slug})`;
|
|
175
|
+
}
|
|
176
|
+
const productItems = items.filter((item) => !item['g:item_group_id']);
|
|
177
|
+
items.forEach((item) => {
|
|
178
|
+
const parentSku = item['g:item_group_id'];
|
|
179
|
+
if (!parentSku || productItems.find((i) => i['g:id'] === parentSku)) return;
|
|
180
|
+
productItems.push({
|
|
181
|
+
...item,
|
|
182
|
+
'g:id': parentSku,
|
|
183
|
+
'g:title': item['g:title']?.replace(/\s[A-Z]{1,3}$/, ''),
|
|
184
|
+
'g:description': item['g:title'] === item['g:description']
|
|
185
|
+
? undefined
|
|
186
|
+
: item['g:description'],
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
const productItemGroups = [];
|
|
190
|
+
for (let i = 0; i < productItems.length; i += 20) {
|
|
191
|
+
productItemGroups.push(productItems.slice(i, i + 20));
|
|
192
|
+
}
|
|
193
|
+
for (let i = 0; i < productItemGroups.length; i++) {
|
|
194
|
+
const { data: { result } } = await api.get('products', {
|
|
195
|
+
params: {
|
|
196
|
+
sku: productItemGroups[i].map(({ 'g:id': sku }) => sku),
|
|
197
|
+
},
|
|
198
|
+
fields: ['sku'],
|
|
199
|
+
});
|
|
200
|
+
for (let ii = 0; ii < productItems.length; ii++) {
|
|
201
|
+
if (result.find(({ sku }) => sku === productItems[ii]['g:id'])) {
|
|
202
|
+
productItems.splice(ii, 1);
|
|
203
|
+
ii -= 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const setStockAndPrices = (productOrVatiation, item) => {
|
|
208
|
+
productOrVatiation.quantity = item['g:availability'] === 'in stock' ? 100 : 0;
|
|
209
|
+
const price = parseFloat(item['g:price'] || item['g:sale_price'] || '');
|
|
210
|
+
if (price) {
|
|
211
|
+
const salePrice = parseFloat(item['g:sale_price'] || '');
|
|
212
|
+
productOrVatiation.price = salePrice || price;
|
|
213
|
+
if (salePrice && salePrice < price) productOrVatiation.base_price = price;
|
|
214
|
+
}
|
|
215
|
+
if (item['g:shipping_weight']) {
|
|
216
|
+
const [weight, unit] = item['g:shipping_weight'].split(' ');
|
|
217
|
+
if (parseFloat(weight)) {
|
|
218
|
+
productOrVatiation.weight = {
|
|
219
|
+
value: parseFloat(weight),
|
|
220
|
+
unit: unit === 'g' || unit === 'kg' ? unit : 'kg',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const setSpecifications = (productOrVatiation, item, isProductItem = false) => {
|
|
226
|
+
const specifications = {};
|
|
227
|
+
if (item['g:color']) {
|
|
228
|
+
if (/[A-Z]{1,3}/.test(item['g:color'])) {
|
|
229
|
+
item['g:size'] = item['g:color'];
|
|
230
|
+
delete item['g:color'];
|
|
231
|
+
} else {
|
|
232
|
+
specifications.colors = [{ text: item['g:color'] }];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
[
|
|
236
|
+
'material',
|
|
237
|
+
'pattern',
|
|
238
|
+
'size',
|
|
239
|
+
'size_system',
|
|
240
|
+
'gender',
|
|
241
|
+
].forEach((spec) => {
|
|
242
|
+
if (!isProductItem && (spec === 'size_system' || spec === 'gender')) return;
|
|
243
|
+
const text = item[`g:${spec}`];
|
|
244
|
+
if (text) {
|
|
245
|
+
specifications[spec] = [{ text }];
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
if (Object.keys(specifications).length) {
|
|
249
|
+
productOrVatiation.specifications = specifications;
|
|
250
|
+
} else if (!isProductItem) {
|
|
251
|
+
productOrVatiation.specifications = {
|
|
252
|
+
size: [{ text: 'Único' }],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const listItemRemoteImages = (item) => {
|
|
257
|
+
const remoteImages = Array.isArray(item['g:additional_image_link'])
|
|
258
|
+
? item['g:additional_image_link']
|
|
259
|
+
: [];
|
|
260
|
+
if (item['g:image_link']) {
|
|
261
|
+
remoteImages.unshift(item['g:image_link']);
|
|
262
|
+
}
|
|
263
|
+
if (typeof item['g:additional_image_link'] === 'string') {
|
|
264
|
+
remoteImages.push(item['g:additional_image_link']);
|
|
265
|
+
}
|
|
266
|
+
return remoteImages;
|
|
267
|
+
};
|
|
268
|
+
for (let i = 0; i < productItems.length; i++) {
|
|
269
|
+
const item = productItems[i];
|
|
270
|
+
const { 'g:id': sku, 'g:title': name } = item;
|
|
271
|
+
if (!sku || !name) continue;
|
|
272
|
+
await echo`\n${new Date().toISOString()}`;
|
|
273
|
+
await echo`${(productItems.length - i)} products to import\n`;
|
|
274
|
+
await echo`SKU: ${sku}`;
|
|
275
|
+
const product = {
|
|
276
|
+
sku,
|
|
277
|
+
name,
|
|
278
|
+
slug: item['g:link']
|
|
279
|
+
? new URL(item['g:link']).pathname.substring(1).toLowerCase()
|
|
280
|
+
: slugify(name),
|
|
281
|
+
condition: item['g:condition'],
|
|
282
|
+
};
|
|
283
|
+
const description = item['g:description']?.trim();
|
|
284
|
+
if (description && description !== name) {
|
|
285
|
+
product.body_html = description;
|
|
286
|
+
}
|
|
287
|
+
const categoryName = item['g:product_type']?.split('>').pop()?.trim();
|
|
288
|
+
const category = categoryName && categories.find((c) => c.name === categoryName);
|
|
289
|
+
if (category) {
|
|
290
|
+
product.categories = [category];
|
|
291
|
+
}
|
|
292
|
+
const brandName = item['g:brand']?.trim();
|
|
293
|
+
const brand = brandName && brands.find((b) => b.name === brandName);
|
|
294
|
+
if (brand) {
|
|
295
|
+
product.brands = [brand];
|
|
296
|
+
}
|
|
297
|
+
setStockAndPrices(product, item);
|
|
298
|
+
const remoteImages = listItemRemoteImages(item);
|
|
299
|
+
const variationItems = items.filter((_item) => {
|
|
300
|
+
return _item['g:item_group_id'] === sku;
|
|
301
|
+
});
|
|
302
|
+
if (variationItems.length) {
|
|
303
|
+
let totalQuantity = 0;
|
|
304
|
+
product.variations = [];
|
|
305
|
+
variationItems.forEach((variationItem) => {
|
|
306
|
+
const variation = {
|
|
307
|
+
sku: variationItem['g:id'],
|
|
308
|
+
name: variationItem['g:title'],
|
|
309
|
+
specifications: {},
|
|
310
|
+
};
|
|
311
|
+
['g:shipping_weight', 'g:price', 'g:sale_price'].forEach((field) => {
|
|
312
|
+
if (item[field] === variationItem[field]) delete variationItem[field];
|
|
313
|
+
});
|
|
314
|
+
setStockAndPrices(variation, variationItem);
|
|
315
|
+
totalQuantity += variation.quantity || 0;
|
|
316
|
+
setSpecifications(variation, variationItem);
|
|
317
|
+
product.variations.push(variation);
|
|
318
|
+
const variationRemoteImages = listItemRemoteImages(variationItem);
|
|
319
|
+
variationRemoteImages.forEach((imageUrl) => {
|
|
320
|
+
if (!remoteImages.includes(imageUrl)) remoteImages.push(imageUrl);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
product.quantity = totalQuantity;
|
|
324
|
+
} else {
|
|
325
|
+
setSpecifications(product, item, true);
|
|
326
|
+
}
|
|
327
|
+
if (remoteImages.length) {
|
|
328
|
+
product.pictures = [];
|
|
329
|
+
}
|
|
330
|
+
for (let ii = 0; ii < remoteImages.length; ii++) {
|
|
331
|
+
const imageUrl = remoteImages[ii];
|
|
332
|
+
if (imageUrl) {
|
|
333
|
+
let retries = 0;
|
|
334
|
+
while (retries < 4) {
|
|
335
|
+
try {
|
|
336
|
+
const picture = await uploadPicture(imageUrl, ecomAuthHeaders);
|
|
337
|
+
product.pictures?.push(picture);
|
|
338
|
+
break;
|
|
339
|
+
} catch (err) {
|
|
340
|
+
// eslint-disable-next-line no-console
|
|
341
|
+
console.error(err);
|
|
342
|
+
if (err.statusCode < 500) {
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
retries += 1;
|
|
346
|
+
await sleep(4000);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
await echo`${JSON.stringify(product, null, 2)}\n`;
|
|
352
|
+
await api.post('products', product);
|
|
353
|
+
}
|
|
354
|
+
return echo``;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export default importFeed;
|
package/lib/login.js
CHANGED
|
@@ -4,7 +4,7 @@ import md5 from 'md5';
|
|
|
4
4
|
import api from '@cloudcommerce/api';
|
|
5
5
|
import createAuth from './create-auth.js';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const login = async () => {
|
|
8
8
|
await echo`-- Login with your E-Com Plus store admin account.
|
|
9
9
|
(i) same credentials used to enter the dashboard (https://ecomplus.app/)
|
|
10
10
|
`;
|
|
@@ -28,11 +28,13 @@ export default async () => {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
});
|
|
31
|
-
const { data:
|
|
31
|
+
const { data: credentials } = await api.post('login', {
|
|
32
32
|
username,
|
|
33
33
|
pass_md5_hash: passMd5,
|
|
34
34
|
});
|
|
35
|
-
const storeId =
|
|
36
|
-
const { data: { access_token: accessToken } } = await api.post('authenticate',
|
|
35
|
+
const storeId = credentials.store_ids[0];
|
|
36
|
+
const { data: { access_token: accessToken } } = await api.post('authenticate', credentials);
|
|
37
37
|
return createAuth(storeId, accessToken);
|
|
38
38
|
};
|
|
39
|
+
|
|
40
|
+
export default login;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudcommerce/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.38.1",
|
|
5
5
|
"description": "e-com.plus Cloud Commerce CLI tools",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cloudcommerce": "./bin/run.mjs"
|
|
@@ -32,11 +32,13 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@fastify/deepmerge": "^2.0.2",
|
|
34
34
|
"dotenv": "^16.4.7",
|
|
35
|
+
"fast-xml-parser": "^5.0.8",
|
|
36
|
+
"image-size": "^1.2.0",
|
|
35
37
|
"libsodium-wrappers": "^0.7.15",
|
|
36
38
|
"md5": "^2.3.0",
|
|
37
39
|
"typescript": "~5.8.2",
|
|
38
|
-
"zx": "^8.
|
|
39
|
-
"@cloudcommerce/api": "2.
|
|
40
|
+
"zx": "^8.4.0",
|
|
41
|
+
"@cloudcommerce/api": "2.38.1"
|
|
40
42
|
},
|
|
41
43
|
"scripts": {
|
|
42
44
|
"build": "bash ../../scripts/build-lib.sh"
|