Skip to content

Commit 73ac460

Browse files
authored
Merge branch 'v2-dev' into feat/migrate-external-cli-plugins-v2
2 parents 15f7a2e + b376f24 commit 73ac460

16 files changed

Lines changed: 538 additions & 154 deletions

File tree

packages/contentstack-export-to-csv/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@contentstack/cli-cm-export-to-csv",
33
"description": "Export entries, taxonomies, terms, or organization users to CSV",
4-
"version": "2.0.0-beta.7",
4+
"version": "2.0.0-beta.8",
55
"author": "Contentstack",
66
"bugs": "https://github.com/contentstack/cli/issues",
77
"dependencies": {

packages/contentstack-export-to-csv/src/utils/api-client.ts

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -138,22 +138,16 @@ export function getOrgUsers(managementAPIClient: ManagementClient, orgUid: strin
138138
return reject(new Error('Org UID not found.'));
139139
}
140140

141-
if (organization.is_owner === true) {
142-
return managementAPIClient
143-
.organization(organization.uid)
144-
.getInvitations()
145-
.then((data: unknown) => {
146-
resolve(data as OrgUsersResponse);
147-
})
148-
.catch(reject);
149-
}
150-
151-
if (!organization.getInvitations && !find(organization.org_roles, 'admin')) {
141+
if (!organization.is_owner && !find(organization.org_roles, 'admin')) {
152142
return reject(new Error(messages.ERROR_ADMIN_ACCESS_DENIED));
153143
}
154144

155145
try {
156-
const users = await getUsers(managementAPIClient, { uid: organization.uid }, { skip: 0, page: 1, limit: 100 });
146+
const users = await getUsers(
147+
managementAPIClient,
148+
{ uid: organization.uid },
149+
{ skip: 0, page: 1, limit: config.limit },
150+
);
157151
return resolve({ items: users || [] });
158152
} catch (error) {
159153
return reject(error);
@@ -175,15 +169,18 @@ async function getUsers(
175169
try {
176170
const users = await managementAPIClient.organization(organization.uid).getInvitations(params) as unknown as OrgUsersResponse;
177171

178-
if (!users.items || (users.items && !users.items.length)) {
172+
if (!users.items || users.items.length < params.limit) {
173+
if (users.items?.length) {
174+
result = result.concat(users.items);
175+
}
179176
return result;
180-
} else {
181-
result = result.concat(users.items);
182-
params.skip = params.page * params.limit;
183-
params.page++;
184-
await wait(200);
185-
return getUsers(managementAPIClient, organization, params, result);
186177
}
178+
179+
result = result.concat(users.items);
180+
params.skip = params.page * params.limit;
181+
params.page++;
182+
await wait(200);
183+
return getUsers(managementAPIClient, organization, params, result);
187184
} catch {
188185
return result;
189186
}

packages/contentstack-export-to-csv/test/unit/utils/api-client.functional.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ describe('api-client functional', () => {
131131
}),
132132
} as any;
133133
const res = await getOrgUsers(client, 'org-1');
134-
expect(res).to.equal(invitations);
134+
expect(res.items).to.have.lengthOf(1);
135+
expect(res.items[0].email).to.equal('a@b.com');
135136
});
136137

137138
it('getOrgUsers rejects when org uid missing', async () => {
@@ -148,8 +149,8 @@ describe('api-client functional', () => {
148149

149150
it('getOrgUsers resolves paginated invitations for non-owner admin org', async () => {
150151
const getInvitations = sandbox.stub();
151-
getInvitations.onFirstCall().resolves({ items: [{ email: 'a@b.com' }] });
152-
getInvitations.onSecondCall().resolves({ items: [] });
152+
getInvitations.onFirstCall().resolves({ items: Array.from({ length: 100 }, (_, i) => ({ email: `u${i}@b.com` })) });
153+
getInvitations.onSecondCall().resolves({ items: [{ email: 'a@b.com' }] });
153154
const client = {
154155
getUser: () =>
155156
Promise.resolve({
@@ -160,7 +161,7 @@ describe('api-client functional', () => {
160161
}),
161162
} as any;
162163
const res = await getOrgUsers(client, 'org-1');
163-
expect(res.items).to.have.lengthOf(1);
164+
expect(res.items).to.have.lengthOf(101);
164165
expect(getInvitations.calledTwice).to.equal(true);
165166
});
166167

packages/contentstack-export-to-csv/test/unit/utils/api-client.test.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { expect } from 'chai';
2+
import sinon from 'sinon';
3+
import config from '../../../src/config';
4+
import { messages } from '../../../src/messages';
5+
import * as errorHandler from '../../../src/utils/error-handler';
26
import {
37
getOrganizations,
48
getOrganizationsWhereUserIsAdmin,
@@ -19,8 +23,66 @@ import {
1923
getTaxonomy,
2024
createImportableCSV,
2125
} from '../../../src/utils/api-client';
26+
import type { ManagementClient, OrgUser } from '../../../src/types';
27+
28+
const ORG_UID = 'org-uid';
29+
30+
function makeUser(index: number): OrgUser {
31+
return {
32+
email: `user${index}@example.com`,
33+
user_uid: `uid-${index}`,
34+
invited_by: 'system',
35+
status: 'accepted',
36+
created_at: '2020-01-01T00:00:00.000Z',
37+
updated_at: '2020-01-01T00:00:00.000Z',
38+
};
39+
}
40+
41+
function createPaginatedMockClient(
42+
organization: {
43+
is_owner?: boolean;
44+
org_roles?: Array<{ admin?: boolean }>;
45+
},
46+
pages: OrgUser[][],
47+
): { client: ManagementClient; getInvitations: sinon.SinonStub; invitationParams: Array<Record<string, number>> } {
48+
const invitationParams: Array<Record<string, number>> = [];
49+
const getInvitations = sinon.stub().callsFake(async (params: Record<string, number>) => {
50+
invitationParams.push({ ...params });
51+
const callIndex = getInvitations.callCount - 1;
52+
const items = pages[callIndex] ?? [];
53+
return { items };
54+
});
55+
56+
const organizationClient = { getInvitations };
57+
const organizationStub = sinon.stub().returns(organizationClient);
58+
59+
const client = {
60+
getUser: sinon.stub().resolves({
61+
organizations: [
62+
{
63+
uid: ORG_UID,
64+
name: 'Test Org',
65+
...organization,
66+
},
67+
],
68+
}),
69+
organization: organizationStub,
70+
} as unknown as ManagementClient;
71+
72+
return { client, getInvitations, invitationParams };
73+
}
2274

2375
describe('api-client', () => {
76+
let waitStub: sinon.SinonStub;
77+
78+
beforeEach(() => {
79+
waitStub = sinon.stub(errorHandler, 'wait').resolves();
80+
});
81+
82+
afterEach(() => {
83+
waitStub.restore();
84+
});
85+
2486
describe('module exports', () => {
2587
it('should export all expected functions', () => {
2688
expect(getOrganizations).to.be.a('function');
@@ -44,5 +106,62 @@ describe('api-client', () => {
44106
});
45107
});
46108

47-
// Note: Functional tests use mocked SDK chains; keep in a dedicated file when re-adding coverage.
109+
describe('getOrgUsers', () => {
110+
it('should paginate getInvitations for organization owners', async () => {
111+
const page1 = Array.from({ length: config.limit }, (_, i) => makeUser(i));
112+
const page2 = Array.from({ length: config.limit }, (_, i) => makeUser(i + config.limit));
113+
const page3 = Array.from({ length: 25 }, (_, i) => makeUser(i + config.limit * 2));
114+
115+
const { client, getInvitations, invitationParams } = createPaginatedMockClient(
116+
{ is_owner: true },
117+
[page1, page2, page3],
118+
);
119+
120+
const result = await getOrgUsers(client, ORG_UID);
121+
122+
expect(result.items).to.have.lengthOf(225);
123+
expect(getInvitations.callCount).to.equal(3);
124+
expect(invitationParams[0]).to.deep.equal({ skip: 0, page: 1, limit: config.limit });
125+
expect(invitationParams[1]).to.deep.equal({ skip: config.limit, page: 2, limit: config.limit });
126+
expect(invitationParams[2]).to.deep.equal({
127+
skip: config.limit * 2,
128+
page: 3,
129+
limit: config.limit,
130+
});
131+
});
132+
133+
it('should paginate getInvitations for organization admins', async () => {
134+
const page1 = Array.from({ length: config.limit }, (_, i) => makeUser(i));
135+
const page2 = Array.from({ length: config.limit }, (_, i) => makeUser(i + config.limit));
136+
const page3 = Array.from({ length: 25 }, (_, i) => makeUser(i + config.limit * 2));
137+
138+
const { client, getInvitations, invitationParams } = createPaginatedMockClient(
139+
{ is_owner: false, org_roles: [{ admin: true }] },
140+
[page1, page2, page3],
141+
);
142+
143+
const result = await getOrgUsers(client, ORG_UID);
144+
145+
expect(result.items).to.have.lengthOf(225);
146+
expect(getInvitations.callCount).to.equal(3);
147+
expect(invitationParams[0]).to.deep.equal({ skip: 0, page: 1, limit: config.limit });
148+
});
149+
150+
it('should reject when user is neither owner nor admin', async () => {
151+
const { client, getInvitations } = createPaginatedMockClient(
152+
{ is_owner: false, org_roles: [{ admin: false }] },
153+
[[]],
154+
);
155+
156+
try {
157+
await getOrgUsers(client, ORG_UID);
158+
expect.fail('Expected getOrgUsers to reject');
159+
} catch (error) {
160+
expect(error).to.be.instanceOf(Error);
161+
expect((error as Error).message).to.equal(messages.ERROR_ADMIN_ACCESS_DENIED);
162+
}
163+
164+
expect(getInvitations.called).to.equal(false);
165+
});
166+
});
48167
});

packages/contentstack-seed/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
## Description
22
The “seed” command in Contentstack CLI allows users to import content to your stack, from Github repositories. It's an effective command that can help you to migrate content to your stack with minimal steps.
33

4-
To import content to your stack, you can choose from the following two sources:
4+
To import content to your stack, you can use either of the following:
55

6-
**Contentstack’s organization**: In this organization, we have provided sample content, which you can import directly to your stack using the seed command.
6+
**Curated official stacks**: When you run the seed command without a full `owner/repo` on `--repo`, the CLI offers a fixed list of official Contentstack seed repositories on GitHub (no GitHub search API).
77

8-
**Github’s repository**: You can import content available on Github’s repository belonging to an organization or an individual.
8+
**Any GitHub repository**: You can also import content from another GitHub repository by passing `--repo` in `owner/repository` form (organization, user, or enterprise account).
99

1010
<!-- usagestop -->
1111
## Commands

packages/contentstack-seed/src/seed/github/client.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import GithubError from './error';
99

1010
export default class GitHubClient {
1111
readonly gitHubRepoUrl: string;
12-
readonly gitHubUserUrl: string;
1312
private readonly httpClient: HttpClient;
1413

1514
static parsePath(path?: string) {
@@ -30,21 +29,11 @@ export default class GitHubClient {
3029
return result;
3130
}
3231

33-
constructor(public username: string, defaultStackPattern: string) {
32+
constructor(public username: string) {
3433
this.gitHubRepoUrl = `https://api.github.com/repos/${username}`;
35-
this.gitHubUserUrl = `https://api.github.com/search/repositories?q=org%3A${username}+in:name+${defaultStackPattern}`;
3634
this.httpClient = HttpClient.create();
3735
}
3836

39-
async getAllRepos(count = 100) {
40-
try {
41-
const response = await this.httpClient.get(`${this.gitHubUserUrl}&per_page=${count}`);
42-
return response.data.items;
43-
} catch (error) {
44-
throw this.buildError(error);
45-
}
46-
}
47-
4837
async getLatest(repo: string, destination: string): Promise<void> {
4938
const tarballUrl = await this.getLatestTarballUrl(repo);
5039
const releaseStream = await this.streamRelease(tarballUrl);

packages/contentstack-seed/src/seed/index.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import { cliux } from '@contentstack/cli-utilities';
44
import * as importer from '../seed/importer';
55
import ContentstackClient, { Organization, Stack } from '../seed/contentstack/client';
66
import {
7+
inquireOfficialSeedStack,
78
inquireOrganization,
89
inquireProceed,
9-
inquireRepo,
1010
inquireStack,
1111
InquireStackResponse,
1212
} from '../seed/interactive';
1313
import GitHubClient from './github/client';
1414
import GithubError from './github/error';
15+
import { OFFICIAL_SEED_STACKS } from './seed-stacks';
1516

1617
const DEFAULT_OWNER = 'contentstack';
17-
const DEFAULT_STACK_PATTERN = 'stack-';
1818

1919
export const ENGLISH_LOCALE = 'en-us';
2020

@@ -38,7 +38,7 @@ export default class ContentModelSeeder {
3838
private readonly parent: any = null;
3939
private readonly csClient: ContentstackClient;
4040

41-
private readonly ghClient: GitHubClient;
41+
private ghClient: GitHubClient;
4242

4343
private readonly _options: ContentModelSeederOptions;
4444

@@ -61,7 +61,7 @@ export default class ContentModelSeeder {
6161
this.managementToken = options.managementToken;
6262

6363
this.csClient = new ContentstackClient(options.cmaHost, limit);
64-
this.ghClient = new GitHubClient(this.ghUsername, DEFAULT_STACK_PATTERN);
64+
this.ghClient = new GitHubClient(this.ghUsername);
6565
}
6666

6767
async run() {
@@ -233,15 +233,9 @@ export default class ContentModelSeeder {
233233
}
234234

235235
async inquireGitHubRepo() {
236-
try {
237-
const allRepos = await this.ghClient.getAllRepos();
238-
const stackRepos = allRepos.filter((repo: any) => repo.name.startsWith(DEFAULT_STACK_PATTERN));
239-
const repoResponse = await inquireRepo(stackRepos);
240-
this.ghRepo = repoResponse.choice;
241-
} catch (error) {
242-
cliux.error(
243-
`Unable to find any Stack repositories within the '${this.ghUsername}' GitHub account. Please re-run this command with a GitHub repository in the 'account/repo' format. You can also re-run the command without arguments to pull from the official Stack list.`,
244-
);
245-
}
236+
const selected = await inquireOfficialSeedStack(OFFICIAL_SEED_STACKS);
237+
this.ghUsername = selected.owner;
238+
this.ghRepo = selected.repo;
239+
this.ghClient = new GitHubClient(this.ghUsername);
246240
}
247241
}

packages/contentstack-seed/src/seed/interactive.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,48 @@
11
import inquirer from 'inquirer';
2+
import { cliux } from '@contentstack/cli-utilities';
3+
4+
import { OfficialSeedStack } from './seed-stacks';
25
import { Organization, Stack } from './contentstack/client';
36

7+
export const OFFICIAL_SEED_STACK_SELECTION_MESSAGE = 'Select a stack to import';
8+
49
export interface InquireStackResponse {
510
isNew: boolean;
611
name: string | null;
712
uid: string | null;
813
api_key: string | null;
914
}
1015

16+
export async function inquireOfficialSeedStack(
17+
stacks: OfficialSeedStack[],
18+
): Promise<{ owner: string; repo: string }> {
19+
if (!stacks || stacks.length === 0) {
20+
throw new Error('Precondition failed: No official seed stacks configured.');
21+
}
22+
23+
const choices = stacks.map((s) => ({
24+
name: s.displayName,
25+
value: s,
26+
}));
27+
28+
const response = await inquirer.prompt([
29+
{
30+
type: 'list',
31+
name: 'stack',
32+
message: OFFICIAL_SEED_STACK_SELECTION_MESSAGE,
33+
choices: [...choices, 'Exit'],
34+
},
35+
]);
36+
37+
if (response.stack === 'Exit') {
38+
cliux.print('Exiting...');
39+
throw new Error('Exit');
40+
}
41+
42+
const selected = response.stack as OfficialSeedStack;
43+
return { owner: selected.owner, repo: selected.repo };
44+
}
45+
1146
export async function inquireRepo(repos: any[]): Promise<{ choice: string }> {
1247
if (!repos || repos.length === 0) throw new Error('Precondition failed: No Repositories found.');
1348

0 commit comments

Comments
 (0)