sfcc-metadata-cli 0.0.1 → 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.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * System Object Extension Command
3
+ * Creates system object custom attribute definitions in migrations
4
+ */
5
+
6
+ const path = require('node:path');
7
+ const fs = require('node:fs');
8
+ const inquirer = require('inquirer');
9
+ const chalk = require('chalk');
10
+ const {
11
+ listMigrations,
12
+ getLatestMigration,
13
+ ensureDir,
14
+ writeFile,
15
+ } = require('../lib/utils');
16
+ const { generateSystemObjectExtension } = require('../lib/templates');
17
+ const { mergeSystemObjectExtension } = require('../lib/merge');
18
+
19
+ const ATTRIBUTE_TYPES = [
20
+ { name: 'String', value: 'string' },
21
+ { name: 'Text (multi-line)', value: 'text' },
22
+ { name: 'HTML', value: 'html' },
23
+ { name: 'Integer', value: 'int' },
24
+ { name: 'Double/Decimal', value: 'double' },
25
+ { name: 'Boolean', value: 'boolean' },
26
+ { name: 'Date', value: 'date' },
27
+ { name: 'Date & Time', value: 'datetime' },
28
+ { name: 'Enum (String)', value: 'enum-of-string' },
29
+ { name: 'Enum (Integer)', value: 'enum-of-int' },
30
+ { name: 'Set of Strings', value: 'set-of-string' },
31
+ { name: 'Image', value: 'image' },
32
+ { name: 'Password', value: 'password' },
33
+ ];
34
+
35
+ // Common SFCC system objects
36
+ const SYSTEM_OBJECTS = [
37
+ { name: 'Order', value: 'Order' },
38
+ { name: 'Product', value: 'Product' },
39
+ { name: 'Category', value: 'Category' },
40
+ { name: 'Customer', value: 'Profile' },
41
+ { name: 'Basket', value: 'Basket' },
42
+ { name: 'ProductLineItem', value: 'ProductLineItem' },
43
+ { name: 'ShippingLineItem', value: 'ShippingLineItem' },
44
+ { name: 'PaymentInstrument', value: 'PaymentInstrument' },
45
+ { name: 'OrderPaymentInstrument', value: 'OrderPaymentInstrument' },
46
+ { name: 'Shipment', value: 'Shipment' },
47
+ { name: 'Store', value: 'Store' },
48
+ { name: 'Content', value: 'Content' },
49
+ { name: 'Folder', value: 'Folder' },
50
+ { name: 'Catalog', value: 'Catalog' },
51
+ { name: 'PriceBook', value: 'PriceBook' },
52
+ { name: 'SourceCodeGroup', value: 'SourceCodeGroup' },
53
+ { name: 'Campaign', value: 'Campaign' },
54
+ { name: 'Promotion', value: 'Promotion' },
55
+ { name: 'Coupon', value: 'Coupon' },
56
+ { name: 'GiftCertificate', value: 'GiftCertificate' },
57
+ { name: 'SitePreferences', value: 'SitePreferences' },
58
+ { name: 'OrganizationPreferences', value: 'OrganizationPreferences' },
59
+ new inquirer.Separator(),
60
+ { name: '+ Enter custom object type', value: '__custom__' },
61
+ ];
62
+
63
+ module.exports = {
64
+ command: 'system-object',
65
+ aliases: ['so', 'sysobj', 'extend'],
66
+ desc: 'Extend a system object with custom attributes',
67
+ builder: (yargs) => {
68
+ return yargs
69
+ .option('migration', {
70
+ alias: 'm',
71
+ type: 'string',
72
+ description:
73
+ 'Target migration folder (uses latest if not specified)',
74
+ })
75
+ .option('object-type', {
76
+ alias: 'o',
77
+ type: 'string',
78
+ description: 'System object type ID (e.g., Order, Product)',
79
+ })
80
+ .option('attribute-id', {
81
+ alias: 'a',
82
+ type: 'string',
83
+ description: 'Attribute ID',
84
+ })
85
+ .option('display-name', {
86
+ type: 'string',
87
+ description: 'Display name for the attribute',
88
+ })
89
+ .option('description', {
90
+ alias: 'd',
91
+ type: 'string',
92
+ description: 'Description for the attribute',
93
+ })
94
+ .option('type', {
95
+ alias: 't',
96
+ type: 'string',
97
+ choices: [
98
+ 'string',
99
+ 'text',
100
+ 'html',
101
+ 'int',
102
+ 'double',
103
+ 'boolean',
104
+ 'date',
105
+ 'datetime',
106
+ 'enum-of-string',
107
+ 'set-of-string',
108
+ ],
109
+ default: 'string',
110
+ description: 'Attribute type',
111
+ })
112
+ .option('group', {
113
+ alias: 'g',
114
+ type: 'string',
115
+ description: 'Attribute group ID',
116
+ })
117
+ .option('interactive', {
118
+ alias: 'i',
119
+ type: 'boolean',
120
+ default: true,
121
+ description: 'Interactive mode with prompts',
122
+ });
123
+ },
124
+ handler: async (argv) => {
125
+ const migrationsDir = path.resolve(argv.migrationsDir);
126
+
127
+ try {
128
+ const existingMigrations = listMigrations(migrationsDir);
129
+
130
+ if (existingMigrations.length === 0) {
131
+ console.log(
132
+ chalk.yellow(
133
+ '\nNo migrations found. Creating a new migration first...',
134
+ ),
135
+ );
136
+ const createCmd = require('./create-migration');
137
+ await createCmd.handler({ ...argv, interactive: true });
138
+ return;
139
+ }
140
+
141
+ let config = {
142
+ objectTypeId: argv.objectType,
143
+ attributeId: argv.attributeId,
144
+ displayName: argv.displayName,
145
+ description: argv.description,
146
+ type: argv.type,
147
+ groupId: argv.group,
148
+ existingGroupAttributes: [],
149
+ };
150
+
151
+ let targetMigration =
152
+ argv.migration || getLatestMigration(migrationsDir);
153
+
154
+ if (argv.interactive) {
155
+ console.log(chalk.cyan('\n=== System Object Extension ===\n'));
156
+
157
+ // Select target migration
158
+ const migrationAnswer = await inquirer.prompt([
159
+ {
160
+ type: 'list',
161
+ name: 'migration',
162
+ message: 'Select target migration:',
163
+ choices: [
164
+ ...existingMigrations.slice(-10).reverse(),
165
+ new inquirer.Separator(),
166
+ {
167
+ name: '+ Create new migration',
168
+ value: '__new__',
169
+ },
170
+ ],
171
+ default: targetMigration,
172
+ },
173
+ ]);
174
+
175
+ if (migrationAnswer.migration === '__new__') {
176
+ const createCmd = require('./create-migration');
177
+ await createCmd.handler({ ...argv, interactive: true });
178
+ targetMigration = getLatestMigration(migrationsDir);
179
+ } else {
180
+ targetMigration = migrationAnswer.migration;
181
+ }
182
+
183
+ // Parse existing groups from migrations
184
+ const existingGroups = await parseExistingGroups(migrationsDir);
185
+
186
+ // Select system object type
187
+ const objectAnswer = await inquirer.prompt([
188
+ {
189
+ type: 'list',
190
+ name: 'objectTypeId',
191
+ message: 'Select system object type:',
192
+ choices: SYSTEM_OBJECTS,
193
+ when: !config.objectTypeId,
194
+ },
195
+ {
196
+ type: 'input',
197
+ name: 'customObjectType',
198
+ message: 'Enter object type ID:',
199
+ when: (answers) =>
200
+ answers.objectTypeId === '__custom__',
201
+ validate: (input) =>
202
+ input ? true : 'Object type ID is required',
203
+ },
204
+ ]);
205
+
206
+ config.objectTypeId =
207
+ objectAnswer.customObjectType ||
208
+ objectAnswer.objectTypeId ||
209
+ config.objectTypeId;
210
+
211
+ // Basic info
212
+ const basicInfo = await inquirer.prompt([
213
+ {
214
+ type: 'input',
215
+ name: 'attributeId',
216
+ message: 'Attribute ID (e.g., customAttribute):',
217
+ validate: (input) => {
218
+ if (!input) return 'Attribute ID is required';
219
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input)) {
220
+ return 'Invalid attribute ID format';
221
+ }
222
+ return true;
223
+ },
224
+ when: !config.attributeId,
225
+ },
226
+ {
227
+ type: 'input',
228
+ name: 'displayName',
229
+ message: 'Display name:',
230
+ default: (answers) => {
231
+ const id =
232
+ answers.attributeId || config.attributeId;
233
+ return id
234
+ .replace(/_/g, ' ')
235
+ .replace(/([A-Z])/g, ' $1')
236
+ .trim()
237
+ .split(' ')
238
+ .map(
239
+ (w) =>
240
+ w.charAt(0).toUpperCase() + w.slice(1),
241
+ )
242
+ .join(' ');
243
+ },
244
+ when: !config.displayName,
245
+ },
246
+ {
247
+ type: 'input',
248
+ name: 'description',
249
+ message: 'Description (optional):',
250
+ when: !config.description,
251
+ },
252
+ {
253
+ type: 'list',
254
+ name: 'type',
255
+ message: 'Attribute type:',
256
+ choices: ATTRIBUTE_TYPES,
257
+ default: config.type || 'string',
258
+ },
259
+ {
260
+ type: 'confirm',
261
+ name: 'localizable',
262
+ message: 'Is localizable?',
263
+ default: false,
264
+ },
265
+ {
266
+ type: 'confirm',
267
+ name: 'mandatory',
268
+ message: 'Is mandatory?',
269
+ default: false,
270
+ },
271
+ ]);
272
+
273
+ config = { ...config, ...basicInfo };
274
+
275
+ // Type-specific prompts
276
+ if (
277
+ config.type === 'enum-of-string' ||
278
+ config.type === 'enum-of-int'
279
+ ) {
280
+ const enumConfig = await inquirer.prompt([
281
+ {
282
+ type: 'confirm',
283
+ name: 'multiSelect',
284
+ message: 'Allow multiple selections?',
285
+ default: false,
286
+ },
287
+ {
288
+ type: 'input',
289
+ name: 'enumValues',
290
+ message:
291
+ 'Enum values (comma-separated, format: value:display or just value):',
292
+ filter: (input) => {
293
+ return input
294
+ .split(',')
295
+ .map((v) => {
296
+ const parts = v.trim().split(':');
297
+ return {
298
+ value: parts[0].trim(),
299
+ display: parts[1]
300
+ ? parts[1].trim()
301
+ : parts[0].trim(),
302
+ };
303
+ })
304
+ .filter((v) => v.value);
305
+ },
306
+ },
307
+ ]);
308
+ config.multiSelect = enumConfig.multiSelect;
309
+ config.enumValues = enumConfig.enumValues;
310
+ }
311
+
312
+ // Default value
313
+ const defaultPrompt = await inquirer.prompt([
314
+ {
315
+ type: config.type === 'boolean' ? 'list' : 'input',
316
+ name: 'defaultValue',
317
+ message: 'Default value:',
318
+ choices:
319
+ config.type === 'boolean'
320
+ ? [
321
+ { name: 'None', value: null },
322
+ { name: 'True', value: 'true' },
323
+ { name: 'False', value: 'false' },
324
+ ]
325
+ : undefined,
326
+ },
327
+ ]);
328
+
329
+ config.defaultValue = defaultPrompt.defaultValue || null;
330
+
331
+ // Group selection
332
+ const objectGroups = existingGroups[config.objectTypeId] || {};
333
+ const existingObjectGroupIds = Object.keys(objectGroups);
334
+ const groupChoices = [
335
+ {
336
+ name: `${config.objectTypeId}_Custom`,
337
+ value: `${config.objectTypeId}_Custom`,
338
+ },
339
+ ...existingObjectGroupIds.map((g) => ({
340
+ name: g,
341
+ value: g,
342
+ })),
343
+ new inquirer.Separator(),
344
+ { name: '+ Create new group', value: '__new__' },
345
+ ];
346
+
347
+ // Remove duplicates
348
+ const uniqueChoices = groupChoices.filter(
349
+ (choice, index, self) =>
350
+ choice.type === 'separator' ||
351
+ index ===
352
+ self.findIndex((c) => c.value === choice.value),
353
+ );
354
+
355
+ const groupAnswer = await inquirer.prompt([
356
+ {
357
+ type: 'list',
358
+ name: 'groupId',
359
+ message: 'Select attribute group:',
360
+ choices: uniqueChoices,
361
+ default: `${config.objectTypeId}_Custom`,
362
+ },
363
+ {
364
+ type: 'input',
365
+ name: 'newGroupId',
366
+ message: 'New group ID:',
367
+ when: (answers) => answers.groupId === '__new__',
368
+ validate: (input) =>
369
+ input ? true : 'Group ID is required',
370
+ },
371
+ {
372
+ type: 'input',
373
+ name: 'newGroupDisplayName',
374
+ message: 'New group display name:',
375
+ when: (answers) => answers.groupId === '__new__',
376
+ default: (answers) => answers.newGroupId,
377
+ },
378
+ ]);
379
+
380
+ if (groupAnswer.groupId === '__new__') {
381
+ config.groupId = groupAnswer.newGroupId;
382
+ config.groupDisplayName = groupAnswer.newGroupDisplayName;
383
+ config.existingGroupAttributes = [];
384
+ } else {
385
+ config.groupId = groupAnswer.groupId;
386
+ config.groupDisplayName = groupAnswer.groupId;
387
+ config.existingGroupAttributes =
388
+ objectGroups[groupAnswer.groupId] || [];
389
+ }
390
+ }
391
+
392
+ // Generate XML
393
+ const xml = generateSystemObjectExtension(config);
394
+
395
+ // Write to file
396
+ const metaPath = path.join(migrationsDir, targetMigration, 'meta');
397
+ const filePath = path.join(
398
+ metaPath,
399
+ 'system-objecttype-extensions.xml',
400
+ );
401
+
402
+ ensureDir(metaPath);
403
+
404
+ // Check if file exists
405
+ if (fs.existsSync(filePath)) {
406
+ console.log(
407
+ chalk.yellow(
408
+ `\nNote: ${chalk.bold('system-objecttype-extensions.xml')} already exists.`,
409
+ ),
410
+ );
411
+
412
+ const { action } = await inquirer.prompt([
413
+ {
414
+ type: 'list',
415
+ name: 'action',
416
+ message: 'What would you like to do?',
417
+ choices: [
418
+ {
419
+ name: 'Merge into existing file',
420
+ value: 'merge',
421
+ },
422
+ {
423
+ name: 'Show generated XML (copy manually)',
424
+ value: 'show',
425
+ },
426
+ { name: 'Overwrite file', value: 'overwrite' },
427
+ { name: 'Cancel', value: 'cancel' },
428
+ ],
429
+ default: 'merge',
430
+ },
431
+ ]);
432
+
433
+ if (action === 'cancel') {
434
+ console.log(chalk.yellow('\nOperation cancelled.'));
435
+ return;
436
+ }
437
+
438
+ if (action === 'show') {
439
+ console.log(chalk.cyan('\n--- Generated XML ---\n'));
440
+ console.log(xml);
441
+ console.log(chalk.cyan('\n--- End XML ---\n'));
442
+ console.log(chalk.gray(`Manually add to: ${filePath}`));
443
+ return;
444
+ }
445
+
446
+ if (action === 'merge') {
447
+ const existingXml = fs.readFileSync(filePath, 'utf8');
448
+ const result = mergeSystemObjectExtension(existingXml, {
449
+ objectTypeId: config.objectTypeId,
450
+ attributeId: config.attributeId,
451
+ displayName: config.displayName,
452
+ description: config.description,
453
+ type: config.type,
454
+ localizable: config.localizable,
455
+ mandatory: config.mandatory,
456
+ externallyManaged: false,
457
+ defaultValue: config.defaultValue,
458
+ groupId: config.groupId,
459
+ groupDisplayName: config.groupDisplayName,
460
+ enumValues: config.enumValues,
461
+ multiSelect: config.multiSelect,
462
+ minLength: config.minLength || 0,
463
+ });
464
+ writeFile(filePath, result.xml);
465
+ console.log(
466
+ chalk.green(
467
+ `\n✓ Merged ${config.objectTypeId} extension: ${config.attributeId}`,
468
+ ),
469
+ );
470
+ console.log(chalk.cyan(` File: ${filePath}`));
471
+ console.log(chalk.gray(` Group: ${config.groupId}`));
472
+
473
+ if (result.groupExisted) {
474
+ console.log(
475
+ chalk.yellow(
476
+ `\n⚠ Warning: Group "${config.groupId}" already existed in this file.`,
477
+ ),
478
+ );
479
+ console.log(
480
+ chalk.yellow(
481
+ ' Review the group-definitions to ensure all attributes are listed.',
482
+ ),
483
+ );
484
+ console.log(
485
+ chalk.gray(
486
+ ' The merge only adds the new attribute reference, existing ones are preserved.',
487
+ ),
488
+ );
489
+ }
490
+ return;
491
+ }
492
+ }
493
+
494
+ writeFile(filePath, xml);
495
+
496
+ console.log(
497
+ chalk.green(
498
+ `\n✓ Created ${config.objectTypeId} extension: ${config.attributeId}`,
499
+ ),
500
+ );
501
+ console.log(chalk.cyan(` File: ${filePath}`));
502
+ console.log(chalk.gray(` Group: ${config.groupId}`));
503
+ } catch (error) {
504
+ console.error(chalk.red(`\nError: ${error.message}`));
505
+ process.exit(1);
506
+ }
507
+ },
508
+ };
509
+
510
+ /**
511
+ * Parses existing group attributes from migration files
512
+ * @param {string} migrationsDir - Migrations directory
513
+ * @returns {Promise<Object>} Map of object types to groups to attribute arrays
514
+ */
515
+ async function parseExistingGroups(migrationsDir) {
516
+ const objectGroups = {};
517
+ const migrations = fs.readdirSync(migrationsDir);
518
+
519
+ for (const migration of migrations) {
520
+ const filePath = path.join(
521
+ migrationsDir,
522
+ migration,
523
+ 'meta',
524
+ 'system-objecttype-extensions.xml',
525
+ );
526
+ if (fs.existsSync(filePath)) {
527
+ const content = fs.readFileSync(filePath, 'utf8');
528
+
529
+ // Extract type extensions
530
+ const typeMatches = content.matchAll(
531
+ /<type-extension type-id="([^"]+)">([\s\S]*?)<\/type-extension>/g,
532
+ );
533
+ for (const typeMatch of typeMatches) {
534
+ const typeId = typeMatch[1];
535
+ const typeContent = typeMatch[2];
536
+
537
+ if (!objectGroups[typeId]) {
538
+ objectGroups[typeId] = {};
539
+ }
540
+
541
+ // Extract group definitions
542
+ const groupMatches = typeContent.matchAll(
543
+ /<attribute-group group-id="([^"]+)">([\s\S]*?)<\/attribute-group>/g,
544
+ );
545
+ for (const groupMatch of groupMatches) {
546
+ const groupId = groupMatch[1];
547
+ const groupContent = groupMatch[2];
548
+
549
+ if (!objectGroups[typeId][groupId]) {
550
+ objectGroups[typeId][groupId] = [];
551
+ }
552
+
553
+ // Extract attribute IDs
554
+ const attrMatches = groupContent.matchAll(
555
+ /<attribute attribute-id="([^"]+)"\/>/g,
556
+ );
557
+ for (const attrMatch of attrMatches) {
558
+ if (
559
+ !objectGroups[typeId][groupId].includes(
560
+ attrMatch[1],
561
+ )
562
+ ) {
563
+ objectGroups[typeId][groupId].push(attrMatch[1]);
564
+ }
565
+ }
566
+ }
567
+ }
568
+ }
569
+ }
570
+
571
+ return objectGroups;
572
+ }
package/index.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Migration Helper CLI
4
+ * Companion tool to b2c-tools for creating SFCC migrations
5
+ */
6
+
7
+ const yargs = require('yargs');
8
+ const { hideBin } = require('yargs/helpers');
9
+
10
+ const createMigrationCommand = require('./commands/create-migration');
11
+ const customObjectCommand = require('./commands/custom-object');
12
+ const sitePreferenceCommand = require('./commands/site-preference');
13
+ const systemObjectCommand = require('./commands/system-object');
14
+
15
+ yargs(hideBin(process.argv))
16
+ .scriptName('migration-helper')
17
+ .usage('$0 <command> [options]')
18
+ .command(createMigrationCommand)
19
+ .command(customObjectCommand)
20
+ .command(sitePreferenceCommand)
21
+ .command(systemObjectCommand)
22
+ .demandCommand(1, 'You must specify a command')
23
+ .option('migrations-dir', {
24
+ alias: 'm',
25
+ type: 'string',
26
+ description: 'Path to migrations directory',
27
+ default: './migrations',
28
+ })
29
+ .help()
30
+ .alias('h', 'help')
31
+ .version()
32
+ .alias('v', 'version')
33
+ .epilogue('For more information, see the b2c-tools documentation')
34
+ .parse();