Skip to content

Commit f8c3010

Browse files
authored
Merge pull request #50 from contentstack/feat/DX-5416
DX - 5416 - feat: added support for the management token
2 parents 30d4ed5 + 138d260 commit f8c3010

File tree

7 files changed

+142
-51
lines changed

7 files changed

+142
-51
lines changed

packages/contentstack-export/src/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ const config: DefaultConfig = {
201201
stack: {
202202
dirName: 'stack',
203203
fileName: 'stack.json',
204+
invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'],
204205
},
205206
dependency: {
206207
entries: ['stack', 'locales', 'content-types'],

packages/contentstack-export/src/export/modules/stack.ts

Lines changed: 109 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import find from 'lodash/find';
2+
import omit from 'lodash/omit';
23
import { resolve as pResolve } from 'node:path';
34
import {
45
handleAndLogError,
@@ -43,38 +44,35 @@ export default class ExportStack extends BaseClass {
4344
try {
4445
log.debug('Starting stack export process...', this.exportConfig.context);
4546

46-
// Initial analysis with loading spinner
47+
// Initial analysis with loading spinner (skip getStack when using management token — no SDK snapshot)
4748
const [stackData] = await this.withLoadingSpinner('STACK: Analyzing stack configuration...', async () => {
48-
const stackData = isAuthenticated() ? await this.getStack() : null;
49+
const stackData =
50+
this.exportConfig.management_token || !isAuthenticated() ? null : await this.getStack();
4951
return [stackData];
5052
});
5153

5254
// Create nested progress manager
5355
const progress = this.createNestedProgress(this.currentModuleName);
5456

55-
// Add processes based on configuration
56-
let processCount = 0;
57-
58-
if (stackData?.org_uid) {
59-
log.debug(`Found organization UID: '${stackData.org_uid}'.`, this.exportConfig.context);
60-
this.exportConfig.org_uid = stackData.org_uid;
57+
const orgUid = stackData?.org_uid ?? stackData?.organization_uid;
58+
if (orgUid) {
59+
log.debug(`Found organization UID: '${orgUid}'.`, this.exportConfig.context);
60+
this.exportConfig.org_uid = orgUid;
6161
this.exportConfig.sourceStackName = stackData.name;
6262
log.debug(`Set source stack name: ${stackData.name}`, this.exportConfig.context);
6363
}
6464

6565
if (!this.exportConfig.management_token) {
6666
progress.addProcess(PROCESS_NAMES.STACK_SETTINGS, 1);
67-
processCount++;
6867
}
68+
progress.addProcess(PROCESS_NAMES.STACK_DETAILS, 1);
6969

7070
if (!this.exportConfig.preserveStackVersion && !this.exportConfig.hasOwnProperty('master_locale')) {
7171
progress.addProcess(PROCESS_NAMES.STACK_LOCALE, 1);
72-
processCount++;
73-
} else if (this.exportConfig.preserveStackVersion) {
74-
progress.addProcess(PROCESS_NAMES.STACK_DETAILS, 1);
75-
processCount++;
7672
}
7773

74+
let stackDetailsExportResult: any;
75+
7876
// Execute processes
7977
if (!this.exportConfig.management_token) {
8078
progress
@@ -85,11 +83,28 @@ export default class ExportStack extends BaseClass {
8583
);
8684
await this.exportStackSettings();
8785
progress.completeProcess(PROCESS_NAMES.STACK_SETTINGS, true);
86+
87+
progress
88+
.startProcess(PROCESS_NAMES.STACK_DETAILS)
89+
.updateStatus(
90+
PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].EXPORTING,
91+
PROCESS_NAMES.STACK_DETAILS,
92+
);
93+
stackDetailsExportResult = await this.exportStack(stackData);
94+
progress.completeProcess(PROCESS_NAMES.STACK_DETAILS, true);
8895
} else {
8996
log.info(
9097
'Skipping stack settings export: Operation is not supported when using a management token.',
9198
this.exportConfig.context,
9299
);
100+
progress
101+
.startProcess(PROCESS_NAMES.STACK_DETAILS)
102+
.updateStatus(
103+
PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].EXPORTING,
104+
PROCESS_NAMES.STACK_DETAILS,
105+
);
106+
stackDetailsExportResult = await this.writeStackJsonFromConfigApiKeyOnly();
107+
progress.completeProcess(PROCESS_NAMES.STACK_DETAILS, true);
93108
}
94109

95110
if (!this.exportConfig.preserveStackVersion && !this.exportConfig.hasOwnProperty('master_locale')) {
@@ -110,17 +125,8 @@ export default class ExportStack extends BaseClass {
110125
this.completeProgress(true);
111126
return masterLocale;
112127
} else if (this.exportConfig.preserveStackVersion) {
113-
progress
114-
.startProcess(PROCESS_NAMES.STACK_DETAILS)
115-
.updateStatus(
116-
PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].EXPORTING,
117-
PROCESS_NAMES.STACK_DETAILS,
118-
);
119-
const stackResult = await this.exportStack();
120-
progress.completeProcess(PROCESS_NAMES.STACK_DETAILS, true);
121-
122128
this.completeProgress(true);
123-
return stackResult;
129+
return stackDetailsExportResult;
124130
} else {
125131
log.debug('Locale locale already set, skipping locale fetch', this.exportConfig.context);
126132
}
@@ -225,33 +231,36 @@ export default class ExportStack extends BaseClass {
225231
});
226232
}
227233

228-
async exportStack(): Promise<any> {
234+
/**
235+
* Reuse stack snapshot from `getStack()` when present so we do not call `stack.fetch()` twice
236+
* (same GET /stacks payload as writing stack.json). Falls back to `this.stack.fetch()` otherwise.
237+
*/
238+
async exportStack(preloadedStack?: Record<string, any> | null): Promise<any> {
229239
log.debug(`Starting stack export for: '${this.exportConfig.apiKey}'...`, this.exportConfig.context);
230240

231241
await fsUtil.makeDirectory(this.stackFolderPath);
232242
log.debug(`Created stack directory at: '${this.stackFolderPath}'`, this.exportConfig.context);
233243

234-
return this.stack
235-
.fetch()
236-
.then((resp: any) => {
237-
const stackFilePath = pResolve(this.stackFolderPath, this.stackConfig.fileName);
238-
log.debug(`Writing stack data to: '${stackFilePath}'`, this.exportConfig.context);
239-
fsUtil.writeFile(stackFilePath, resp);
240-
241-
// Track progress for stack export completion
244+
if (this.isStackFetchPayload(preloadedStack)) {
245+
log.debug('Reusing stack payload from analysis step (no extra stack.fetch).', this.exportConfig.context);
246+
try {
247+
return this.persistStackJsonPayload(preloadedStack);
248+
} catch (error: any) {
242249
this.progressManager?.tick(
243-
true,
244-
`stack: ${this.exportConfig.apiKey}`,
245-
null,
250+
false,
251+
'stack export',
252+
error?.message || PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].FAILED,
246253
PROCESS_NAMES.STACK_DETAILS,
247254
);
255+
handleAndLogError(error, { ...this.exportConfig.context });
256+
return undefined;
257+
}
258+
}
248259

249-
log.success(
250-
`Stack details exported successfully for stack ${this.exportConfig.apiKey}`,
251-
this.exportConfig.context,
252-
);
253-
log.debug('Stack export completed successfully.', this.exportConfig.context);
254-
return resp;
260+
return this.stack
261+
.fetch()
262+
.then((resp: any) => {
263+
return this.persistStackJsonPayload(resp);
255264
})
256265
.catch((error: any) => {
257266
log.debug(`Error occurred while exporting stack: ${this.exportConfig.apiKey}`, this.exportConfig.context);
@@ -265,6 +274,65 @@ export default class ExportStack extends BaseClass {
265274
});
266275
}
267276

277+
private isStackFetchPayload(data: unknown): data is Record<string, any> {
278+
return (
279+
typeof data === 'object' &&
280+
data !== null &&
281+
!Array.isArray(data) &&
282+
('api_key' in data || 'uid' in data)
283+
);
284+
}
285+
286+
/**
287+
* Management-token exports cannot use Stack CMA endpoints for full metadata; write api_key from config only.
288+
*/
289+
private async writeStackJsonFromConfigApiKeyOnly(): Promise<{ api_key: string }> {
290+
if (!this.exportConfig.apiKey || typeof this.exportConfig.apiKey !== 'string') {
291+
throw new Error('Stack API key is required to write stack.json when using a management token.');
292+
}
293+
294+
log.debug('Writing config-based stack.json (api_key only, no stack fetch).', this.exportConfig.context);
295+
296+
await fsUtil.makeDirectory(this.stackFolderPath);
297+
const payload = { api_key: this.exportConfig.apiKey };
298+
const stackFilePath = pResolve(this.stackFolderPath, this.stackConfig.fileName);
299+
fsUtil.writeFile(stackFilePath, payload);
300+
301+
this.progressManager?.tick(
302+
true,
303+
`stack: ${this.exportConfig.apiKey}`,
304+
null,
305+
PROCESS_NAMES.STACK_DETAILS,
306+
);
307+
308+
log.success(
309+
`Stack identifier written to stack.json from config for stack ${this.exportConfig.apiKey}`,
310+
this.exportConfig.context,
311+
);
312+
return payload;
313+
}
314+
315+
private persistStackJsonPayload(resp: Record<string, any>): any {
316+
const sanitized = omit(resp, this.stackConfig.invalidKeys ?? []);
317+
const stackFilePath = pResolve(this.stackFolderPath, this.stackConfig.fileName);
318+
log.debug(`Writing stack data to: '${stackFilePath}'`, this.exportConfig.context);
319+
fsUtil.writeFile(stackFilePath, sanitized);
320+
321+
this.progressManager?.tick(
322+
true,
323+
`stack: ${this.exportConfig.apiKey}`,
324+
null,
325+
PROCESS_NAMES.STACK_DETAILS,
326+
);
327+
328+
log.success(
329+
`Stack details exported successfully for stack ${this.exportConfig.apiKey}`,
330+
this.exportConfig.context,
331+
);
332+
log.debug('Stack export completed successfully.', this.exportConfig.context);
333+
return sanitized;
334+
}
335+
268336
async exportStackSettings(): Promise<any> {
269337
log.info('Exporting stack settings...', this.exportConfig.context);
270338
await fsUtil.makeDirectory(this.stackFolderPath);

packages/contentstack-export/src/types/default-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export default interface DefaultConfig {
147147
stack: {
148148
dirName: string;
149149
fileName: string;
150+
invalidKeys: string[];
150151
dependencies?: Modules[];
151152
};
152153
dependency: {

packages/contentstack-export/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export interface CustomRoleConfig {
127127
export interface StackConfig {
128128
dirName: string;
129129
fileName: string;
130+
invalidKeys: string[];
130131
dependencies?: Modules[];
131132
limit?: number;
132133
}

packages/contentstack-export/test/unit/export/modules/assets.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ describe('ExportAssets', () => {
174174
stack: {
175175
dirName: 'stack',
176176
fileName: 'stack.json',
177+
invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'],
177178
},
178179
dependency: {
179180
entries: [],

packages/contentstack-export/test/unit/export/modules/base-class.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ describe('BaseClass', () => {
192192
stack: {
193193
dirName: 'stack',
194194
fileName: 'stack.json',
195+
invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'],
195196
},
196197
dependency: {
197198
entries: [],

packages/contentstack-export/test/unit/export/modules/stack.test.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ describe('ExportStack', () => {
187187
stack: {
188188
dirName: 'stack',
189189
fileName: 'stack.json',
190+
invalidKeys: ['SYS_ACL', 'user_uids', 'owner_uid'],
190191
limit: 100,
191192
},
192193
dependency: {
@@ -424,22 +425,28 @@ describe('ExportStack', () => {
424425
});
425426

426427
describe('exportStack() method', () => {
427-
it('should export stack successfully and write to file', async () => {
428+
it('should export stack successfully and write to file omitting invalidKeys', async () => {
428429
const writeFileStub = FsUtility.prototype.writeFile as sinon.SinonStub;
429430
const makeDirectoryStub = FsUtility.prototype.makeDirectory as sinon.SinonStub;
430-
const stackData = { name: 'Test Stack', uid: 'stack-uid', org_uid: 'org-123' };
431+
const stackData = {
432+
name: 'Test Stack',
433+
uid: 'stack-uid',
434+
org_uid: 'org-123',
435+
SYS_ACL: {},
436+
user_uids: ['u1'],
437+
owner_uid: 'owner-1',
438+
};
439+
const expectedWritten = { name: 'Test Stack', uid: 'stack-uid', org_uid: 'org-123' };
431440
mockStackClient.fetch = sinon.stub().resolves(stackData);
432441

433442
const result = await exportStack.exportStack();
434443

435444
expect(writeFileStub.called).to.be.true;
436445
expect(makeDirectoryStub.called).to.be.true;
437-
// Should return the stack data
438-
expect(result).to.deep.equal(stackData);
439-
// Verify file was written with correct path
446+
expect(result).to.deep.equal(expectedWritten);
440447
const writeCall = writeFileStub.getCall(0);
441448
expect(writeCall.args[0]).to.include('stack.json');
442-
expect(writeCall.args[1]).to.deep.equal(stackData);
449+
expect(writeCall.args[1]).to.deep.equal(expectedWritten);
443450
});
444451

445452
it('should handle errors when exporting stack without throwing', async () => {
@@ -544,9 +551,11 @@ describe('ExportStack', () => {
544551
getStackStub.restore();
545552
});
546553

547-
it('should skip exportStackSettings when management_token is present', async () => {
548-
const getStackStub = sinon.stub(exportStack, 'getStack').resolves({});
554+
it('should write stack.json from config api_key only when management_token is present', async () => {
555+
const writeFileStub = FsUtility.prototype.writeFile as sinon.SinonStub;
556+
const getStackSpy = sinon.spy(exportStack, 'getStack');
549557
const exportStackSettingsSpy = sinon.spy(exportStack, 'exportStackSettings');
558+
const exportStackSpy = sinon.spy(exportStack, 'exportStack');
550559

551560
exportStack.exportConfig.management_token = 'some-token';
552561
exportStack.exportConfig.preserveStackVersion = false;
@@ -555,11 +564,20 @@ describe('ExportStack', () => {
555564

556565
await exportStack.start();
557566

558-
// Verify exportStackSettings was NOT called
567+
expect(getStackSpy.called).to.be.false;
559568
expect(exportStackSettingsSpy.called).to.be.false;
569+
expect(exportStackSpy.called).to.be.false;
560570

561-
getStackStub.restore();
571+
const stackJsonWrite = writeFileStub.getCalls().find((c) => String(c.args[0]).includes('stack.json'));
572+
expect(stackJsonWrite).to.exist;
573+
expect(stackJsonWrite!.args[1]).to.deep.equal({ api_key: 'test-api-key' });
574+
575+
const settingsWrite = writeFileStub.getCalls().find((c) => String(c.args[0]).includes('settings.json'));
576+
expect(settingsWrite).to.be.undefined;
577+
578+
getStackSpy.restore();
562579
exportStackSettingsSpy.restore();
580+
exportStackSpy.restore();
563581
});
564582
});
565583
});

0 commit comments

Comments
 (0)