@contrail/flexplm 1.7.4-alpha.368c8b1 → 1.7.4-alpha.57ee2a7

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.
@@ -15,6 +15,12 @@ export interface FCConfig {
15
15
  vibeEventEndpoint: string;
16
16
  csrfEndpoint: string;
17
17
  useDistinctRestEndPointForImages?: boolean;
18
+ /** Opt-in. When true, an item family that itself passes meetsCriteria() is published to the
19
+ * season even when every one of its options is still in a pre-development lifecycle stage. */
20
+ publishReleasedFamilyWithPreDevelopmentOptions?: boolean;
21
+ /** Only meaningful when publishReleasedFamilyWithPreDevelopmentOptions is true. When true, such a
22
+ * family is republished on EVERY publish, not only when one of its options actually changed. */
23
+ alwaysPublishReleasedFamilyWithPreDevelopmentOptions?: boolean;
18
24
  itemPreDevelopmentLifecycleStages: string[];
19
25
  identifierAtts?: {
20
26
  [key: string]: string[];
@@ -12,6 +12,12 @@ export declare class ItemFamilyChanges {
12
12
  colorDeletes: string[];
13
13
  colorUpdates: string[];
14
14
  colorUnchanged: string[];
15
+ /** Options that changed but are still pre-development. They do NOT produce
16
+ * LCSSKUSeasonLink events - they only signal that the family needs republishing. */
17
+ preDevelopmentOptionChanges: string[];
18
+ /** True when at least one option row was tracked at family level only, because the option is
19
+ * pre-development while the family itself is released. */
20
+ hasPreDevelopmentOnlyOptions: boolean;
15
21
  assortmentItemFullChangeMap: Map<string, any>;
16
22
  assortmentItemDeleteMap: Map<string, any>;
17
23
  itemToFederatedIdMapping: Map<string, string>;
@@ -14,6 +14,12 @@ class ItemFamilyChanges {
14
14
  this.colorDeletes = [];
15
15
  this.colorUpdates = [];
16
16
  this.colorUnchanged = [];
17
+ /** Options that changed but are still pre-development. They do NOT produce
18
+ * LCSSKUSeasonLink events - they only signal that the family needs republishing. */
19
+ this.preDevelopmentOptionChanges = [];
20
+ /** True when at least one option row was tracked at family level only, because the option is
21
+ * pre-development while the family itself is released. */
22
+ this.hasPreDevelopmentOnlyOptions = false;
17
23
  this.assortmentItemFullChangeMap = new Map();
18
24
  this.assortmentItemDeleteMap = new Map();
19
25
  this.itemToFederatedIdMapping = new Map();
@@ -32,6 +38,8 @@ class ItemFamilyChanges {
32
38
  s += ' colorDeletes: [' + this.colorDeletes + ']\n';
33
39
  s += ' colorUpdates: [' + this.colorUpdates + ']\n';
34
40
  s += ' colorUnchanged: [' + this.colorUnchanged + ']\n';
41
+ s += ' preDevelopmentOptionChanges: [' + this.preDevelopmentOptionChanges + ']\n';
42
+ s += ' hasPreDevelopmentOnlyOptions: ' + this.hasPreDevelopmentOnlyOptions + '\n';
35
43
  s += 'assortmentItemFullChangeMap:\n';
36
44
  s += 'size: ' + this.assortmentItemFullChangeMap.size + '\n[';
37
45
  for (const key of this.assortmentItemFullChangeMap.keys()) {
@@ -62,6 +62,11 @@ export declare class BaseProcessPublishAssortment {
62
62
  getFullChangeAssortmentMap(fullChange: any): Map<string, object>;
63
63
  getDeleteChangesAssortmentMap(deleteChanges: any[]): Map<string, object>;
64
64
  getReleasedForDevelopmentItemAndFamilyIds(fullChange: any, deleteChanges: any): string[];
65
+ /** Adds item families that pass meetsCriteria() but whose option rows are all pre-development.
66
+ * Only ever ADDS family ids - never option ids, and never removes anything. */
67
+ protected addReleasedFamiliesOfPreDevelopmentOptions(aItems: any[], releasedForDevelopmentItemIds: string[]): void;
68
+ protected publishReleasedFamilyWithPreDevOptions(): boolean;
69
+ protected alwaysPublishReleasedFamilyWithPreDevOptions(): boolean;
65
70
  meetsCriteria(aItem: any): boolean;
66
71
  processPublish(pcd: PublishChangeData, changeDetail: any, fullChange: any, deleteChanges: any): Promise<{
67
72
  results: {
@@ -11,6 +11,7 @@ const fsPromise = require("fs/promises");
11
11
  const path = require("path");
12
12
  const app_framework_1 = require("@contrail/app-framework");
13
13
  const event_short_message_status_1 = require("../util/event-short-message-status");
14
+ const config_defaults_1 = require("../util/config-defaults");
14
15
  class BaseProcessPublishAssortment {
15
16
  constructor(_config, _dc, _mapFileUtil) {
16
17
  this.TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -527,9 +528,43 @@ class BaseProcessPublishAssortment {
527
528
  }
528
529
  }
529
530
  }
531
+ if (this.publishReleasedFamilyWithPreDevOptions()) {
532
+ this.addReleasedFamiliesOfPreDevelopmentOptions(assortmentItemsArray, releasedForDevelopmentItemIds);
533
+ this.addReleasedFamiliesOfPreDevelopmentOptions(deleteChanges, releasedForDevelopmentItemIds);
534
+ }
530
535
  console.info('releasedForDevelopmentItemIds: ' + releasedForDevelopmentItemIds);
531
536
  return releasedForDevelopmentItemIds;
532
537
  }
538
+ /** Adds item families that pass meetsCriteria() but whose option rows are all pre-development.
539
+ * Only ever ADDS family ids - never option ids, and never removes anything. */
540
+ addReleasedFamiliesOfPreDevelopmentOptions(aItems, releasedForDevelopmentItemIds) {
541
+ for (const aItem of (aItems || [])) {
542
+ const item = aItem?.item;
543
+ const itemId = item?.id;
544
+ const itemFamilyId = item?.itemFamilyId;
545
+ if (!itemFamilyId || itemId === itemFamilyId) {
546
+ continue;
547
+ }
548
+ if (releasedForDevelopmentItemIds.includes(itemFamilyId)) {
549
+ continue;
550
+ }
551
+ if (this.meetsCriteria(aItem)) {
552
+ continue;
553
+ }
554
+ const familyItem = item?.itemFamily;
555
+ if (familyItem && this.meetsCriteria({ item: familyItem })) {
556
+ console.info('adding released family with pre-development options: ' + itemFamilyId);
557
+ releasedForDevelopmentItemIds.push(itemFamilyId);
558
+ }
559
+ }
560
+ }
561
+ publishReleasedFamilyWithPreDevOptions() {
562
+ return config_defaults_1.ConfigDefaults.isPropertyTrue(this.config?.publishReleasedFamilyWithPreDevelopmentOptions);
563
+ }
564
+ alwaysPublishReleasedFamilyWithPreDevOptions() {
565
+ return this.publishReleasedFamilyWithPreDevOptions()
566
+ && config_defaults_1.ConfigDefaults.isPropertyTrue(this.config?.alwaysPublishReleasedFamilyWithPreDevelopmentOptions);
567
+ }
533
568
  meetsCriteria(aItem) {
534
569
  const item = aItem?.item;
535
570
  const lifecycleStage = item?.lifecycleStage;
@@ -730,7 +765,12 @@ class BaseProcessPublishAssortment {
730
765
  for (const [itemId, aItem] of assortmentItemFullChangeMap) {
731
766
  const projectItem = aItem?.projectItem;
732
767
  const itemFamilyId = aItem?.item?.itemFamilyId;
733
- if (!pcd.releasedForDevelopmentItemIds.includes(itemFamilyId) || !pcd.releasedForDevelopmentItemIds.includes(itemId)) {
768
+ const familyInScope = pcd.releasedForDevelopmentItemIds.includes(itemFamilyId);
769
+ const itemInScope = pcd.releasedForDevelopmentItemIds.includes(itemId);
770
+ // Released family + pre-development option: track at family level only (no SKU events).
771
+ const familyOnly = !itemInScope && familyInScope && itemId !== itemFamilyId
772
+ && this.publishReleasedFamilyWithPreDevOptions();
773
+ if (!familyInScope || (!itemInScope && !familyOnly)) {
734
774
  continue;
735
775
  }
736
776
  const ifc = itemFamilyChanges.get(itemFamilyId) || new item_family_changes_1.ItemFamilyChanges(itemFamilyId, pcd.sinceDate);
@@ -738,10 +778,29 @@ class BaseProcessPublishAssortment {
738
778
  ifc.itemFamilyObject = itemId === itemFamilyId ? aItem?.item : aItem?.item?.itemFamily;
739
779
  itemFamilyChanges.set(itemFamilyId, ifc);
740
780
  }
781
+ else if (itemId === itemFamilyId && this.publishReleasedFamilyWithPreDevOptions()) {
782
+ // A pre-development option row may have created this bucket with the partial
783
+ // item.itemFamily; the family's own row carries the full item, so prefer it.
784
+ ifc.itemFamilyObject = aItem?.item;
785
+ }
741
786
  ifc.assortmentItemFullChangeMap.set(itemId, aItem);
742
787
  if (pcd.itemToFederatedIdMapping.has(itemId)) {
743
788
  ifc.itemToFederatedIdMapping.set(itemId, pcd.itemToFederatedIdMapping.get(itemId));
744
789
  }
790
+ if (familyOnly) {
791
+ ifc.hasPreDevelopmentOnlyOptions = true;
792
+ if (addIds.includes(itemId) || deleteIds.includes(itemId) || updateIds.includes(itemId)
793
+ || (projectItem && new Date(projectItem.updatedOn) > pcd.sinceDate)) {
794
+ ifc.preDevelopmentOptionChanges.push(itemId);
795
+ }
796
+ else {
797
+ ifc.colorUnchanged.push(itemId);
798
+ }
799
+ if (projectItem && !projectItem?.roles && aItem?.item?.roles) {
800
+ projectItem.roles = aItem?.item?.roles;
801
+ }
802
+ continue;
803
+ }
745
804
  if (itemId === itemFamilyId) {
746
805
  if (addIds.includes(itemId))
747
806
  ifc.familyAdd = true;
@@ -780,7 +839,11 @@ class BaseProcessPublishAssortment {
780
839
  for (const [itemId, delEntity] of assortmentItemDeleteMap) {
781
840
  const itemFamilyId = delEntity?.item?.itemFamilyId;
782
841
  const item = delEntity?.item;
783
- if (!pcd.releasedForDevelopmentItemIds.includes(itemFamilyId) || !pcd.releasedForDevelopmentItemIds.includes(itemId)) {
842
+ const familyInScope = pcd.releasedForDevelopmentItemIds.includes(itemFamilyId);
843
+ const itemInScope = pcd.releasedForDevelopmentItemIds.includes(itemId);
844
+ const familyOnly = !itemInScope && familyInScope && itemId !== itemFamilyId
845
+ && this.publishReleasedFamilyWithPreDevOptions();
846
+ if (!familyInScope || (!itemInScope && !familyOnly)) {
784
847
  continue;
785
848
  }
786
849
  else {
@@ -810,6 +873,10 @@ class BaseProcessPublishAssortment {
810
873
  if (itemId === itemFamilyId) {
811
874
  ifc.familyDelete = true;
812
875
  }
876
+ else if (familyOnly) {
877
+ ifc.hasPreDevelopmentOnlyOptions = true;
878
+ ifc.preDevelopmentOptionChanges.push(itemId);
879
+ }
813
880
  else {
814
881
  ifc.colorDeletes.push(itemId);
815
882
  }
@@ -857,6 +924,8 @@ class BaseProcessPublishAssortment {
857
924
  const projectItem = this.getProjectItem(itemFamilyChanges, itemFamilyChanges.itemFamilyId);
858
925
  const familyAssortmentItem = itemFamilyChanges.familyAdd || itemFamilyChanges.familyUpdate || itemFamilyChanges.familyDelete
859
926
  || itemFamilyChanges.colorAdds.length > 0 || itemFamilyChanges.colorUpdates.length > 0 || itemFamilyChanges.colorDeletes.length > 0
927
+ || itemFamilyChanges.preDevelopmentOptionChanges.length > 0
928
+ || (this.alwaysPublishReleasedFamilyWithPreDevOptions() && itemFamilyChanges.hasPreDevelopmentOnlyOptions)
860
929
  || (projectItem && this.updatedSinceDate(projectItem, itemFamilyChanges.sinceDate));
861
930
  //familyItemRemoved is used when adding the first option to an assortment
862
931
  //and will have updates for the family item.
@@ -1874,3 +1874,259 @@ describe('sendToFlexPLM / handleVibeIQFile / sendPublishPayloadEvent', () => {
1874
1874
  });
1875
1875
  });
1876
1876
  });
1877
+ describe('publishReleasedFamilyWithPreDevelopmentOptions', () => {
1878
+ const FAMILY_ID = 'AERfdvdAt1HHEVvQ';
1879
+ const LIME_ID = 'limeGreenItemId__';
1880
+ const MAROON_ID = 'maroonItemId_____';
1881
+ const SINCE = new Date('2026-09-03T16:54:20.778Z');
1882
+ const BEFORE_SINCE = '2026-09-01T00:00:00.000Z';
1883
+ const AFTER_SINCE = '2026-09-03T17:00:00.000Z';
1884
+ /** The full family item, as it appears on the family's own assortment row. */
1885
+ const fullFamilyItem = {
1886
+ id: FAMILY_ID, itemFamilyId: FAMILY_ID, name: 'Sweatpant', itemNumber: 11,
1887
+ lifecycleStage: 'released', roles: ['family'], richFamilyOnlyField: 'PRESENT'
1888
+ };
1889
+ /** The partial family item, as hydrated onto an option row. */
1890
+ const partialFamilyItem = {
1891
+ id: FAMILY_ID, itemFamilyId: FAMILY_ID, name: 'Sweatpant', itemNumber: 11,
1892
+ lifecycleStage: 'released', roles: ['family']
1893
+ };
1894
+ const familyRow = () => ({
1895
+ entityType: 'assortment-item',
1896
+ id: 'ai-' + FAMILY_ID,
1897
+ itemId: FAMILY_ID,
1898
+ item: { ...fullFamilyItem },
1899
+ projectItem: { id: 'proj:' + FAMILY_ID, itemId: FAMILY_ID, updatedOn: BEFORE_SINCE, roles: ['family'] }
1900
+ });
1901
+ const optionRow = (itemId, optionName, projectItemUpdatedOn) => ({
1902
+ entityType: 'assortment-item',
1903
+ id: 'ai-' + itemId,
1904
+ itemId,
1905
+ item: {
1906
+ id: itemId, itemFamilyId: FAMILY_ID, name: 'Sweatpant', optionName,
1907
+ optionGroup: 'color', lifecycleStage: 'concept', roles: ['color', 'option'],
1908
+ itemFamily: { ...partialFamilyItem }
1909
+ },
1910
+ projectItem: { id: 'proj:' + itemId, itemId, updatedOn: projectItemUpdatedOn, roles: ['color', 'option'] },
1911
+ familyProjectItem: { id: 'proj:' + FAMILY_ID, itemId: FAMILY_ID, updatedOn: BEFORE_SINCE, roles: ['family'] }
1912
+ });
1913
+ const emptyDetail = { adds: [], deletes: [], updates: [], familyItemsRemoved: [] };
1914
+ const baseConfig = { identifierAtts: { LCSProduct: ['itemNumber'] }, itemPreDevelopmentLifecycleStages: ['concept'] };
1915
+ const makePpa2 = (flagOn, alwaysOn) => {
1916
+ const cfg = {
1917
+ ...baseConfig,
1918
+ publishReleasedFamilyWithPreDevelopmentOptions: flagOn,
1919
+ alwaysPublishReleasedFamilyWithPreDevelopmentOptions: alwaysOn
1920
+ };
1921
+ const mfu = new transform_data_1.MapFileUtil(new sdk_1.Entities());
1922
+ return new base_process_publish_assortment_1.BaseProcessPublishAssortment(cfg, new data_converter_1.DataConverter(cfg, mfu), mfu);
1923
+ };
1924
+ const makePpa = (flagOn) => makePpa2(flagOn, false);
1925
+ const makePcd = (releasedIds) => {
1926
+ const sf = { entityReference: 'assortment:11EZML5dMn3tREuT', objectClass: 'LCSSeason' };
1927
+ const pcd = new publish_change_data_1.PublishChangeData('11EZML5dMn3tREuT', sf, 'apc1', SINCE);
1928
+ pcd.itemToFederatedIdMapping = new Map();
1929
+ pcd.releasedForDevelopmentItemIds = releasedIds;
1930
+ return pcd;
1931
+ };
1932
+ const eventSeasonFed = { entityReference: 'assortment:a1', objectClass: 'LCSSeason' };
1933
+ const stubEventDeps = (ppa) => {
1934
+ jest.spyOn(ppa, 'getAssortment').mockResolvedValue({ id: 'a1', publishToFlexPLM: true });
1935
+ jest.spyOn(ppa, 'getSeasonalData').mockResolvedValue({});
1936
+ jest.spyOn(map_utils_1.MapUtil, 'applyTransformMap').mockImplementation(async (...args) => args[2]);
1937
+ jest.spyOn(type_conversion_utils_1.TypeConversionUtils, 'getIdentifierProperties').mockResolvedValue(['itemNumber']);
1938
+ jest.spyOn(type_conversion_utils_1.TypeConversionUtils, 'getInformationalProperties').mockResolvedValue([]);
1939
+ jest.spyOn(type_conversion_utils_1.TypeConversionUtils, 'isOutboundCreatableFromEntity').mockResolvedValue(true);
1940
+ };
1941
+ afterEach(() => { jest.restoreAllMocks(); });
1942
+ /////////////////////////////////////////////////////////////////////////////
1943
+ // getReleasedForDevelopmentItemAndFamilyIds
1944
+ /////////////////////////////////////////////////////////////////////////////
1945
+ it('REGRESSION: flag off - released family with all-concept options stays out of scope', () => {
1946
+ const fullChange = { assortmentItems: [optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE),
1947
+ optionRow(MAROON_ID, 'Maroon', BEFORE_SINCE)] };
1948
+ expect(makePpa(false).getReleasedForDevelopmentItemAndFamilyIds(fullChange, [])).toEqual([]);
1949
+ });
1950
+ it('flag on - released family enters scope; concept options do NOT', () => {
1951
+ const fullChange = { assortmentItems: [optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE),
1952
+ optionRow(MAROON_ID, 'Maroon', BEFORE_SINCE)] };
1953
+ expect(makePpa(true).getReleasedForDevelopmentItemAndFamilyIds(fullChange, [])).toEqual([FAMILY_ID]);
1954
+ });
1955
+ it('flag on - concept family with concept options still stays out of scope', () => {
1956
+ const row = optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE);
1957
+ row.item.itemFamily = { ...partialFamilyItem, lifecycleStage: 'concept' };
1958
+ expect(makePpa(true).getReleasedForDevelopmentItemAndFamilyIds({ assortmentItems: [row] }, [])).toEqual([]);
1959
+ });
1960
+ it('flag on - family reached only through a delete record', () => {
1961
+ const deleted = optionRow(MAROON_ID, 'Maroon', BEFORE_SINCE);
1962
+ expect(makePpa(true).getReleasedForDevelopmentItemAndFamilyIds({ assortmentItems: [] }, [deleted]))
1963
+ .toEqual([FAMILY_ID]);
1964
+ });
1965
+ it('flag on - two concept options of the same family add the family only once', () => {
1966
+ const fullChange = { assortmentItems: [optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE),
1967
+ optionRow(MAROON_ID, 'Maroon', BEFORE_SINCE)] };
1968
+ expect(makePpa(true).getReleasedForDevelopmentItemAndFamilyIds(fullChange, [])).toHaveLength(1);
1969
+ });
1970
+ it('flag on - option row with no hydrated itemFamily is skipped without throwing', () => {
1971
+ const bare = optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE);
1972
+ delete bare.item.itemFamily;
1973
+ expect(makePpa(true).getReleasedForDevelopmentItemAndFamilyIds({ assortmentItems: [bare] }, [])).toEqual([]);
1974
+ });
1975
+ it('released family WITH its own assortment row is in scope with or without the flag', () => {
1976
+ const rows = { assortmentItems: [familyRow(), optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE)] };
1977
+ expect(makePpa(false).getReleasedForDevelopmentItemAndFamilyIds(rows, [])).toEqual([FAMILY_ID]);
1978
+ expect(makePpa(true).getReleasedForDevelopmentItemAndFamilyIds(rows, [])).toEqual([FAMILY_ID]);
1979
+ });
1980
+ it('string flag values from app config are honoured', () => {
1981
+ const fullChange = { assortmentItems: [optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE)] };
1982
+ expect(makePpa('true').getReleasedForDevelopmentItemAndFamilyIds(fullChange, [])).toEqual([FAMILY_ID]);
1983
+ expect(makePpa('false').getReleasedForDevelopmentItemAndFamilyIds(fullChange, [])).toEqual([]);
1984
+ });
1985
+ /////////////////////////////////////////////////////////////////////////////
1986
+ // getItemFamilyChanges
1987
+ /////////////////////////////////////////////////////////////////////////////
1988
+ it('ACTION 2: deleting a concept option marks the released family for republish', () => {
1989
+ const ppa = makePpa(true);
1990
+ const remaining = optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE);
1991
+ const deleted = optionRow(MAROON_ID, 'Maroon', BEFORE_SINCE);
1992
+ const pcd = makePcd([FAMILY_ID]);
1993
+ pcd.itemFamilyChanges = ppa.getItemFamilyChanges(pcd, emptyDetail, ppa.getFullChangeAssortmentMap({ assortmentItems: [remaining] }), ppa.getDeleteChangesAssortmentMap([deleted]));
1994
+ expect(pcd.itemFamilyChanges.size).toEqual(1);
1995
+ const ifc = pcd.itemFamilyChanges.get(FAMILY_ID);
1996
+ expect(ifc.preDevelopmentOptionChanges).toEqual([MAROON_ID]);
1997
+ expect(ifc.hasPreDevelopmentOnlyOptions).toBe(true);
1998
+ expect(ifc.colorDeletes).toEqual([]);
1999
+ expect(ifc.colorAdds).toEqual([]);
2000
+ expect(ifc.itemFamilyObject.id).toEqual(FAMILY_ID);
2001
+ });
2002
+ it('adding a concept option marks the family, not a colorAdd', () => {
2003
+ const ppa = makePpa(true);
2004
+ const added = optionRow(MAROON_ID, 'Maroon', BEFORE_SINCE);
2005
+ const pcd = makePcd([FAMILY_ID]);
2006
+ pcd.itemFamilyChanges = ppa.getItemFamilyChanges(pcd, { ...emptyDetail, adds: [{ id: MAROON_ID }] }, ppa.getFullChangeAssortmentMap({ assortmentItems: [added] }), new Map());
2007
+ const ifc = pcd.itemFamilyChanges.get(FAMILY_ID);
2008
+ expect(ifc.preDevelopmentOptionChanges).toEqual([MAROON_ID]);
2009
+ expect(ifc.colorAdds).toEqual([]);
2010
+ });
2011
+ it('ACTION 1: nothing changed - family bucket exists but signals no change', () => {
2012
+ const ppa = makePpa(true);
2013
+ const pcd = makePcd([FAMILY_ID]);
2014
+ const rows = { assortmentItems: [optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE),
2015
+ optionRow(MAROON_ID, 'Maroon', BEFORE_SINCE)] };
2016
+ pcd.itemFamilyChanges = ppa.getItemFamilyChanges(pcd, emptyDetail, ppa.getFullChangeAssortmentMap(rows), new Map());
2017
+ const ifc = pcd.itemFamilyChanges.get(FAMILY_ID);
2018
+ expect(ifc.preDevelopmentOptionChanges).toEqual([]);
2019
+ expect(ifc.colorUnchanged.sort()).toEqual([LIME_ID, MAROON_ID].sort());
2020
+ });
2021
+ it('REGRESSION: flag off - concept option rows produce no ItemFamilyChanges at all', () => {
2022
+ const ppa = makePpa(false);
2023
+ const pcd = makePcd([FAMILY_ID]);
2024
+ pcd.itemFamilyChanges = ppa.getItemFamilyChanges(pcd, emptyDetail, ppa.getFullChangeAssortmentMap({ assortmentItems: [optionRow(LIME_ID, 'Lime Green', AFTER_SINCE)] }), new Map());
2025
+ expect(pcd.itemFamilyChanges.size).toEqual(0);
2026
+ });
2027
+ it('REGRESSION: a released option is still a colorAdd, never a preDevelopmentOptionChange', () => {
2028
+ const ppa = makePpa(true);
2029
+ const released = optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE);
2030
+ released.item.lifecycleStage = 'development';
2031
+ const pcd = makePcd([FAMILY_ID, LIME_ID]);
2032
+ pcd.itemFamilyChanges = ppa.getItemFamilyChanges(pcd, { ...emptyDetail, adds: [{ id: LIME_ID }] }, ppa.getFullChangeAssortmentMap({ assortmentItems: [released] }), new Map());
2033
+ const ifc = pcd.itemFamilyChanges.get(FAMILY_ID);
2034
+ expect(ifc.colorAdds).toEqual([LIME_ID]);
2035
+ expect(ifc.preDevelopmentOptionChanges).toEqual([]);
2036
+ expect(ifc.hasPreDevelopmentOnlyOptions).toBe(false);
2037
+ });
2038
+ it('itemFamilyObject prefers the full family item when the OPTION row is iterated first', () => {
2039
+ const ppa = makePpa(true);
2040
+ const pcd = makePcd([FAMILY_ID]);
2041
+ const map = new Map();
2042
+ map.set(LIME_ID, optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE));
2043
+ map.set(FAMILY_ID, familyRow());
2044
+ pcd.itemFamilyChanges = ppa.getItemFamilyChanges(pcd, emptyDetail, map, new Map());
2045
+ const ifc = pcd.itemFamilyChanges.get(FAMILY_ID);
2046
+ expect(ifc.itemFamilyObject.richFamilyOnlyField).toEqual('PRESENT');
2047
+ });
2048
+ it('itemFamilyObject is the full family item when the FAMILY row is iterated first', () => {
2049
+ const ppa = makePpa(true);
2050
+ const pcd = makePcd([FAMILY_ID]);
2051
+ const map = new Map();
2052
+ map.set(FAMILY_ID, familyRow());
2053
+ map.set(LIME_ID, optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE));
2054
+ pcd.itemFamilyChanges = ppa.getItemFamilyChanges(pcd, emptyDetail, map, new Map());
2055
+ const ifc = pcd.itemFamilyChanges.get(FAMILY_ID);
2056
+ expect(ifc.itemFamilyObject.richFamilyOnlyField).toEqual('PRESENT');
2057
+ });
2058
+ it('delete record with a missing item entity is skipped safely', () => {
2059
+ const ppa = makePpa(true);
2060
+ const pcd = makePcd([FAMILY_ID]);
2061
+ const delMap = new Map();
2062
+ delMap.set('GHOST', { itemId: 'GHOST', item: undefined });
2063
+ pcd.itemFamilyChanges = ppa.getItemFamilyChanges(pcd, emptyDetail, new Map(), delMap);
2064
+ expect(pcd.itemFamilyChanges.size).toEqual(0);
2065
+ });
2066
+ /////////////////////////////////////////////////////////////////////////////
2067
+ // getEventsForItemFamilyChanges
2068
+ /////////////////////////////////////////////////////////////////////////////
2069
+ it('emits exactly one LCSProductSeasonLink and zero LCSSKUSeasonLink', async () => {
2070
+ const ppa = makePpa(true);
2071
+ const ifc = new item_family_changes_1.ItemFamilyChanges(FAMILY_ID, SINCE);
2072
+ ifc.itemFamilyObject = fullFamilyItem;
2073
+ ifc.hasPreDevelopmentOnlyOptions = true;
2074
+ ifc.preDevelopmentOptionChanges.push(MAROON_ID);
2075
+ ifc.assortmentItemFullChangeMap.set(LIME_ID, optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE));
2076
+ stubEventDeps(ppa);
2077
+ const events = await ppa.getEventsForItemFamilyChanges(ifc, 'a1', eventSeasonFed, new Map());
2078
+ expect(events).toHaveLength(1);
2079
+ expect(events[0].objectClass).toBe('LCSProductSeasonLink');
2080
+ expect(events[0].eventType).toBe('UPSERT_ON_SEASON');
2081
+ expect(events[0].entityReference).toBe('item:' + FAMILY_ID);
2082
+ expect(events[0].LCSProduct['itemNumber']).toBe(11);
2083
+ });
2084
+ it('REGRESSION: an ItemFamilyChanges with no changes still emits nothing', async () => {
2085
+ const ppa = makePpa(true);
2086
+ const ifc = new item_family_changes_1.ItemFamilyChanges(FAMILY_ID, SINCE);
2087
+ ifc.itemFamilyObject = fullFamilyItem;
2088
+ ifc.colorUnchanged.push(LIME_ID, MAROON_ID);
2089
+ ifc.assortmentItemFullChangeMap.set(LIME_ID, optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE));
2090
+ stubEventDeps(ppa);
2091
+ expect(await ppa.getEventsForItemFamilyChanges(ifc, 'a1', eventSeasonFed, new Map())).toHaveLength(0);
2092
+ });
2093
+ /////////////////////////////////////////////////////////////////////////////
2094
+ // alwaysPublishReleasedFamilyWithPreDevelopmentOptions
2095
+ /////////////////////////////////////////////////////////////////////////////
2096
+ const unchangedFamilyIfc = () => {
2097
+ const ifc = new item_family_changes_1.ItemFamilyChanges(FAMILY_ID, SINCE);
2098
+ ifc.itemFamilyObject = fullFamilyItem;
2099
+ ifc.hasPreDevelopmentOnlyOptions = true;
2100
+ ifc.colorUnchanged.push(LIME_ID, MAROON_ID);
2101
+ ifc.assortmentItemFullChangeMap.set(LIME_ID, optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE));
2102
+ return ifc;
2103
+ };
2104
+ it('ACTION 1 with always-flag: unchanged family still emits one LCSProductSeasonLink', async () => {
2105
+ const ppa = makePpa2(true, true);
2106
+ stubEventDeps(ppa);
2107
+ const events = await ppa.getEventsForItemFamilyChanges(unchangedFamilyIfc(), 'a1', eventSeasonFed, new Map());
2108
+ expect(events).toHaveLength(1);
2109
+ expect(events[0].objectClass).toBe('LCSProductSeasonLink');
2110
+ });
2111
+ it('always-flag off: unchanged family emits nothing', async () => {
2112
+ const ppa = makePpa2(true, false);
2113
+ stubEventDeps(ppa);
2114
+ expect(await ppa.getEventsForItemFamilyChanges(unchangedFamilyIfc(), 'a1', eventSeasonFed, new Map()))
2115
+ .toHaveLength(0);
2116
+ });
2117
+ it('always-flag cannot act alone when the primary flag is off', async () => {
2118
+ const ppa = makePpa2(false, true);
2119
+ stubEventDeps(ppa);
2120
+ expect(await ppa.getEventsForItemFamilyChanges(unchangedFamilyIfc(), 'a1', eventSeasonFed, new Map()))
2121
+ .toHaveLength(0);
2122
+ });
2123
+ it('REGRESSION: always-flag does not republish an ordinary unchanged released family', async () => {
2124
+ const ppa = makePpa2(true, true);
2125
+ stubEventDeps(ppa);
2126
+ const ifc = new item_family_changes_1.ItemFamilyChanges(FAMILY_ID, SINCE);
2127
+ ifc.itemFamilyObject = fullFamilyItem;
2128
+ ifc.colorUnchanged.push(LIME_ID);
2129
+ ifc.assortmentItemFullChangeMap.set(LIME_ID, optionRow(LIME_ID, 'Lime Green', BEFORE_SINCE));
2130
+ expect(await ppa.getEventsForItemFamilyChanges(ifc, 'a1', eventSeasonFed, new Map())).toHaveLength(0);
2131
+ });
2132
+ });
@@ -20,6 +20,8 @@ export declare class ConfigDefaults {
20
20
  processAsItem: boolean;
21
21
  };
22
22
  useDistinctRestEndPointForImages: boolean;
23
+ publishReleasedFamilyWithPreDevelopmentOptions: boolean;
24
+ alwaysPublishReleasedFamilyWithPreDevelopmentOptions: boolean;
23
25
  csrfEndpoint: string;
24
26
  vibeEventEndpoint: string;
25
27
  payloadDefaultAsArray: boolean;
@@ -67,6 +67,8 @@ class ConfigDefaults {
67
67
  processAsItem: false,
68
68
  },
69
69
  useDistinctRestEndPointForImages: false,
70
+ publishReleasedFamilyWithPreDevelopmentOptions: false,
71
+ alwaysPublishReleasedFamilyWithPreDevelopmentOptions: false,
70
72
  csrfEndpoint: '/servlet/rest/security/csrf',
71
73
  vibeEventEndpoint: '/rfa/vibeiq/vibeEvents',
72
74
  payloadDefaultAsArray: true,
@@ -393,3 +393,50 @@ describe('all tests', () => {
393
393
  });
394
394
  });
395
395
  });
396
+ describe('publishReleasedFamilyWithPreDevelopmentOptions config', () => {
397
+ const config = {
398
+ apiHost: 'http://test.com',
399
+ userName: 'vibeiq',
400
+ password: 'vibeiq',
401
+ plmEnviornment: 'SB'
402
+ };
403
+ it('publishReleasedFamilyWithPreDevelopmentOptions-get default', async () => {
404
+ const startConfig = Object.assign({}, config);
405
+ const fcConfig = await config_defaults_1.ConfigDefaults.setConfigDefaults(startConfig);
406
+ expect(fcConfig['publishReleasedFamilyWithPreDevelopmentOptions']).toBe(false);
407
+ });
408
+ it('alwaysPublishReleasedFamilyWithPreDevelopmentOptions-get default', async () => {
409
+ const startConfig = Object.assign({}, config);
410
+ const fcConfig = await config_defaults_1.ConfigDefaults.setConfigDefaults(startConfig);
411
+ expect(fcConfig['alwaysPublishReleasedFamilyWithPreDevelopmentOptions']).toBe(false);
412
+ });
413
+ it('publishReleasedFamilyWithPreDevelopmentOptions-set boolean true', async () => {
414
+ const startConfig = Object.assign({}, config);
415
+ startConfig['publishReleasedFamilyWithPreDevelopmentOptions'] = true;
416
+ const fcConfig = await config_defaults_1.ConfigDefaults.setConfigDefaults(startConfig);
417
+ expect(config_defaults_1.ConfigDefaults.isPropertyTrue(fcConfig['publishReleasedFamilyWithPreDevelopmentOptions'])).toBe(true);
418
+ });
419
+ it('publishReleasedFamilyWithPreDevelopmentOptions-set string true from app config', async () => {
420
+ const startConfig = Object.assign({}, config);
421
+ startConfig['publishReleasedFamilyWithPreDevelopmentOptions'] = 'true';
422
+ const fcConfig = await config_defaults_1.ConfigDefaults.setConfigDefaults(startConfig);
423
+ expect(config_defaults_1.ConfigDefaults.isPropertyTrue(fcConfig['publishReleasedFamilyWithPreDevelopmentOptions'])).toBe(true);
424
+ });
425
+ it('publishReleasedFamilyWithPreDevelopmentOptions-string false stays off', async () => {
426
+ const startConfig = Object.assign({}, config);
427
+ startConfig['publishReleasedFamilyWithPreDevelopmentOptions'] = 'false';
428
+ const fcConfig = await config_defaults_1.ConfigDefaults.setConfigDefaults(startConfig);
429
+ expect(config_defaults_1.ConfigDefaults.isPropertyTrue(fcConfig['publishReleasedFamilyWithPreDevelopmentOptions'])).toBe(false);
430
+ });
431
+ it('both flags can be delivered through complexConfig', async () => {
432
+ const startConfig = Object.assign({}, config, {
433
+ complexConfig: {
434
+ publishReleasedFamilyWithPreDevelopmentOptions: true,
435
+ alwaysPublishReleasedFamilyWithPreDevelopmentOptions: 'true'
436
+ }
437
+ });
438
+ const fcConfig = await config_defaults_1.ConfigDefaults.setConfigDefaults(startConfig);
439
+ expect(config_defaults_1.ConfigDefaults.isPropertyTrue(fcConfig['publishReleasedFamilyWithPreDevelopmentOptions'])).toBe(true);
440
+ expect(config_defaults_1.ConfigDefaults.isPropertyTrue(fcConfig['alwaysPublishReleasedFamilyWithPreDevelopmentOptions'])).toBe(true);
441
+ });
442
+ });
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@contrail/flexplm",
3
- "version": "1.7.4-alpha.368c8b1",
3
+ "version": "1.7.4-alpha.57ee2a7",
4
4
  "description": "Library used for integration with flexplm.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
7
  "bin": {
8
- "flexplm-mapping": "lib/cli/index.js",
9
- "flexplm-config": "lib/cli/config-index.js"
8
+ "flexplm-mapping": "lib/cli/index.js"
10
9
  },
11
10
  "files": [
12
11
  "lib/**/*",
@@ -2,13 +2,9 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
 
5
- const TEMPLATES = ['mapping-template.ts.template', 'config-template.json.template'];
5
+ const SRC = path.join('src', 'cli', 'template', 'mapping-template.ts.template');
6
+ const DST = path.join('lib', 'cli', 'template', 'mapping-template.ts.template');
6
7
 
7
- for (const templateFilename of TEMPLATES) {
8
- const SRC = path.join('src', 'cli', 'template', templateFilename);
9
- const DST = path.join('lib', 'cli', 'template', templateFilename);
10
-
11
- fs.mkdirSync(path.dirname(DST), { recursive: true });
12
- fs.copyFileSync(SRC, DST);
13
- console.log(`Copied ${SRC} -> ${DST}`);
14
- }
8
+ fs.mkdirSync(path.dirname(DST), { recursive: true });
9
+ fs.copyFileSync(SRC, DST);
10
+ console.log(`Copied ${SRC} -> ${DST}`);
@@ -1,5 +0,0 @@
1
- export declare class ConfigCreateCommand {
2
- private prompt;
3
- private findTemplate;
4
- run(): Promise<void>;
5
- }
@@ -1,85 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || function (mod) {
19
- if (mod && mod.__esModule) return mod;
20
- var result = {};
21
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
- __setModuleDefault(result, mod);
23
- return result;
24
- };
25
- Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.ConfigCreateCommand = void 0;
27
- const fs = __importStar(require("fs"));
28
- const path = __importStar(require("path"));
29
- const readline = __importStar(require("readline"));
30
- const TEMPLATE_FILENAME = 'config-template.json.template';
31
- const ORG_PLACEHOLDER = '<ORG_NAME>';
32
- const APP_IDENTIFIER_PLACEHOLDER = '<APP_IDENTIFIER>';
33
- const DEFAULT_APP_IDENTIFIER = '@vibeiq/flexplm-connector';
34
- class ConfigCreateCommand {
35
- prompt(rl, question) {
36
- return new Promise((resolve) => {
37
- rl.question(question, (answer) => resolve(answer));
38
- });
39
- }
40
- findTemplate() {
41
- const candidates = [
42
- path.join(__dirname, '..', 'template', TEMPLATE_FILENAME),
43
- path.join(__dirname, '..', '..', '..', 'src', 'cli', 'template', TEMPLATE_FILENAME),
44
- ];
45
- for (const candidate of candidates) {
46
- if (fs.existsSync(candidate)) {
47
- return candidate;
48
- }
49
- }
50
- throw new Error(`Could not locate ${TEMPLATE_FILENAME}. Tried:\n ${candidates.join('\n ')}`);
51
- }
52
- async run() {
53
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
54
- const onSigint = () => {
55
- rl.close();
56
- process.stdout.write('\n');
57
- process.exit(130);
58
- };
59
- process.once('SIGINT', onSigint);
60
- let orgName;
61
- let appIdentifier;
62
- try {
63
- orgName = (await this.prompt(rl, 'orgName: ')).trim();
64
- appIdentifier = (await this.prompt(rl, `appIdentifier (default: ${DEFAULT_APP_IDENTIFIER}): `)).trim() || DEFAULT_APP_IDENTIFIER;
65
- }
66
- finally {
67
- process.removeListener('SIGINT', onSigint);
68
- rl.close();
69
- }
70
- if (!orgName) {
71
- throw new Error('orgName is required');
72
- }
73
- const templatePath = this.findTemplate();
74
- const templateBody = fs.readFileSync(templatePath, 'utf8');
75
- const rendered = templateBody.split(ORG_PLACEHOLDER).join(orgName).split(APP_IDENTIFIER_PLACEHOLDER).join(appIdentifier);
76
- const outPath = path.resolve(process.cwd(), `${orgName}-flexplmConfig.json`);
77
- if (fs.existsSync(outPath)) {
78
- throw new Error(`Refusing to overwrite existing file: ${outPath}`);
79
- }
80
- fs.writeFileSync(outPath, rendered, 'utf8');
81
- console.log(`Created ${outPath}`);
82
- console.log('"orgName" and "appIdentifier" are pre-filled, but this file is not complete yet: FlexPLM connectivity also requires apiHost, userName, and password. See the "_availableAttributes" block in the file for these and other attributes this org can set (identifierAtts, LCSMaterial, etc.) — add the ones you need as real top-level keys, then delete "_availableAttributes"; the connector ignores it and applies defaults at runtime for anything you omit.');
83
- }
84
- }
85
- exports.ConfigCreateCommand = ConfigCreateCommand;
@@ -1 +0,0 @@
1
- export {};
@@ -1,80 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || function (mod) {
19
- if (mod && mod.__esModule) return mod;
20
- var result = {};
21
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
- __setModuleDefault(result, mod);
23
- return result;
24
- };
25
- Object.defineProperty(exports, "__esModule", { value: true });
26
- const fs = __importStar(require("fs"));
27
- const os = __importStar(require("os"));
28
- const path = __importStar(require("path"));
29
- let answers = [];
30
- jest.mock('readline', () => ({
31
- createInterface: () => ({
32
- question: (_q, cb) => cb(answers.shift() || ''),
33
- close: () => { },
34
- }),
35
- }));
36
- const config_create_1 = require("./config-create");
37
- describe('ConfigCreateCommand', () => {
38
- let tempDir;
39
- let originalCwd;
40
- let logSpy;
41
- beforeEach(() => {
42
- originalCwd = process.cwd();
43
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flexplm-config-create-'));
44
- process.chdir(tempDir);
45
- logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
46
- answers = [];
47
- });
48
- afterEach(() => {
49
- logSpy.mockRestore();
50
- process.chdir(originalCwd);
51
- fs.rmSync(tempDir, { recursive: true, force: true });
52
- });
53
- it('writes <orgName>-flexplmConfig.json with orgName and appIdentifier filled in', async () => {
54
- answers = ['acme', '@vibeiq/flexplm-connector'];
55
- await new config_create_1.ConfigCreateCommand().run();
56
- const outPath = path.join(tempDir, 'acme-flexplmConfig.json');
57
- expect(fs.existsSync(outPath)).toBe(true);
58
- const config = JSON.parse(fs.readFileSync(outPath, 'utf8'));
59
- expect(config.orgName).toEqual('acme');
60
- expect(config.appIdentifier).toEqual('@vibeiq/flexplm-connector');
61
- });
62
- it('defaults appIdentifier to @vibeiq/flexplm-connector when left blank', async () => {
63
- answers = ['acme', ''];
64
- await new config_create_1.ConfigCreateCommand().run();
65
- const outPath = path.join(tempDir, 'acme-flexplmConfig.json');
66
- const config = JSON.parse(fs.readFileSync(outPath, 'utf8'));
67
- expect(config.appIdentifier).toEqual('@vibeiq/flexplm-connector');
68
- });
69
- it('throws when orgName is empty', async () => {
70
- answers = [' '];
71
- await expect(new config_create_1.ConfigCreateCommand().run()).rejects.toThrow(/orgName is required/);
72
- });
73
- it('refuses to overwrite an existing file', async () => {
74
- const existing = path.join(tempDir, 'acme-flexplmConfig.json');
75
- fs.writeFileSync(existing, 'do not clobber', 'utf8');
76
- answers = ['acme', '@vibeiq/flexplm-connector'];
77
- await expect(new config_create_1.ConfigCreateCommand().run()).rejects.toThrow(/Refusing to overwrite/);
78
- expect(fs.readFileSync(existing, 'utf8')).toEqual('do not clobber');
79
- });
80
- });
@@ -1,19 +0,0 @@
1
- interface ConfigUploadOptions {
2
- filePath: string;
3
- message?: string;
4
- branch?: string;
5
- skipGit: boolean;
6
- updateConfig: boolean;
7
- }
8
- export declare class ConfigUploadCommand {
9
- static parseArgs(args: string[]): ConfigUploadOptions;
10
- static buildCommitMessage(userMessage: string, fileId: string): string;
11
- private prompt;
12
- private promptHidden;
13
- private runGit;
14
- private tryRunGit;
15
- private commitToGit;
16
- run(args: string[]): Promise<void>;
17
- private setConfigFileOnAppOrg;
18
- }
19
- export {};
@@ -1,249 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || function (mod) {
19
- if (mod && mod.__esModule) return mod;
20
- var result = {};
21
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
- __setModuleDefault(result, mod);
23
- return result;
24
- };
25
- Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.ConfigUploadCommand = void 0;
27
- const child_process_1 = require("child_process");
28
- const fs = __importStar(require("fs"));
29
- const path = __importStar(require("path"));
30
- const readline = __importStar(require("readline"));
31
- const sdk_1 = require("@contrail/sdk");
32
- class ConfigUploadCommand {
33
- static parseArgs(args) {
34
- let filePath;
35
- let message;
36
- let branch;
37
- let skipGit = false;
38
- let updateConfig = false;
39
- for (let i = 0; i < args.length; i++) {
40
- const a = args[i];
41
- if (a === '-m') {
42
- message = args[++i];
43
- if (message === undefined) {
44
- throw new Error('-m requires a commit message argument');
45
- }
46
- }
47
- else if (a === '-b') {
48
- branch = args[++i];
49
- if (branch === undefined) {
50
- throw new Error('-b requires a branch name argument');
51
- }
52
- }
53
- else if (a === '--skip-git' || a === '--skipGit') {
54
- skipGit = true;
55
- }
56
- else if (a === '--update-config') {
57
- updateConfig = true;
58
- }
59
- else if (a.startsWith('-')) {
60
- throw new Error(`Unknown option: ${a}`);
61
- }
62
- else if (!filePath) {
63
- filePath = a;
64
- }
65
- else {
66
- throw new Error(`Unexpected argument: ${a}`);
67
- }
68
- }
69
- if (!filePath) {
70
- throw new Error('upload: missing <path.json> argument');
71
- }
72
- return { filePath, message, branch, skipGit, updateConfig };
73
- }
74
- static buildCommitMessage(userMessage, fileId) {
75
- const lines = userMessage.split(/\r?\n/);
76
- lines[0] = `${lines[0]} [fileId: ${fileId}]`;
77
- return lines.join('\n');
78
- }
79
- prompt(question) {
80
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
81
- return new Promise((resolve) => {
82
- rl.question(question, (answer) => {
83
- rl.close();
84
- resolve(answer.trim());
85
- });
86
- });
87
- }
88
- promptHidden(question) {
89
- return new Promise((resolve) => {
90
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
91
- const rlAny = rl;
92
- rlAny._writeToOutput = (str) => {
93
- if (str.includes(question)) {
94
- rlAny.output.write(str);
95
- }
96
- };
97
- rl.question(question, (answer) => {
98
- rl.close();
99
- process.stdout.write('\n');
100
- resolve(answer);
101
- });
102
- });
103
- }
104
- runGit(args, cwd) {
105
- return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
106
- }
107
- tryRunGit(args, cwd) {
108
- try {
109
- const stdout = this.runGit(args, cwd);
110
- return { ok: true, stdout, stderr: '' };
111
- }
112
- catch (err) {
113
- return {
114
- ok: false,
115
- stdout: err && err.stdout ? err.stdout.toString() : '',
116
- stderr: err && err.stderr ? err.stderr.toString() : (err && err.message) || '',
117
- };
118
- }
119
- }
120
- async commitToGit(absPath, fileId, options) {
121
- const repoDir = path.dirname(absPath);
122
- const relPath = path.basename(absPath);
123
- const versionCheck = this.tryRunGit(['--version'], repoDir);
124
- if (!versionCheck.ok) {
125
- console.log('git command not available; skipping git commit.');
126
- return;
127
- }
128
- const insideRepo = this.tryRunGit(['rev-parse', '--is-inside-work-tree'], repoDir);
129
- if (!insideRepo.ok || insideRepo.stdout.trim() !== 'true') {
130
- console.log(`Not inside a git working tree (${repoDir}); skipping git commit.`);
131
- return;
132
- }
133
- const tracked = this.tryRunGit(['ls-files', '--error-unmatch', relPath], repoDir);
134
- if (!tracked.ok) {
135
- const answer = (await this.prompt(`File is not tracked by git: ${relPath}\nAdd it to git? (Y/n): `)).toLowerCase();
136
- if (answer === 'n' || answer === 'no') {
137
- console.log('Nothing was done in git.');
138
- return;
139
- }
140
- }
141
- if (options.branch) {
142
- const branchResult = this.tryRunGit(['checkout', '-b', options.branch], repoDir);
143
- if (!branchResult.ok) {
144
- throw new Error(`Failed to create branch "${options.branch}": ${branchResult.stderr.trim()}`);
145
- }
146
- console.log(`Created and switched to branch "${options.branch}"`);
147
- }
148
- let message = options.message;
149
- if (!message) {
150
- message = await this.prompt('Commit message: ');
151
- if (!message) {
152
- throw new Error('A commit message is required');
153
- }
154
- }
155
- const finalMessage = ConfigUploadCommand.buildCommitMessage(message, fileId);
156
- const addResult = this.tryRunGit(['add', '--', relPath], repoDir);
157
- if (!addResult.ok) {
158
- throw new Error(`git add failed: ${addResult.stderr.trim()}`);
159
- }
160
- const commitResult = this.tryRunGit(['commit', '-m', finalMessage, '--', relPath], repoDir);
161
- if (!commitResult.ok) {
162
- throw new Error(`git commit failed: ${commitResult.stderr.trim() || commitResult.stdout.trim()}`);
163
- }
164
- console.log(commitResult.stdout.trim());
165
- }
166
- async run(args) {
167
- const options = ConfigUploadCommand.parseArgs(args);
168
- const absPath = path.resolve(process.cwd(), options.filePath);
169
- if (!fs.existsSync(absPath)) {
170
- throw new Error(`File not found: ${absPath}`);
171
- }
172
- if (!absPath.endsWith('.json')) {
173
- throw new Error(`Expected a .json file, got: ${absPath}`);
174
- }
175
- const raw = fs.readFileSync(absPath, 'utf8');
176
- let config;
177
- try {
178
- config = JSON.parse(raw);
179
- }
180
- catch (err) {
181
- throw new Error(`File is not valid JSON: ${absPath}\n${err && err.message ? err.message : err}`);
182
- }
183
- const orgName = config && config.orgName;
184
- const appIdentifier = config && config.appIdentifier;
185
- if (!orgName) {
186
- throw new Error(`Config file is missing "orgName": ${absPath}`);
187
- }
188
- if (!appIdentifier) {
189
- throw new Error(`Config file is missing "appIdentifier": ${absPath}`);
190
- }
191
- let email = process.env.CONTRAIL_CLI_EMAIL;
192
- let password = process.env.CONTRAIL_CLI_PASSWORD;
193
- if (!email) {
194
- email = await this.prompt('Email: ');
195
- }
196
- if (!password) {
197
- password = await this.promptHidden('Password: ');
198
- }
199
- if (!email || !password) {
200
- throw new Error('Email and password are required');
201
- }
202
- await (0, sdk_1.login)({ orgSlug: orgName, email, password });
203
- console.log(`Logged in to org "${orgName}" as ${email}`);
204
- const apps = await new sdk_1.Entities().get({
205
- entityName: 'app',
206
- criteria: { identifier: appIdentifier },
207
- });
208
- if (!apps || apps.length !== 1) {
209
- throw new Error(`Expected exactly one app with identifier "${appIdentifier}" in org "${orgName}", found ${apps ? apps.length : 0}`);
210
- }
211
- const app = apps[0];
212
- const buffer = fs.readFileSync(absPath);
213
- const fileName = path.basename(absPath);
214
- const fileOwner = `app:${app.id}`;
215
- const uploadedFile = await new sdk_1.Files().createAndUploadFileFromBuffer(buffer, 'application/json', fileName, fileOwner);
216
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
217
- const responsePath = `${absPath}.uploaded-${timestamp}.json`;
218
- fs.writeFileSync(responsePath, JSON.stringify(uploadedFile, null, 2), 'utf8');
219
- console.log(`Wrote response to ${responsePath}`);
220
- console.log(`FILE ID: ${uploadedFile.id}`);
221
- if (!options.skipGit) {
222
- await this.commitToGit(absPath, uploadedFile.id, options);
223
- }
224
- if (options.updateConfig) {
225
- await this.setConfigFileOnAppOrg(app.id, appIdentifier, orgName, uploadedFile.id);
226
- }
227
- }
228
- async setConfigFileOnAppOrg(appId, appIdentifier, orgName, fileId) {
229
- const appOrgs = await new sdk_1.Entities().get({
230
- entityName: 'app-org',
231
- criteria: { appId },
232
- });
233
- if (!appOrgs || appOrgs.length === 0) {
234
- throw new Error(`Failed to set the file onto the app config for "${appIdentifier}" because it is not installed in org "${orgName}". Install it via the admin console before using --update-config. You can paste the uploaded file's ID into the app config without needing to re-run this command.`);
235
- }
236
- if (appOrgs.length > 1) {
237
- throw new Error(`Failed to set the file onto the app config for "${appIdentifier}" in org "${orgName}" because ${appOrgs.length} installations were identified. Expected one. Please contact customer support for assistance.`);
238
- }
239
- const appOrg = appOrgs[0];
240
- const nextAppConfig = { ...(appOrg.appConfig || {}), configFile: fileId };
241
- await new sdk_1.Entities().update({
242
- entityName: 'app-org',
243
- id: appOrg.id,
244
- object: { appConfig: nextAppConfig },
245
- });
246
- console.log(`Successfully set "appConfig.configFile" for installed "${appIdentifier}" to new FILE ID: "${fileId}" on org "${orgName}"`);
247
- }
248
- }
249
- exports.ConfigUploadCommand = ConfigUploadCommand;
@@ -1 +0,0 @@
1
- export {};
@@ -1,94 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- jest.mock('@contrail/sdk', () => ({
4
- Entities: jest.fn(),
5
- Files: jest.fn(),
6
- login: jest.fn(),
7
- }));
8
- const config_upload_1 = require("./config-upload");
9
- describe('ConfigUploadCommand.parseArgs', () => {
10
- it('parses a bare file path', () => {
11
- const opts = config_upload_1.ConfigUploadCommand.parseArgs(['config.json']);
12
- expect(opts).toEqual({
13
- filePath: 'config.json',
14
- message: undefined,
15
- branch: undefined,
16
- skipGit: false,
17
- updateConfig: false,
18
- });
19
- });
20
- it('parses -m commit message option', () => {
21
- const opts = config_upload_1.ConfigUploadCommand.parseArgs(['config.json', '-m', 'my message']);
22
- expect(opts.message).toEqual('my message');
23
- expect(opts.skipGit).toBe(false);
24
- });
25
- it('parses -b branch option', () => {
26
- const opts = config_upload_1.ConfigUploadCommand.parseArgs(['config.json', '-b', 'feature/x']);
27
- expect(opts.branch).toEqual('feature/x');
28
- });
29
- it('parses --skip-git flag', () => {
30
- const opts = config_upload_1.ConfigUploadCommand.parseArgs(['config.json', '--skip-git']);
31
- expect(opts.skipGit).toBe(true);
32
- });
33
- it('accepts the legacy --skipGit alias', () => {
34
- const opts = config_upload_1.ConfigUploadCommand.parseArgs(['config.json', '--skipGit']);
35
- expect(opts.skipGit).toBe(true);
36
- });
37
- it('parses --update-config flag', () => {
38
- const opts = config_upload_1.ConfigUploadCommand.parseArgs(['config.json', '--update-config']);
39
- expect(opts.updateConfig).toBe(true);
40
- });
41
- it('throws when -m is missing its value', () => {
42
- expect(() => config_upload_1.ConfigUploadCommand.parseArgs(['config.json', '-m'])).toThrow(/-m requires a commit message/);
43
- });
44
- it('throws on unknown option', () => {
45
- expect(() => config_upload_1.ConfigUploadCommand.parseArgs(['config.json', '--bogus'])).toThrow(/Unknown option: --bogus/);
46
- });
47
- it('throws when no file path is provided', () => {
48
- expect(() => config_upload_1.ConfigUploadCommand.parseArgs([])).toThrow(/missing <path\.json>/);
49
- });
50
- });
51
- describe('ConfigUploadCommand.buildCommitMessage', () => {
52
- it('appends fileId to the first line of a single-line message', () => {
53
- expect(config_upload_1.ConfigUploadCommand.buildCommitMessage('initial commit', 'abc123')).toEqual('initial commit [fileId: abc123]');
54
- });
55
- it('handles CRLF line endings', () => {
56
- const result = config_upload_1.ConfigUploadCommand.buildCommitMessage('header\r\nbody', 'fid');
57
- expect(result).toEqual('header [fileId: fid]\nbody');
58
- });
59
- });
60
- describe('ConfigUploadCommand.run', () => {
61
- const fs = require('fs');
62
- const os = require('os');
63
- const path = require('path');
64
- let tempDir;
65
- beforeEach(() => {
66
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flexplm-config-upload-'));
67
- });
68
- afterEach(() => {
69
- fs.rmSync(tempDir, { recursive: true, force: true });
70
- });
71
- it('throws when the config file is missing orgName', async () => {
72
- const filePath = path.join(tempDir, 'bad.json');
73
- fs.writeFileSync(filePath, JSON.stringify({ appIdentifier: '@vibeiq/flexplm-connector' }), 'utf8');
74
- await expect(new config_upload_1.ConfigUploadCommand().run([filePath, '--skip-git'])).rejects.toThrow(/missing "orgName"/);
75
- });
76
- it('throws when the config file is missing appIdentifier', async () => {
77
- const filePath = path.join(tempDir, 'bad.json');
78
- fs.writeFileSync(filePath, JSON.stringify({ orgName: 'acme' }), 'utf8');
79
- await expect(new config_upload_1.ConfigUploadCommand().run([filePath, '--skip-git'])).rejects.toThrow(/missing "appIdentifier"/);
80
- });
81
- it('throws when the file is not valid JSON', async () => {
82
- const filePath = path.join(tempDir, 'bad.json');
83
- fs.writeFileSync(filePath, '{not json', 'utf8');
84
- await expect(new config_upload_1.ConfigUploadCommand().run([filePath, '--skip-git'])).rejects.toThrow(/not valid JSON/);
85
- });
86
- it('throws when the file does not exist', async () => {
87
- await expect(new config_upload_1.ConfigUploadCommand().run([path.join(tempDir, 'nope.json'), '--skip-git'])).rejects.toThrow(/File not found/);
88
- });
89
- it('throws when the file is not a .json file', async () => {
90
- const filePath = path.join(tempDir, 'config.txt');
91
- fs.writeFileSync(filePath, '{}', 'utf8');
92
- await expect(new config_upload_1.ConfigUploadCommand().run([filePath, '--skip-git'])).rejects.toThrow(/Expected a \.json file/);
93
- });
94
- });
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
- export declare class ConfigCli {
3
- main(): Promise<void>;
4
- }
5
- export declare function main(): Promise<void>;
@@ -1,61 +0,0 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.main = exports.ConfigCli = void 0;
5
- const config_create_1 = require("./commands/config-create");
6
- const config_upload_1 = require("./commands/config-upload");
7
- const USAGE = `Usage: flexplm-config <command> [args]
8
-
9
- Commands:
10
- create Scaffold a new connector config .json file in the current directory
11
- upload <path.json> [opts] Upload a connector config .json file to VibeIQ
12
-
13
- Upload options:
14
- -m <message> Git commit message (prompted if omitted)
15
- -b <branch> Create a new git branch before committing
16
- --skip-git Skip the post-upload git commit (default: commit)
17
- --update-config Patch the app-org appConfig.configFile with the uploaded file ID without needing to paste into the admin console
18
-
19
- Environment (upload):
20
- CONTRAIL_CLI_EMAIL VibeIQ user email
21
- CONTRAIL_CLI_PASSWORD VibeIQ user password
22
- `;
23
- class ConfigCli {
24
- async main() {
25
- const [, , command, ...rest] = process.argv;
26
- switch (command) {
27
- case 'create':
28
- await new config_create_1.ConfigCreateCommand().run();
29
- return;
30
- case 'upload':
31
- if (!rest[0]) {
32
- console.error('upload: missing <path.json> argument');
33
- console.error(USAGE);
34
- process.exit(1);
35
- }
36
- await new config_upload_1.ConfigUploadCommand().run(rest);
37
- return;
38
- case undefined:
39
- case '-h':
40
- case '--help':
41
- case 'help':
42
- console.log(USAGE);
43
- return;
44
- default:
45
- console.error(`Unknown command: ${command}`);
46
- console.error(USAGE);
47
- process.exit(1);
48
- }
49
- }
50
- }
51
- exports.ConfigCli = ConfigCli;
52
- function main() {
53
- return new ConfigCli().main();
54
- }
55
- exports.main = main;
56
- if (require.main === module) {
57
- main().catch((err) => {
58
- console.error(err && err.message ? err.message : err);
59
- process.exit(1);
60
- });
61
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,68 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- const createRunMock = jest.fn().mockResolvedValue(undefined);
4
- const uploadRunMock = jest.fn().mockResolvedValue(undefined);
5
- jest.mock('./commands/config-create', () => ({
6
- ConfigCreateCommand: jest.fn().mockImplementation(() => ({ run: createRunMock })),
7
- }));
8
- jest.mock('./commands/config-upload', () => ({
9
- ConfigUploadCommand: jest.fn().mockImplementation(() => ({ run: uploadRunMock })),
10
- }));
11
- const config_index_1 = require("./config-index");
12
- describe('config cli main dispatcher', () => {
13
- let originalArgv;
14
- let logSpy;
15
- let errorSpy;
16
- let exitSpy;
17
- beforeEach(() => {
18
- originalArgv = process.argv;
19
- createRunMock.mockClear();
20
- uploadRunMock.mockClear();
21
- logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
22
- errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
23
- exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code) => {
24
- throw new Error(`__EXIT__:${code}`);
25
- }));
26
- });
27
- afterEach(() => {
28
- process.argv = originalArgv;
29
- logSpy.mockRestore();
30
- errorSpy.mockRestore();
31
- exitSpy.mockRestore();
32
- });
33
- function setArgv(...args) {
34
- process.argv = ['node', 'cli', ...args];
35
- }
36
- it('dispatches the create command', async () => {
37
- setArgv('create');
38
- await (0, config_index_1.main)();
39
- expect(createRunMock).toHaveBeenCalledTimes(1);
40
- expect(uploadRunMock).not.toHaveBeenCalled();
41
- });
42
- it('dispatches the upload command and forwards remaining args', async () => {
43
- setArgv('upload', 'config.json', '-m', 'msg', '--skip-git');
44
- await (0, config_index_1.main)();
45
- expect(uploadRunMock).toHaveBeenCalledWith(['config.json', '-m', 'msg', '--skip-git']);
46
- });
47
- it('exits when upload is missing its argument', async () => {
48
- setArgv('upload');
49
- await expect((0, config_index_1.main)()).rejects.toThrow('__EXIT__:1');
50
- expect(uploadRunMock).not.toHaveBeenCalled();
51
- expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/missing <path\.json>/));
52
- });
53
- it.each(['help', '-h', '--help'])('prints usage for %s', async (helpFlag) => {
54
- setArgv(helpFlag);
55
- await (0, config_index_1.main)();
56
- expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Usage: flexplm-config/));
57
- });
58
- it('prints usage when no command is provided', async () => {
59
- setArgv();
60
- await (0, config_index_1.main)();
61
- expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Usage: flexplm-config/));
62
- });
63
- it('exits on an unknown command', async () => {
64
- setArgv('bogus');
65
- await expect((0, config_index_1.main)()).rejects.toThrow('__EXIT__:1');
66
- expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Unknown command: bogus/));
67
- });
68
- });
@@ -1,30 +0,0 @@
1
- {
2
- "orgName": "<ORG_NAME>",
3
- "appIdentifier": "<APP_IDENTIFIER>",
4
-
5
- "_availableAttributes": {
6
- "_note": "Reference only — the connector never reads this key. Add any of the attributes below as real top-level keys if this org needs them, then delete this whole \"_availableAttributes\" block.",
7
- "apiHost": "FlexPLM API host base URL. Required — throws NEED_CONFIG_VALUES if missing",
8
- "userName": "FlexPLM user name used for Basic Auth. Required; rewritten to a () => string getter",
9
- "password": "FlexPLM password used for Basic Auth. Required; rewritten to a () => string getter",
10
- "urlContext": "Path prefix for FlexPLM URLs. Default: '/Windchill'",
11
- "itemPreDevelopmentLifecycleStages": "Item lifecycle stages during which items are not synced to FlexPLM. Default: ['concept']",
12
- "logLevel": "Possible values: error/warn/info/debug. Default: info",
13
- "identifierAtts": "Deprecated. Map of FlexPLM object class to identifier attribute name(s), e.g. { \"LCSProduct\": [\"itemNumber\"] }",
14
- "plmEnviornment": "Sent as the PLM_ENV header on every FlexPLM request. Only used by 1 customer. NOTE: this key is intentionally spelled \"plmEnviornment\" (matching the connector's actual config key) — do not \"fix\" the spelling to \"plmEnvironment\", it will silently stop working",
15
- "propertyMapping": "Deprecated. Reserved for custom property-mapping overrides",
16
- "complexConfig": "Object of additional attributes (see below) merged onto the top level of the config, then deleted; __proto__/constructor/prototype keys are stripped for safety. e.g. { \"complexConfig\": { \"csrfEndpoint\": \"...\" } }",
17
- "csrfEndpoint (in complexConfig)": "CSRF token endpoint path. Default: '/servlet/rest/security/csrf'",
18
- "dataConverter.useDisplayForEnumerationMatching (in complexConfig)": "Default: false",
19
- "dataConverter.verboseDebug (in complexConfig)": "Default: false",
20
- "flexplmConnect.staticHeaders (in complexConfig)": "Extra static headers added to every FlexPLM request",
21
- "LCSMaterial.processAsItem (in complexConfig)": "true routes LCSMaterial to item:material instead of custom-entity. Default: false",
22
- "max_thumbnail_size (in complexConfig)": "Max thumbnail size in bytes. Default: 5 MB",
23
- "payloadDefaultAsArray (in complexConfig)": "Whether outbound payload values default to arrays. Default: true",
24
- "search.<entityType>.useIdentityServiceForInboundData (in complexConfig)": "entityType is one of: item, color, custom-entity, project-item. Default: false",
25
- "sendMode.ASYNC_PUBLISH_SEASON (in complexConfig)": "Default: 'vibeiqfile'",
26
- "syncOptionSets (in complexConfig)": "Array of { flexInternalName, vibeSlug }",
27
- "useDistinctRestEndPointForImages (in complexConfig)": "Default: false; when true routes image fetch through /rfa/vibeiq/image instead of the raw URL",
28
- "vibeEventEndpoint (in complexConfig)": "Endpoint VibeIQ posts inbound events to. Default: '/rfa/vibeiq/vibeEvents'"
29
- }
30
- }