@remkoj/optimizely-cms-cli 6.0.0-rc.1 → 6.0.0-rc.3

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/dist/index.js CHANGED
@@ -1,16 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Developer Utitility providing helpers for common tasks to building a
4
+ * frontend application that uses Optimizely CMS/Graph as content repository.
5
+ *
6
+ * License: Apache 2
7
+ * Copyright (c) 2023-2026 - Remko Jantzen
8
+ */
9
+
1
10
  import { globSync, glob, globIterate } from 'glob';
2
11
  import { config } from 'dotenv';
3
12
  import { expand } from 'dotenv-expand';
4
13
  import path from 'node:path';
5
14
  import fs from 'node:fs';
6
- import { readPartialEnvConfig, createClient, OptiCmsVersion, ContentRoots } from '@remkoj/optimizely-cms-api';
15
+ import { readPartialEnvConfig, createClient, ContentRoots } from '@remkoj/optimizely-cms-api';
7
16
  import yargs from 'yargs';
8
17
  import chalk from 'chalk';
9
18
  import figures from 'figures';
10
19
  import Table from 'cli-table3';
11
20
  import slugify from '@sindresorhus/slugify';
12
21
  import { diff } from 'deep-object-diff';
13
- import fsAsync from 'node:fs/promises';
22
+ import fs$1 from 'node:fs/promises';
14
23
  import { input, select, confirm } from '@inquirer/prompts';
15
24
  import createDeepMerge from '@fastify/deepmerge';
16
25
  import { Ajv } from 'ajv';
@@ -209,10 +218,12 @@ function getStyleFilePathsSync(displayTemplate, contentBaseType = '_Component',
209
218
  target = displayTemplate.nodeType;
210
219
  break;
211
220
  case 'component':
212
- const baseType = contentBaseType;
213
- target = displayTemplate.contentType;
214
- groupPath = path.join(keyToSlug(baseType, { stripLeadingUnderscore: true }), keyToSlug(displayTemplate.contentType, { stripLeadingGroup: true }));
215
- break;
221
+ {
222
+ const baseType = contentBaseType;
223
+ target = displayTemplate.contentType;
224
+ groupPath = path.join(keyToSlug(baseType, { stripLeadingUnderscore: true }), keyToSlug(displayTemplate.contentType, { stripLeadingGroup: true }));
225
+ break;
226
+ }
216
227
  default:
217
228
  throw new Error(`Unsupported DisplayTemplate target for ${displayTemplate.key}`);
218
229
  }
@@ -230,7 +241,7 @@ function getStyleFilePathsSync(displayTemplate, contentBaseType = '_Component',
230
241
  };
231
242
  }
232
243
  async function getStyleFilePaths(definition, opts) {
233
- let defintionBaseType = undefined;
244
+ let defintionBaseType;
234
245
  if (definition.contentType && !opts.contentBaseType && opts.client) {
235
246
  const contentType = await opts.client.contentTypesGet({ path: { key: definition.contentType } }).catch(() => undefined);
236
247
  defintionBaseType = contentType?.baseType;
@@ -340,7 +351,7 @@ function isFolder(contentType) {
340
351
  * Test if the provided content type is a system type
341
352
  *
342
353
  * @param contentType The ContentType to test
343
- * @returns `true` if the ContentType is a folder, `false` otherwise
354
+ * @returns `true` if the ContentType is a system type, `false` otherwise
344
355
  * @see {@link IntegrationApi.ContentType}
345
356
  */
346
357
  function isSystemType(contentType) {
@@ -348,12 +359,30 @@ function isSystemType(contentType) {
348
359
  return false;
349
360
  return contentType.source === 'system';
350
361
  }
362
+ /**
363
+ * Type guard that returns `true` when `toTest` is a non-empty array.
364
+ *
365
+ * @param toTest The value to inspect.
366
+ * @returns `true` when `toTest` is an `Array` with at least one element.
367
+ */
351
368
  function isNonEmptyArray(toTest) {
352
369
  return Array.isArray(toTest) && toTest.length > 0;
353
370
  }
371
+ /**
372
+ * Type guard that returns `true` when `toTest` is `null`, `undefined`, or an empty array.
373
+ *
374
+ * @param toTest The value to inspect.
375
+ * @returns `true` when `toTest` is `null` or an `Array` with no elements.
376
+ */
354
377
  function isEmptyArray(toTest) {
355
378
  return toTest === null || !Array.isArray(toTest) || toTest.length === 0;
356
379
  }
380
+ /**
381
+ * Type guard that returns `true` when `toTest` is neither `null` nor `undefined`.
382
+ *
383
+ * @param toTest The value to inspect.
384
+ * @returns `true` when `toTest` is a defined, non-null value of type `T`.
385
+ */
357
386
  function isDefined(toTest) {
358
387
  return toTest !== null && toTest !== undefined;
359
388
  }
@@ -441,25 +470,28 @@ async function* getAllContentTypes(client, debug = false, pageSize = 25) {
441
470
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Pulling Content Types from Optimizely CMS\n`));
442
471
  let requestPageSize = pageSize;
443
472
  let requestPageIndex = 0;
444
- let totalItemCount = 0;
445
- let totalPages = 0;
473
+ let totalItemCount;
474
+ let totalPages;
446
475
  do {
447
- const resultsPage = await client.contentTypesList({ query: { pageIndex: requestPageIndex, pageSize: requestPageSize } }).catch((_) => {
476
+ const resultsPage = await client.contentTypesList({ query: { pageIndex: requestPageIndex, pageSize: requestPageSize } }).catch(() => {
448
477
  return {
449
478
  items: [],
450
479
  totalItemCount: 0,
480
+ totalCount: 0,
451
481
  pageIndex: requestPageIndex,
452
482
  pageSize: requestPageSize
453
483
  };
454
484
  });
485
+ console.log(resultsPage);
455
486
  // Calculate fields for next page
456
- totalItemCount = resultsPage.totalItemCount ?? 0;
487
+ //@ts-expect-error There's a difference between the SaaS & PaaS API, hence we're ignoring the next line
488
+ totalItemCount = resultsPage.totalItemCount ?? resultsPage.totalCount ?? 0;
457
489
  requestPageSize = resultsPage.pageSize;
458
490
  requestPageIndex = resultsPage.pageIndex + 1;
459
491
  totalPages = Math.ceil(totalItemCount / requestPageSize);
460
492
  // Debug output
461
493
  if (debug)
462
- process.stdout.write(chalk.gray(`${figures.arrowRight} Fetched contentTypes page ${requestPageIndex} of ${totalPages} (${requestPageSize} items per page)\n`));
494
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Fetched contentTypes page ${requestPageIndex} of ${totalPages} (${requestPageSize} items per page, ${totalItemCount} items available)\n`));
463
495
  // Yield items
464
496
  for (const contentType of (resultsPage.items ?? [])) {
465
497
  yield contentType;
@@ -543,10 +575,10 @@ async function getStyles(client, args, pageSize = 25) {
543
575
  async function* getAllStyles(client, debug = false, pageSize = 5) {
544
576
  let requestPageSize = pageSize;
545
577
  let requestPageIndex = 0;
546
- let totalItemCount = 0;
547
- let totalPages = 0;
578
+ let totalItemCount;
579
+ let totalPages;
548
580
  do {
549
- const resultsPage = await client.displayTemplatesList({ query: { pageIndex: requestPageIndex, pageSize: requestPageSize } }).catch((_) => {
581
+ const resultsPage = await client.displayTemplatesList({ query: { pageIndex: requestPageIndex, pageSize: requestPageSize } }).catch(() => {
550
582
  return {
551
583
  items: [],
552
584
  totalItemCount: 0,
@@ -555,13 +587,14 @@ async function* getAllStyles(client, debug = false, pageSize = 5) {
555
587
  };
556
588
  });
557
589
  // Calculate fields for next page
558
- totalItemCount = resultsPage.totalItemCount ?? 0;
590
+ //@ts-expect-error Difference between PaaS & SaaS
591
+ totalItemCount = resultsPage.totalItemCount ?? resultsPage.totalCount ?? 0;
559
592
  requestPageSize = resultsPage.pageSize;
560
593
  requestPageIndex = resultsPage.pageIndex + 1;
561
594
  totalPages = Math.ceil(totalItemCount / requestPageSize);
562
595
  // Debug output
563
596
  if (debug)
564
- process.stdout.write(chalk.gray(`${figures.arrowRight} Fetched displayTemplates page ${requestPageIndex} of ${totalPages} (${requestPageSize} items per page)\n`));
597
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Fetched displayTemplates page ${requestPageIndex} of ${totalPages} (${requestPageSize} items per page, ${totalItemCount} items available)\n`));
565
598
  // Yield items
566
599
  for (const displayTemplate of (resultsPage.items ?? [])) {
567
600
  yield displayTemplate;
@@ -624,7 +657,7 @@ async function toTypeFilesList(displayTemplates, client, basePath) {
624
657
  return new TypeFilesList();
625
658
  }
626
659
 
627
- function normalizeMergePatch(value, omitKeys, requiredKeys = [], requiredSource) {
660
+ function normalizeMergePatch(value, omitKeys, requiredKeys = [], requiredSource, atomicKeys = []) {
628
661
  if (value === undefined || value === null)
629
662
  return null;
630
663
  if (Array.isArray(value))
@@ -632,8 +665,15 @@ function normalizeMergePatch(value, omitKeys, requiredKeys = [], requiredSource)
632
665
  if (typeof value !== 'object')
633
666
  return value;
634
667
  const output = Object.entries(value).reduce((acc, [key, entry]) => {
635
- if (!omitKeys.includes(key))
636
- acc[key] = normalizeMergePatch(entry, omitKeys);
668
+ if (!omitKeys.includes(key)) {
669
+ if (atomicKeys.includes(key) && requiredSource !== undefined) {
670
+ // Include the full value from newValue verbatim — do not recurse into the diff
671
+ acc[key] = requiredSource[key];
672
+ }
673
+ else {
674
+ acc[key] = normalizeMergePatch(entry, omitKeys);
675
+ }
676
+ }
637
677
  return acc;
638
678
  }, {});
639
679
  const withRequiredKeys = requiredKeys.reduce((acc, key) => {
@@ -698,13 +738,18 @@ function normalizeMergePatch(value, omitKeys, requiredKeys = [], requiredSource)
698
738
  * regardless of whether they changed.
699
739
  * @param options.requiredFields - Keys that should always appear in the generated patch,
700
740
  * using values from `newValue`.
741
+ * @param options.atomicFields - Keys whose values are always included verbatim when they
742
+ * change, rather than being recursively patched. Use for
743
+ * record- or dictionary-typed properties (e.g.
744
+ * `{ [key: string]: SomeObject }`) and arrays of objects.
701
745
  * @returns A {@link MergePatch} object ready to be serialised as an `application/merge-patch+json` body.
702
746
  */
703
747
  function generatePatch(currentValue, newValue, options = {}) {
704
748
  const requiredKeys = options.requiredFields ?? [];
705
749
  const omitKeys = (options.readonlyFields ?? []).filter(key => !requiredKeys.includes(key));
750
+ const atomicKeys = (options.atomicFields ?? []);
706
751
  const patch = diff(currentValue, newValue);
707
- return normalizeMergePatch(patch, omitKeys, requiredKeys, newValue);
752
+ return normalizeMergePatch(patch, omitKeys, requiredKeys, newValue, atomicKeys);
708
753
  }
709
754
  /**
710
755
  * Extracts all field paths from a {@link MergePatch} as dot-separated strings.
@@ -776,18 +821,17 @@ const StylesPushCommand = {
776
821
  // Create / Replace the current template
777
822
  const newTemplate = await (currentTemplate ? (async () => {
778
823
  const patch = generatePatch(currentTemplate, styleDefinition, {
779
- readonlyFields: ['key', 'created', 'lastModified', 'createdBy', 'lastModifiedBy']
824
+ readonlyFields: ['key', 'created', 'lastModified', 'createdBy', 'lastModifiedBy'],
825
+ atomicFields: ['settings']
780
826
  });
781
827
  if (!path || Object.entries(patch).length === 0)
782
828
  return currentTemplate;
783
- // @ts-expect-error There's a mis-match between the logic in the CMS and the contents of the
784
- // OpenAPI Spec file.
785
829
  return client.displayTemplatesPatch({ path: { key: styleKey }, body: patch });
786
830
  })() :
787
831
  client.displayTemplatesCreate({ body: styleDefinition }));
788
- const missedFields = generatePatch(newTemplate, currentTemplate, {
832
+ const missedFields = newTemplate ? generatePatch(newTemplate, currentTemplate, {
789
833
  readonlyFields: ['key', 'created', 'lastModified', 'createdBy', 'lastModifiedBy']
790
- });
834
+ }) : undefined;
791
835
  if (missedFields && Object.keys(missedFields).length > 0)
792
836
  throw new Error(`The Display template ${styleKey} failed to update properties: ${getPatchFields(missedFields).join('; ')}`);
793
837
  return newTemplate;
@@ -804,16 +848,21 @@ const StylesPushCommand = {
804
848
  });
805
849
  results.forEach(result => {
806
850
  if (result.status === 'fulfilled') {
807
- const tpl = result.value;
808
- styles.push([
809
- tpl.displayName,
810
- tpl.key,
811
- tpl.isDefault ? figures.tick : figures.cross,
812
- tpl.contentType ? `${tpl.contentType} (C)` : tpl.baseType ? `${tpl.baseType} (B)` : `${tpl.nodeType} (N)`
813
- ]);
851
+ if (isDefined(result.value)) {
852
+ const tpl = result.value;
853
+ styles.push([
854
+ tpl.displayName,
855
+ tpl.key,
856
+ tpl.isDefault ? figures.tick : figures.cross,
857
+ tpl.contentType ? `${tpl.contentType} (C)` : tpl.baseType ? `${tpl.baseType} (B)` : `${tpl.nodeType} (N)`
858
+ ]);
859
+ }
860
+ else {
861
+ process.stderr.write(`DisplayTemplate pushed without errors, but no result was returned.\n`);
862
+ }
814
863
  }
815
864
  else {
816
- process.stderr.write(`Error processing DisplayTemplate: ${result.reason}\n`);
865
+ process.stderr.write(`Error processing DisplayTemplate: ${result.reason ?? "n"}\n`);
817
866
  }
818
867
  });
819
868
  process.stdout.write(styles.toString() + "\n");
@@ -826,7 +875,7 @@ function tryReadJsonFile(filePath, debug = false) {
826
875
  process.stdout.write(chalk.gray(`${figures.arrowRight} Reading style definition from ${filePath}\n`));
827
876
  return JSON.parse(fs.readFileSync(filePath, { encoding: 'utf-8' }));
828
877
  }
829
- catch (e) {
878
+ catch {
830
879
  process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} Error while reading ${filePath}\n`));
831
880
  }
832
881
  return undefined;
@@ -837,10 +886,6 @@ const StylesListCommand = {
837
886
  describe: "List Visual Builder style definitions from the CMS",
838
887
  handler: async (args) => {
839
888
  const client = createCmsClient(args);
840
- if (client.runtimeCmsVersion == OptiCmsVersion.CMS12) {
841
- process.stdout.write(chalk.gray(`${figures.cross} Styles are not supported on CMS12\n`));
842
- return;
843
- }
844
889
  const { all: results } = await getStyles(client, { ...args, excludeBaseTypes: [], excludeNodeTypes: [], excludeTemplates: [], excludeTypes: [], baseTypes: [], nodes: [], templates: [], types: [], templateTypes: [], all: false });
845
890
  const styles = new Table({
846
891
  head: [
@@ -893,7 +938,7 @@ const StylesPullCommand = {
893
938
  const displayTemplateGroup = styleFiles.get(groupIdentifier);
894
939
  for (const { file: filePath, data: displayTemplate } of (displayTemplateGroup?.templates || [])) {
895
940
  // Write JSON to disk
896
- var updatedJson = await createDisplayTemplateFile(displayTemplate, filePath, force, cfg.debug);
941
+ const updatedJson = await createDisplayTemplateFile(displayTemplate, filePath, force, cfg.debug);
897
942
  if (updatedJson)
898
943
  updatedTemplates.push(displayTemplate.key);
899
944
  }
@@ -940,7 +985,7 @@ async function createDisplayTemplateFile(displayTemplate, filePath, force = fals
940
985
  async function createDisplayTemplateHelper(typeFile, typeFileId, force = false, debug = false) {
941
986
  const prefix = '//not-modified - Remove this line when making change to prevent it from being updated by the CLI tools';
942
987
  const { filePath: typeFilePath, templates } = typeFile;
943
- const shouldWrite = await fsAsync.readFile(typeFilePath, { encoding: "utf-8" }).then(data => data.startsWith('//not-modified')).catch((e) => {
988
+ const shouldWrite = await fs$1.readFile(typeFilePath, { encoding: "utf-8" }).then(data => data.startsWith('//not-modified')).catch((e) => {
944
989
  if (e?.code === 'ENOENT')
945
990
  return true;
946
991
  if (debug)
@@ -1004,7 +1049,7 @@ export function is${t.data.key}Props(props?: ${typeId}LayoutProps | null) : prop
1004
1049
  }`);
1005
1050
  });
1006
1051
  }
1007
- void await fsAsync.writeFile(typeFilePath, prefix + "\n" + imports.join("\n") + "\n\n" + typeContents.join("\n"));
1052
+ void await fs$1.writeFile(typeFilePath, prefix + "\n" + imports.join("\n") + "\n\n" + typeContents.join("\n"));
1008
1053
  return true;
1009
1054
  }
1010
1055
 
@@ -1132,12 +1177,12 @@ const StylesDeleteCommand = {
1132
1177
  // Remove style file
1133
1178
  if (args.withStyleFile) {
1134
1179
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Removing *.opti-style.json file for ${displayTemplate.displayName} [${displayTemplate.key}]\n`));
1135
- await fsAsync.rm(styleFilePath, { force: false, recursive: false, maxRetries: 1 }).catch((e) => {
1180
+ await fs$1.rm(styleFilePath, { force: false, recursive: false, maxRetries: 1 }).catch((e) => {
1136
1181
  if (e?.code === 'ENOENT') // The file can't be deleted as it does not exist - that's the desired state
1137
1182
  return;
1138
1183
  throw e;
1139
1184
  });
1140
- await fsAsync.rmdir(itemPath, { recursive: false, maxRetries: 1 }).catch((e) => {
1185
+ await fs$1.rmdir(itemPath, { recursive: false, maxRetries: 1 }).catch((e) => {
1141
1186
  if (e?.code === 'ENOTEMPTY') {
1142
1187
  process.stdout.write(chalk.yellowBright(`${figures.warning} The folder is not empty, please remove ${itemPath} manually if needed\n`));
1143
1188
  return;
@@ -1153,12 +1198,12 @@ const StylesDeleteCommand = {
1153
1198
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Removing/updating displayTemplates.ts file for ${displayTemplate.displayName} [${displayTemplate.key}]\n`));
1154
1199
  const remainingTemplates = styleHelpers[targetType].templates.filter(x => !keysToDelete.includes(x.key));
1155
1200
  if (remainingTemplates.length === 0) {
1156
- await fsAsync.rm(styleHelpers[targetType].filePath, { force: false, recursive: false, maxRetries: 1 }).catch((e) => {
1201
+ await fs$1.rm(styleHelpers[targetType].filePath, { force: false, recursive: false, maxRetries: 1 }).catch((e) => {
1157
1202
  if (e?.code === 'ENOENT') // The file can't be deleted as it does not exist - that's the desired state
1158
1203
  return;
1159
1204
  throw e;
1160
1205
  });
1161
- await fsAsync.rmdir(typesPath, { recursive: false, maxRetries: 1 }).catch((e) => {
1206
+ await fs$1.rmdir(typesPath, { recursive: false, maxRetries: 1 }).catch((e) => {
1162
1207
  if (e?.code === 'ENOTEMPTY') {
1163
1208
  process.stdout.write(chalk.yellowBright(`${figures.warning} The folder is not empty, please remove ${typesPath} manually if needed\n`));
1164
1209
  return;
@@ -1716,10 +1761,11 @@ ${varName}.getMetaData = async (contentLink, locale, client) => {
1716
1761
 
1717
1762
  export default ${varName}`,
1718
1763
  // Template for all experience component types
1719
- experience: (contentType, varName, displayTemplate) => `import { type OptimizelyNextPage as CmsComponent } from "@remkoj/optimizely-cms-nextjs";
1764
+ experience: (contentType, varName, displayTemplate) => `import 'server-only'
1765
+ import { type OptimizelyNextPage as CmsComponent } from "@remkoj/optimizely-cms-nextjs";
1720
1766
  import { ExperienceDataFragmentDoc, get${contentType.key}DataDocument, type get${contentType.key}DataQuery } from '@/gql/graphql'
1721
1767
  import { getFragmentData } from '@/gql/fragment-masking'
1722
- import { OptimizelyComposition, isNode, CmsEditable } from "@remkoj/optimizely-cms-react/rsc";${displayTemplate ? `
1768
+ import { OptimizelyComposition, isNode, CmsEditable, type ServerContext } from "@remkoj/optimizely-cms-react/rsc";${displayTemplate ? `
1723
1769
  import { ${displayTemplate} } from "./displayTemplates";` : ''}
1724
1770
  import { getSdk } from "@/gql/client"
1725
1771
 
@@ -1735,7 +1781,7 @@ import { getSdk } from "@/gql/client"
1735
1781
  * [Documentation: Customizing queries](https://github.com/remkoj/optimizely-dxp-clients/blob/main/packages/optimizely-graph-functions/docs/customizing_queries.md)
1736
1782
  */
1737
1783
  export const ${varName} : CmsComponent<get${contentType.key}DataQuery${displayTemplate ? ', ' + displayTemplate : ''}> = ({ data${displayTemplate ? ', layoutProps' : ''}, ctx }) => {
1738
- if (ctx) ctx.editableContentIsExperience = true
1784
+ if (ctx) (ctx as ServerContext).setEditableContentIsExperience(true)
1739
1785
  const composition = getFragmentData(ExperienceDataFragmentDoc, data).composition
1740
1786
  const componentName = '${contentType.displayName}'
1741
1787
  const componentInfo = '${contentType.description?.replaceAll("'", "\\'") ?? ''}'
@@ -1915,8 +1961,8 @@ const NextJsFactoryCommand = {
1915
1961
  // Build factory / component structure
1916
1962
  const componentFactoryDefintions = new Map();
1917
1963
  components.forEach(component => {
1918
- const componentDir = path.dirname(path.join(...component));
1919
- path.basename(path.join(...component));
1964
+ const componentDir = path.dirname(path.posix.join(...component));
1965
+ // const componentFile = path.basename(path.posix.join(...component));
1920
1966
  // Determine component target
1921
1967
  const componentKey = getComponentKey(component, basePath);
1922
1968
  let componentVariant = (component.length > 1 ? component.at(component.length - 1) ?? 'default' : 'default').replace('index', 'default');
@@ -2050,12 +2096,12 @@ function shouldWriteFactory(factoryFile, force = false, debug = false) {
2050
2096
  function processName(input) {
2051
2097
  if (input == ROOT_FACTORY_KEY)
2052
2098
  return "Cms";
2053
- const nameSegements = input.split(/[-\_]/g);
2099
+ const nameSegements = input.split(/[-_]/g);
2054
2100
  return nameSegements.map(ucFirst).join('');
2055
2101
  }
2056
2102
  function generateFactory(factoryInfo, factoryKey) {
2057
2103
  // Get the factory name
2058
- const factoryName = factoryKey.split(path.sep).map(processName).join("") + "Factory";
2104
+ const factoryName = factoryKey.split(path.posix.sep).map(processName).join("") + "Factory";
2059
2105
  // Get the components and sub-factories, sorted by key to minimize changes between runs
2060
2106
  const components = [...factoryInfo.entries].sort((a, b) => { return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; });
2061
2107
  const subFactories = [...factoryInfo.subfactories].sort((a, b) => { return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; });
@@ -2371,12 +2417,12 @@ const SchemaDownloadCommand = {
2371
2417
  const fullSchemaDir = path.normalize(path.join(projectPath, schemaDir));
2372
2418
  const relativeSchemaDir = path.relative(projectPath, fullSchemaDir);
2373
2419
  process.stdout.write(`\n${figures.arrowRight} Validating schema folder ${relativeSchemaDir}\n`);
2374
- const schemaDirInfo = await fsAsync.stat(fullSchemaDir).catch((e) => { if (e.code == 'ENOENT')
2420
+ const schemaDirInfo = await fs$1.stat(fullSchemaDir).catch((e) => { if (e.code == 'ENOENT')
2375
2421
  return undefined;
2376
2422
  else
2377
2423
  throw e; });
2378
2424
  if (!schemaDirInfo) {
2379
- await fsAsync.mkdir(fullSchemaDir, { recursive: true });
2425
+ await fs$1.mkdir(fullSchemaDir, { recursive: true });
2380
2426
  }
2381
2427
  else if (!schemaDirInfo.isDirectory()) {
2382
2428
  process.stderr.write(chalk.redBright(chalk.bold(`${figures.cross} [Error] The schema directory exists, but is not a directory (${relativeSchemaDir})\n`)));
@@ -2389,7 +2435,7 @@ const SchemaDownloadCommand = {
2389
2435
  const schemaFile = path.normalize(path.join(fullSchemaDir, `${schema.title.toLowerCase()}.schema.json`));
2390
2436
  const relativePath = path.relative(projectPath, schemaFile);
2391
2437
  try {
2392
- await fsAsync.writeFile(schemaFile, JSON.stringify(schema.schema, undefined, 2), {
2438
+ await fs$1.writeFile(schemaFile, JSON.stringify(schema.schema, undefined, 2), {
2393
2439
  encoding: "utf-8",
2394
2440
  flag: force ? 'w' : 'wx'
2395
2441
  });
@@ -2639,7 +2685,7 @@ const MigrateCommand = {
2639
2685
  const operations = [];
2640
2686
  for (const file of files) {
2641
2687
  const fullPath = path.join(componentsPath, file);
2642
- const pathInfo = await fsAsync.stat(fullPath);
2688
+ const pathInfo = await fs$1.stat(fullPath);
2643
2689
  if (pathInfo.isDirectory()) { // We only need to rename directories
2644
2690
  const dirName = path.basename(fullPath);
2645
2691
  const dirParent = path.dirname(fullPath);
@@ -2662,13 +2708,260 @@ const MigrateCommand = {
2662
2708
  }
2663
2709
  }
2664
2710
  for (const operation of operations) {
2665
- void await fsAsync.rename(operation.from, operation.to);
2711
+ void await fs$1.rename(operation.from, operation.to);
2666
2712
  }
2667
2713
  NextJsFactoryCommand.handler(args);
2668
2714
  }
2669
2715
  };
2670
2716
 
2671
- var version = "6.0.0-rc.1";
2717
+ const SECTION_START = '<!-- @remkoj/optimizely-packages:start -->';
2718
+ const SECTION_END = '<!-- @remkoj/optimizely-packages:end -->';
2719
+ const ProjectAiCommand = {
2720
+ command: 'project:ai',
2721
+ describe: 'Create or update AI assistant configuration files (AGENTS.md, CLAUDE.md, GitHub Copilot, Cursor) so installed @remkoj package documentation is available to the model',
2722
+ builder: (yargs) => {
2723
+ yargs.option('force', {
2724
+ alias: 'f',
2725
+ description: 'Overwrite existing files entirely instead of merging into existing content',
2726
+ boolean: true,
2727
+ type: 'boolean',
2728
+ demandOption: false,
2729
+ default: false,
2730
+ });
2731
+ yargs.option('tools', {
2732
+ alias: 't',
2733
+ description: 'AI tools to configure',
2734
+ array: true,
2735
+ type: 'string',
2736
+ choices: ['agents', 'claude', 'copilot', 'cursor'],
2737
+ demandOption: false,
2738
+ default: ['agents', 'claude', 'copilot', 'cursor'],
2739
+ });
2740
+ return yargs;
2741
+ },
2742
+ handler: async (args) => {
2743
+ const projectPath = path.isAbsolute(args.path)
2744
+ ? args.path
2745
+ : path.normalize(path.join(process.cwd(), args.path));
2746
+ const force = args.force ?? false;
2747
+ const tools = args.tools ?? ['agents', 'claude', 'copilot', 'cursor'];
2748
+ // Discover installed @remkoj packages that include AGENTS.md
2749
+ process.stdout.write(`\n${figures.arrowRight} Scanning for @remkoj packages with AGENTS.md\n`);
2750
+ const packages = await findRemkojPackages(projectPath);
2751
+ if (packages.length === 0) {
2752
+ process.stderr.write(chalk.yellowBright(`\n${figures.warning} No @remkoj packages with AGENTS.md found in node_modules — run \`npm install\` first.\n`));
2753
+ return;
2754
+ }
2755
+ for (const pkg of packages) {
2756
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} ${chalk.bold(pkg.name)}\n`);
2757
+ }
2758
+ if (tools.includes('agents')) {
2759
+ process.stdout.write(`\n${figures.arrowRight} Updating AGENTS.md\n`);
2760
+ await updateWithSectionMarkers(path.join(projectPath, 'AGENTS.md'), buildAgentsMdSection(packages, projectPath), buildAgentsMdPreamble(), force, projectPath);
2761
+ }
2762
+ if (tools.includes('claude')) {
2763
+ process.stdout.write(`\n${figures.arrowRight} Writing CLAUDE.md\n`);
2764
+ await writeNewFileOnly(path.join(projectPath, 'CLAUDE.md'), `# CLAUDE.md\n\nSee [AGENTS.md](./AGENTS.md) for guidance on working with the Optimizely DXP packages in this project.\n`, force, projectPath);
2765
+ }
2766
+ if (tools.includes('copilot')) {
2767
+ process.stdout.write(`\n${figures.arrowRight} Updating GitHub Copilot instruction files\n`);
2768
+ await updateCopilotInstructions(projectPath, packages);
2769
+ }
2770
+ if (tools.includes('cursor')) {
2771
+ process.stdout.write(`\n${figures.arrowRight} Writing Cursor rules\n`);
2772
+ const rulesDir = path.join(projectPath, '.cursor', 'rules');
2773
+ await ensureDir(rulesDir);
2774
+ await writeCursorRules(path.join(rulesDir, 'optimizely-packages.mdc'), packages, projectPath);
2775
+ }
2776
+ process.stdout.write(`\n${chalk.greenBright(chalk.bold(figures.tick))} AI assistant configuration updated successfully\n`);
2777
+ },
2778
+ };
2779
+ // ── Helpers ───────────────────────────────────────────────────────────────────
2780
+ async function findRemkojPackages(projectPath) {
2781
+ const scopeDir = path.join(projectPath, 'node_modules', '@remkoj');
2782
+ const packages = [];
2783
+ try {
2784
+ const dirs = await fs$1.readdir(scopeDir);
2785
+ for (const dir of dirs) {
2786
+ const agentsPath = path.join(scopeDir, dir, 'AGENTS.md');
2787
+ try {
2788
+ await fs$1.access(agentsPath);
2789
+ packages.push({ name: `@remkoj/${dir}`, agentsPath });
2790
+ }
2791
+ catch {
2792
+ // Package has no AGENTS.md — skip silently
2793
+ }
2794
+ }
2795
+ }
2796
+ catch {
2797
+ // @remkoj scope directory not found in node_modules
2798
+ }
2799
+ return packages;
2800
+ }
2801
+ function wrapSection(content) {
2802
+ return `${SECTION_START}\n${content}\n${SECTION_END}`;
2803
+ }
2804
+ function buildReferenceList(packages, projectPath) {
2805
+ return packages
2806
+ .map((pkg) => {
2807
+ const rel = path.relative(projectPath, pkg.agentsPath).replace(/\\/g, '/');
2808
+ return `- [\`${pkg.name}\`](${rel})`;
2809
+ })
2810
+ .join('\n');
2811
+ }
2812
+ function buildAgentsMdPreamble() {
2813
+ return `# AI Agent Documentation — Optimizely DXP Packages
2814
+
2815
+ The section below is auto-generated by \`opti-cms project:ai\`. Re-run after installing or upgrading \`@remkoj\` packages.
2816
+
2817
+ `;
2818
+ }
2819
+ function buildAgentsMdSection(packages, projectPath) {
2820
+ return `The following \`@remkoj\` packages are installed. Read their \`AGENTS.md\` for documentation:\n\n${buildReferenceList(packages, projectPath)}`;
2821
+ }
2822
+ /**
2823
+ * Creates or updates the auto-generated section delimited by SECTION_START/SECTION_END markers.
2824
+ *
2825
+ * - File absent: creates it with preamble + section.
2826
+ * - File present with markers: replaces the section in-place.
2827
+ * - File present without markers: appends the section (preserves existing content).
2828
+ * - force=true: always replaces the entire file with preamble + section.
2829
+ */
2830
+ async function updateWithSectionMarkers(filePath, sectionContent, preamble, force, projectPath) {
2831
+ const rel = path.relative(projectPath, filePath);
2832
+ const section = wrapSection(sectionContent);
2833
+ let existing;
2834
+ try {
2835
+ existing = await fs$1.readFile(filePath, 'utf-8');
2836
+ }
2837
+ catch {
2838
+ // File does not exist — will be created
2839
+ }
2840
+ if (!existing || force) {
2841
+ await fs$1.writeFile(filePath, preamble + section + '\n', 'utf-8');
2842
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} ${existing ? 'Replaced' : 'Created'} ${rel}\n`);
2843
+ return;
2844
+ }
2845
+ if (existing.includes(SECTION_START) && existing.includes(SECTION_END)) {
2846
+ const before = existing.slice(0, existing.indexOf(SECTION_START));
2847
+ const after = existing.slice(existing.indexOf(SECTION_END) + SECTION_END.length);
2848
+ await fs$1.writeFile(filePath, before + section + after, 'utf-8');
2849
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} Updated section in ${rel}\n`);
2850
+ }
2851
+ else {
2852
+ await fs$1.writeFile(filePath, existing.trimEnd() + '\n\n' + section + '\n', 'utf-8');
2853
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} Appended section to ${rel}\n`);
2854
+ }
2855
+ }
2856
+ /**
2857
+ * Writes a file only when it does not already exist.
2858
+ * When force=true the file is always (re)written.
2859
+ */
2860
+ async function writeNewFileOnly(filePath, content, force, projectPath) {
2861
+ const rel = path.relative(projectPath, filePath);
2862
+ let exists = false;
2863
+ try {
2864
+ await fs$1.access(filePath);
2865
+ exists = true;
2866
+ }
2867
+ catch {
2868
+ // File does not exist
2869
+ }
2870
+ if (exists && !force) {
2871
+ process.stdout.write(` ${chalk.yellowBright(figures.info)} ${rel} already exists — use --force to overwrite\n`);
2872
+ return;
2873
+ }
2874
+ await fs$1.writeFile(filePath, content, 'utf-8');
2875
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} ${exists ? 'Replaced' : 'Created'} ${rel}\n`);
2876
+ }
2877
+ /**
2878
+ * Writes one `.vscode/instructions/remkoj-<name>.instructions.md` file per package,
2879
+ * each containing a `#file:` reference to the package's AGENTS.md in node_modules.
2880
+ * VS Code Copilot resolves `#file:` references at query time, so content stays
2881
+ * current after a package upgrade without re-running this command.
2882
+ *
2883
+ * Also removes the deprecated `github.copilot.chat.codeGeneration.instructions`
2884
+ * key from `.vscode/settings.json` if it was written by a previous run.
2885
+ */
2886
+ async function updateCopilotInstructions(projectPath, packages) {
2887
+ const instructionsDir = path.join(projectPath, '.vscode', 'instructions');
2888
+ await ensureDir(instructionsDir);
2889
+ // Remove stale remkoj instruction files from previous runs
2890
+ const FILE_PREFIX = 'remkoj-';
2891
+ try {
2892
+ const existing = await fs$1.readdir(instructionsDir);
2893
+ for (const f of existing) {
2894
+ if (f.startsWith(FILE_PREFIX) && f.endsWith('.instructions.md')) {
2895
+ await fs$1.unlink(path.join(instructionsDir, f));
2896
+ }
2897
+ }
2898
+ }
2899
+ catch {
2900
+ // Directory unreadable — ignore
2901
+ }
2902
+ // Write one instruction file per package using a #file: reference
2903
+ for (const pkg of packages) {
2904
+ const safeName = pkg.name.replace('@remkoj/', '');
2905
+ const filePath = path.join(instructionsDir, `${FILE_PREFIX}${safeName}.instructions.md`);
2906
+ const ref = path.relative(projectPath, pkg.agentsPath).replace(/\\/g, '/');
2907
+ const content = `---\napplyTo: "**"\n---\n\n#file:${ref}\n`;
2908
+ await fs$1.writeFile(filePath, content, 'utf-8');
2909
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} Wrote ${path.relative(projectPath, filePath)}\n`);
2910
+ }
2911
+ // Clean up the deprecated settings key written by previous versions of this command
2912
+ await removeDeprecatedCopilotSetting(projectPath);
2913
+ }
2914
+ async function removeDeprecatedCopilotSetting(projectPath) {
2915
+ const settingsFile = path.join(projectPath, '.vscode', 'settings.json');
2916
+ let settings;
2917
+ try {
2918
+ settings = JSON.parse(await fs$1.readFile(settingsFile, 'utf-8'));
2919
+ }
2920
+ catch {
2921
+ return; // File absent or invalid — nothing to clean up
2922
+ }
2923
+ const key = 'github.copilot.chat.codeGeneration.instructions';
2924
+ if (!(key in settings))
2925
+ return;
2926
+ const remaining = settings[key].filter((e) => !e.file?.startsWith('node_modules/@remkoj/'));
2927
+ if (remaining.length === 0) {
2928
+ delete settings[key];
2929
+ }
2930
+ else {
2931
+ settings[key] = remaining;
2932
+ }
2933
+ await fs$1.writeFile(settingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
2934
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} Removed deprecated setting from ${path.relative(projectPath, settingsFile)}\n`);
2935
+ }
2936
+ /** Writes the Cursor rules file with a reference list; always regenerated since it is fully derived from node_modules. */
2937
+ async function writeCursorRules(filePath, packages, projectPath) {
2938
+ const rel = path.relative(projectPath, filePath);
2939
+ let exists = false;
2940
+ try {
2941
+ await fs$1.access(filePath);
2942
+ exists = true;
2943
+ }
2944
+ catch {
2945
+ // File does not exist
2946
+ }
2947
+ const list = buildReferenceList(packages, projectPath);
2948
+ const content = [
2949
+ '---',
2950
+ 'description: Documentation for @remkoj Optimizely DXP packages installed in this project',
2951
+ 'alwaysApply: true',
2952
+ '---',
2953
+ '',
2954
+ wrapSection(`The following \`@remkoj\` packages are installed. Read their \`AGENTS.md\` for documentation:\n\n${list}`),
2955
+ '',
2956
+ ].join('\n');
2957
+ await fs$1.writeFile(filePath, content, 'utf-8');
2958
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} ${exists ? 'Updated' : 'Created'} ${rel}\n`);
2959
+ }
2960
+ async function ensureDir(dirPath) {
2961
+ await fs$1.mkdir(dirPath, { recursive: true });
2962
+ }
2963
+
2964
+ var version = "6.0.0-rc.2";
2672
2965
  var name = "opti-cms";
2673
2966
  var APP = {
2674
2967
  version: version,
@@ -2685,14 +2978,6 @@ const CmsVersionCommand = {
2685
2978
  const client = createCmsClient(args);
2686
2979
  if (client.debug)
2687
2980
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Reading version information Optimizely CMS\n`));
2688
- const versionInfo = await client.getInstanceInfo().catch((error) => {
2689
- switch (error.message) {
2690
- case "fetch failed":
2691
- throw new Error("Unable to connect to Optimizely CMS, please verify that the CMS URL is correct");
2692
- default:
2693
- throw error;
2694
- }
2695
- });
2696
2981
  const info = new Table({
2697
2982
  head: [
2698
2983
  chalk.yellow(chalk.bold("Component")),
@@ -2701,14 +2986,10 @@ const CmsVersionCommand = {
2701
2986
  colWidths: [30, 60],
2702
2987
  colAligns: ["left", "left"]
2703
2988
  });
2704
- info.push(["Base URL", versionInfo.baseUrl ?? client.cmsUrl.href]);
2989
+ info.push(["Base URL", client.cmsUrl.href]);
2705
2990
  info.push(["Client API", client.apiVersion]);
2706
- info.push(["Service API", versionInfo.apiVersion]);
2707
- info.push(["CMS Build", versionInfo.cmsVersion]);
2708
- info.push(["Service Build", versionInfo.serviceVersion]);
2709
2991
  info.push(["SDK", APP.version]);
2710
2992
  process.stdout.write(info.toString() + "\n");
2711
- process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Optimizely CMS Status: ${versionInfo.status}\n`));
2712
2993
  process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
2713
2994
  }
2714
2995
  };
@@ -2765,7 +3046,7 @@ const CmsResetCommand = {
2765
3046
  });
2766
3047
  if (!didRemoveCustomProperties) {
2767
3048
  const ctUrl = new URL('/ui/EPiServer.Cms.UI.Admin/default#/ContentTypes', client.cmsUrl);
2768
- process.stdout.write(`${chalk.redBright(figures.cross)} Custom properties on the \"Blank Section\" and \"Blank Experience\" Content Types must be removed manually, please remove and restart command.\n`);
3049
+ process.stdout.write(`${chalk.redBright(figures.cross)} Custom properties on the "Blank Section" and "Blank Experience" Content Types must be removed manually, please remove and restart command.\n`);
2769
3050
  process.stdout.write(` ${figures.arrowRight} Content Type manager: ${ctUrl}\n`);
2770
3051
  process.exit(1);
2771
3052
  }
@@ -2799,7 +3080,7 @@ async function deleteContentItem(client, key, onlyChildren = false) {
2799
3080
  const children = await getContentItemChildren(client, key);
2800
3081
  let hasError = false;
2801
3082
  let removedCount = 0;
2802
- for (let child of children.items)
3083
+ for (const child of children.items)
2803
3084
  if (child.key) {
2804
3085
  removedCount = removedCount + await deleteContentItem(client, child.key).catch(e => {
2805
3086
  console.error(e);
@@ -2807,7 +3088,7 @@ async function deleteContentItem(client, key, onlyChildren = false) {
2807
3088
  return 0;
2808
3089
  });
2809
3090
  }
2810
- for (let child of children.assets)
3091
+ for (const child of children.assets)
2811
3092
  if (child.key) {
2812
3093
  removedCount = removedCount + await deleteContentItem(client, child.key).catch(e => {
2813
3094
  console.error(e);
@@ -2826,7 +3107,7 @@ async function deleteContentItem(client, key, onlyChildren = false) {
2826
3107
  process.stderr.write(chalk.redBright(` ${figures.cross}The ContentItem ${key} is a reserved item, skipping deletion\n`));
2827
3108
  return removedCount;
2828
3109
  }
2829
- const deleteResult = await client.contentDelete({ path: { key }, headers: { "cms-permanent-delete": true } }).then(r => r.key).catch((e) => {
3110
+ const deleteResult = await client.contentDelete({ path: { key }, headers: { "cms-permanent-delete": true } }).then(r => r ? r.key : key).catch((e) => {
2830
3111
  if (e.status == 404)
2831
3112
  return key;
2832
3113
  throw e;
@@ -2850,7 +3131,6 @@ async function resetSystemTypes(client) {
2850
3131
  const newType = await client.contentTypesPatch({
2851
3132
  path: { key: systemType },
2852
3133
  body: {
2853
- key: systemType,
2854
3134
  properties: {}
2855
3135
  }
2856
3136
  }).catch((e) => e.status == 404 ? undefined : null);
@@ -2862,7 +3142,7 @@ async function resetSystemTypes(client) {
2862
3142
  async function removeContentTypes(client) {
2863
3143
  const contentTypes = await getAllTypes(client);
2864
3144
  let removedCount = 0;
2865
- for (let contentType of contentTypes.filter(item => item.source == '' && !reservedTypes.includes(item.key))) {
3145
+ for (const contentType of contentTypes.filter(item => item.source == '' && !reservedTypes.includes(item.key))) {
2866
3146
  if (client.debug)
2867
3147
  process.stdout.write(` ${chalk.blueBright(figures.arrowRight)} Removing content type ${contentType.displayName} (${contentType.key})\n`);
2868
3148
  const result = await client.contentTypesDelete({ path: { key: contentType.key } }).catch((e) => {
@@ -2903,7 +3183,7 @@ async function getAllTemplates(client, batchSize = 100) {
2903
3183
  }
2904
3184
  throw e;
2905
3185
  });
2906
- const totalItemCount = items.totalItemCount ?? items.items?.length ?? 0;
3186
+ const totalItemCount = items.totalCount ?? items.totalItemCount ?? items.items?.length ?? 0;
2907
3187
  const pageSize = items.pageSize ?? items.items?.length ?? 0;
2908
3188
  const actualItems = items.items ?? [];
2909
3189
  const pageCount = Math.ceil(totalItemCount / pageSize);
@@ -2933,7 +3213,7 @@ async function getAllTypes(client, batchSize = 100) {
2933
3213
  }
2934
3214
  throw e;
2935
3215
  });
2936
- const totalItemCount = items.totalItemCount ?? items.items?.length ?? 0;
3216
+ const totalItemCount = items.totalCount ?? items.totalItemCount ?? items.items?.length ?? 0;
2937
3217
  const pageSize = items.pageSize ?? items.items?.length ?? 0;
2938
3218
  const actualItems = items.items ?? [];
2939
3219
  const pageCount = Math.ceil(totalItemCount / pageSize);
@@ -2963,7 +3243,7 @@ async function getAllAssets(client, parentKey, batchSize = 100) {
2963
3243
  }
2964
3244
  throw e;
2965
3245
  });
2966
- const totalItemCount = items.totalItemCount ?? items.items?.length ?? 0;
3246
+ const totalItemCount = items.totalCount ?? items.totalItemCount ?? items.items?.length ?? 0;
2967
3247
  const pageSize = items.pageSize ?? items.items?.length ?? 0;
2968
3248
  const actualItems = items.items ?? [];
2969
3249
  const pageCount = Math.ceil(totalItemCount / pageSize);
@@ -2993,7 +3273,7 @@ async function getAllItems(client, parentKey, batchSize = 100) {
2993
3273
  }
2994
3274
  throw e;
2995
3275
  });
2996
- const totalItemCount = items.totalItemCount ?? items.items?.length ?? 0;
3276
+ const totalItemCount = items.totalCount ?? items.totalItemCount ?? items.items?.length ?? 0;
2997
3277
  const pageSize = items.pageSize ?? items.items?.length ?? 0;
2998
3278
  const actualItems = items.items ?? [];
2999
3279
  const pageCount = Math.ceil(totalItemCount / pageSize);
@@ -3034,7 +3314,8 @@ const commands = [
3034
3314
  StylesDeleteCommand,
3035
3315
  TypesPullCommand,
3036
3316
  TypesPushCommand,
3037
- MigrateCommand
3317
+ MigrateCommand,
3318
+ ProjectAiCommand,
3038
3319
  ];
3039
3320
 
3040
3321
  async function main() {