diff --git a/packages/contentstack-export/src/config/index.ts b/packages/contentstack-export/src/config/index.ts index 14fe590b..23de9a5a 100644 --- a/packages/contentstack-export/src/config/index.ts +++ b/packages/contentstack-export/src/config/index.ts @@ -201,6 +201,7 @@ const config: DefaultConfig = { stack: { dirName: 'stack', fileName: 'stack.json', + invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'], }, dependency: { entries: ['stack', 'locales', 'content-types'], diff --git a/packages/contentstack-export/src/export/modules/stack.ts b/packages/contentstack-export/src/export/modules/stack.ts index 5007235d..9e12aa84 100644 --- a/packages/contentstack-export/src/export/modules/stack.ts +++ b/packages/contentstack-export/src/export/modules/stack.ts @@ -1,4 +1,5 @@ import find from 'lodash/find'; +import omit from 'lodash/omit'; import { resolve as pResolve } from 'node:path'; import { handleAndLogError, @@ -43,38 +44,35 @@ export default class ExportStack extends BaseClass { try { log.debug('Starting stack export process...', this.exportConfig.context); - // Initial analysis with loading spinner + // Initial analysis with loading spinner (skip getStack when using management token — no SDK snapshot) const [stackData] = await this.withLoadingSpinner('STACK: Analyzing stack configuration...', async () => { - const stackData = isAuthenticated() ? await this.getStack() : null; + const stackData = + this.exportConfig.management_token || !isAuthenticated() ? null : await this.getStack(); return [stackData]; }); // Create nested progress manager const progress = this.createNestedProgress(this.currentModuleName); - // Add processes based on configuration - let processCount = 0; - - if (stackData?.org_uid) { - log.debug(`Found organization UID: '${stackData.org_uid}'.`, this.exportConfig.context); - this.exportConfig.org_uid = stackData.org_uid; + const orgUid = stackData?.org_uid ?? stackData?.organization_uid; + if (orgUid) { + log.debug(`Found organization UID: '${orgUid}'.`, this.exportConfig.context); + this.exportConfig.org_uid = orgUid; this.exportConfig.sourceStackName = stackData.name; log.debug(`Set source stack name: ${stackData.name}`, this.exportConfig.context); } if (!this.exportConfig.management_token) { progress.addProcess(PROCESS_NAMES.STACK_SETTINGS, 1); - processCount++; } + progress.addProcess(PROCESS_NAMES.STACK_DETAILS, 1); if (!this.exportConfig.preserveStackVersion && !this.exportConfig.hasOwnProperty('master_locale')) { progress.addProcess(PROCESS_NAMES.STACK_LOCALE, 1); - processCount++; - } else if (this.exportConfig.preserveStackVersion) { - progress.addProcess(PROCESS_NAMES.STACK_DETAILS, 1); - processCount++; } + let stackDetailsExportResult: any; + // Execute processes if (!this.exportConfig.management_token) { progress @@ -85,11 +83,28 @@ export default class ExportStack extends BaseClass { ); await this.exportStackSettings(); progress.completeProcess(PROCESS_NAMES.STACK_SETTINGS, true); + + progress + .startProcess(PROCESS_NAMES.STACK_DETAILS) + .updateStatus( + PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].EXPORTING, + PROCESS_NAMES.STACK_DETAILS, + ); + stackDetailsExportResult = await this.exportStack(stackData); + progress.completeProcess(PROCESS_NAMES.STACK_DETAILS, true); } else { log.info( 'Skipping stack settings export: Operation is not supported when using a management token.', this.exportConfig.context, ); + progress + .startProcess(PROCESS_NAMES.STACK_DETAILS) + .updateStatus( + PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].EXPORTING, + PROCESS_NAMES.STACK_DETAILS, + ); + stackDetailsExportResult = await this.writeStackJsonFromConfigApiKeyOnly(); + progress.completeProcess(PROCESS_NAMES.STACK_DETAILS, true); } if (!this.exportConfig.preserveStackVersion && !this.exportConfig.hasOwnProperty('master_locale')) { @@ -110,17 +125,8 @@ export default class ExportStack extends BaseClass { this.completeProgress(true); return masterLocale; } else if (this.exportConfig.preserveStackVersion) { - progress - .startProcess(PROCESS_NAMES.STACK_DETAILS) - .updateStatus( - PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].EXPORTING, - PROCESS_NAMES.STACK_DETAILS, - ); - const stackResult = await this.exportStack(); - progress.completeProcess(PROCESS_NAMES.STACK_DETAILS, true); - this.completeProgress(true); - return stackResult; + return stackDetailsExportResult; } else { log.debug('Locale locale already set, skipping locale fetch', this.exportConfig.context); } @@ -225,33 +231,36 @@ export default class ExportStack extends BaseClass { }); } - async exportStack(): Promise { + /** + * Reuse stack snapshot from `getStack()` when present so we do not call `stack.fetch()` twice + * (same GET /stacks payload as writing stack.json). Falls back to `this.stack.fetch()` otherwise. + */ + async exportStack(preloadedStack?: Record | null): Promise { log.debug(`Starting stack export for: '${this.exportConfig.apiKey}'...`, this.exportConfig.context); await fsUtil.makeDirectory(this.stackFolderPath); log.debug(`Created stack directory at: '${this.stackFolderPath}'`, this.exportConfig.context); - return this.stack - .fetch() - .then((resp: any) => { - const stackFilePath = pResolve(this.stackFolderPath, this.stackConfig.fileName); - log.debug(`Writing stack data to: '${stackFilePath}'`, this.exportConfig.context); - fsUtil.writeFile(stackFilePath, resp); - - // Track progress for stack export completion + if (this.isStackFetchPayload(preloadedStack)) { + log.debug('Reusing stack payload from analysis step (no extra stack.fetch).', this.exportConfig.context); + try { + return this.persistStackJsonPayload(preloadedStack); + } catch (error: any) { this.progressManager?.tick( - true, - `stack: ${this.exportConfig.apiKey}`, - null, + false, + 'stack export', + error?.message || PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].FAILED, PROCESS_NAMES.STACK_DETAILS, ); + handleAndLogError(error, { ...this.exportConfig.context }); + return undefined; + } + } - log.success( - `Stack details exported successfully for stack ${this.exportConfig.apiKey}`, - this.exportConfig.context, - ); - log.debug('Stack export completed successfully.', this.exportConfig.context); - return resp; + return this.stack + .fetch() + .then((resp: any) => { + return this.persistStackJsonPayload(resp); }) .catch((error: any) => { log.debug(`Error occurred while exporting stack: ${this.exportConfig.apiKey}`, this.exportConfig.context); @@ -265,6 +274,65 @@ export default class ExportStack extends BaseClass { }); } + private isStackFetchPayload(data: unknown): data is Record { + return ( + typeof data === 'object' && + data !== null && + !Array.isArray(data) && + ('api_key' in data || 'uid' in data) + ); + } + + /** + * Management-token exports cannot use Stack CMA endpoints for full metadata; write api_key from config only. + */ + private async writeStackJsonFromConfigApiKeyOnly(): Promise<{ api_key: string }> { + if (!this.exportConfig.apiKey || typeof this.exportConfig.apiKey !== 'string') { + throw new Error('Stack API key is required to write stack.json when using a management token.'); + } + + log.debug('Writing config-based stack.json (api_key only, no stack fetch).', this.exportConfig.context); + + await fsUtil.makeDirectory(this.stackFolderPath); + const payload = { api_key: this.exportConfig.apiKey }; + const stackFilePath = pResolve(this.stackFolderPath, this.stackConfig.fileName); + fsUtil.writeFile(stackFilePath, payload); + + this.progressManager?.tick( + true, + `stack: ${this.exportConfig.apiKey}`, + null, + PROCESS_NAMES.STACK_DETAILS, + ); + + log.success( + `Stack identifier written to stack.json from config for stack ${this.exportConfig.apiKey}`, + this.exportConfig.context, + ); + return payload; + } + + private persistStackJsonPayload(resp: Record): any { + const sanitized = omit(resp, this.stackConfig.invalidKeys ?? []); + const stackFilePath = pResolve(this.stackFolderPath, this.stackConfig.fileName); + log.debug(`Writing stack data to: '${stackFilePath}'`, this.exportConfig.context); + fsUtil.writeFile(stackFilePath, sanitized); + + this.progressManager?.tick( + true, + `stack: ${this.exportConfig.apiKey}`, + null, + PROCESS_NAMES.STACK_DETAILS, + ); + + log.success( + `Stack details exported successfully for stack ${this.exportConfig.apiKey}`, + this.exportConfig.context, + ); + log.debug('Stack export completed successfully.', this.exportConfig.context); + return sanitized; + } + async exportStackSettings(): Promise { log.info('Exporting stack settings...', this.exportConfig.context); await fsUtil.makeDirectory(this.stackFolderPath); diff --git a/packages/contentstack-export/src/types/default-config.ts b/packages/contentstack-export/src/types/default-config.ts index b0823992..ca833b28 100644 --- a/packages/contentstack-export/src/types/default-config.ts +++ b/packages/contentstack-export/src/types/default-config.ts @@ -147,6 +147,7 @@ export default interface DefaultConfig { stack: { dirName: string; fileName: string; + invalidKeys: string[]; dependencies?: Modules[]; }; dependency: { diff --git a/packages/contentstack-export/src/types/index.ts b/packages/contentstack-export/src/types/index.ts index 63baf41e..7db80019 100644 --- a/packages/contentstack-export/src/types/index.ts +++ b/packages/contentstack-export/src/types/index.ts @@ -127,6 +127,7 @@ export interface CustomRoleConfig { export interface StackConfig { dirName: string; fileName: string; + invalidKeys: string[]; dependencies?: Modules[]; limit?: number; } diff --git a/packages/contentstack-export/test/unit/export/modules/assets.test.ts b/packages/contentstack-export/test/unit/export/modules/assets.test.ts index 346d6875..37d0f671 100644 --- a/packages/contentstack-export/test/unit/export/modules/assets.test.ts +++ b/packages/contentstack-export/test/unit/export/modules/assets.test.ts @@ -174,6 +174,7 @@ describe('ExportAssets', () => { stack: { dirName: 'stack', fileName: 'stack.json', + invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'], }, dependency: { entries: [], diff --git a/packages/contentstack-export/test/unit/export/modules/base-class.test.ts b/packages/contentstack-export/test/unit/export/modules/base-class.test.ts index 53ece4d1..baf9d73b 100644 --- a/packages/contentstack-export/test/unit/export/modules/base-class.test.ts +++ b/packages/contentstack-export/test/unit/export/modules/base-class.test.ts @@ -192,6 +192,7 @@ describe('BaseClass', () => { stack: { dirName: 'stack', fileName: 'stack.json', + invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'], }, dependency: { entries: [], diff --git a/packages/contentstack-export/test/unit/export/modules/stack.test.ts b/packages/contentstack-export/test/unit/export/modules/stack.test.ts index 52645c02..69b86cf1 100644 --- a/packages/contentstack-export/test/unit/export/modules/stack.test.ts +++ b/packages/contentstack-export/test/unit/export/modules/stack.test.ts @@ -187,6 +187,7 @@ describe('ExportStack', () => { stack: { dirName: 'stack', fileName: 'stack.json', + invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'], limit: 100, }, dependency: { @@ -424,22 +425,28 @@ describe('ExportStack', () => { }); describe('exportStack() method', () => { - it('should export stack successfully and write to file', async () => { + it('should export stack successfully and write to file omitting invalidKeys', async () => { const writeFileStub = FsUtility.prototype.writeFile as sinon.SinonStub; const makeDirectoryStub = FsUtility.prototype.makeDirectory as sinon.SinonStub; - const stackData = { name: 'Test Stack', uid: 'stack-uid', org_uid: 'org-123' }; + const stackData = { + name: 'Test Stack', + uid: 'stack-uid', + org_uid: 'org-123', + SYS_ACL: {}, + user_uids: ['u1'], + owner_uid: 'owner-1', + }; + const expectedWritten = { name: 'Test Stack', uid: 'stack-uid', org_uid: 'org-123' }; mockStackClient.fetch = sinon.stub().resolves(stackData); const result = await exportStack.exportStack(); expect(writeFileStub.called).to.be.true; expect(makeDirectoryStub.called).to.be.true; - // Should return the stack data - expect(result).to.deep.equal(stackData); - // Verify file was written with correct path + expect(result).to.deep.equal(expectedWritten); const writeCall = writeFileStub.getCall(0); expect(writeCall.args[0]).to.include('stack.json'); - expect(writeCall.args[1]).to.deep.equal(stackData); + expect(writeCall.args[1]).to.deep.equal(expectedWritten); }); it('should handle errors when exporting stack without throwing', async () => { @@ -544,9 +551,11 @@ describe('ExportStack', () => { getStackStub.restore(); }); - it('should skip exportStackSettings when management_token is present', async () => { - const getStackStub = sinon.stub(exportStack, 'getStack').resolves({}); + it('should write stack.json from config api_key only when management_token is present', async () => { + const writeFileStub = FsUtility.prototype.writeFile as sinon.SinonStub; + const getStackSpy = sinon.spy(exportStack, 'getStack'); const exportStackSettingsSpy = sinon.spy(exportStack, 'exportStackSettings'); + const exportStackSpy = sinon.spy(exportStack, 'exportStack'); exportStack.exportConfig.management_token = 'some-token'; exportStack.exportConfig.preserveStackVersion = false; @@ -555,11 +564,20 @@ describe('ExportStack', () => { await exportStack.start(); - // Verify exportStackSettings was NOT called + expect(getStackSpy.called).to.be.false; expect(exportStackSettingsSpy.called).to.be.false; + expect(exportStackSpy.called).to.be.false; - getStackStub.restore(); + const stackJsonWrite = writeFileStub.getCalls().find((c) => String(c.args[0]).includes('stack.json')); + expect(stackJsonWrite).to.exist; + expect(stackJsonWrite!.args[1]).to.deep.equal({ api_key: 'test-api-key' }); + + const settingsWrite = writeFileStub.getCalls().find((c) => String(c.args[0]).includes('settings.json')); + expect(settingsWrite).to.be.undefined; + + getStackSpy.restore(); exportStackSettingsSpy.restore(); + exportStackSpy.restore(); }); }); });