Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/contentstack-export/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
150 changes: 109 additions & 41 deletions packages/contentstack-export/src/export/modules/stack.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import find from 'lodash/find';
import omit from 'lodash/omit';
import { resolve as pResolve } from 'node:path';
import {
handleAndLogError,
Expand Down Expand Up @@ -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
Expand All @@ -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')) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -225,33 +231,36 @@ export default class ExportStack extends BaseClass {
});
}

async exportStack(): Promise<any> {
/**
* 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<string, any> | null): Promise<any> {
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);
Expand All @@ -265,6 +274,65 @@ export default class ExportStack extends BaseClass {
});
}

private isStackFetchPayload(data: unknown): data is Record<string, any> {
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<string, any>): 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<any> {
log.info('Exporting stack settings...', this.exportConfig.context);
await fsUtil.makeDirectory(this.stackFolderPath);
Expand Down
1 change: 1 addition & 0 deletions packages/contentstack-export/src/types/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export default interface DefaultConfig {
stack: {
dirName: string;
fileName: string;
invalidKeys: string[];
dependencies?: Modules[];
};
dependency: {
Expand Down
1 change: 1 addition & 0 deletions packages/contentstack-export/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export interface CustomRoleConfig {
export interface StackConfig {
dirName: string;
fileName: string;
invalidKeys: string[];
dependencies?: Modules[];
limit?: number;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ describe('ExportAssets', () => {
stack: {
dirName: 'stack',
fileName: 'stack.json',
invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'],
},
dependency: {
entries: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ describe('BaseClass', () => {
stack: {
dirName: 'stack',
fileName: 'stack.json',
invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'],
},
dependency: {
entries: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ describe('ExportStack', () => {
stack: {
dirName: 'stack',
fileName: 'stack.json',
invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'],
limit: 100,
},
dependency: {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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;
Expand All @@ -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();
});
});
});
Loading