Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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/cli/src/commands/create/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface CreateOptions {
template?: string;
blueprint?: string; // Deprecated, use template instead
skipSpace?: boolean;
key?: string; // Access token for Storyblok
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(blocking):
@dipankarmaikap what do you think about naming it token, or accessToken? I know it used to be called key, but I believe those 2 are more descriptive and here we have a chance to improve the DX ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going with token

}

export const templates = {
Expand Down
46 changes: 45 additions & 1 deletion packages/cli/src/commands/create/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,37 @@ const createMockUser = (overrides: Partial<StoryblokUser> = {}): StoryblokUser =
name: 'Test Organization',
},
has_partner: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
});

describe('createCommand', () => {
describe('--key option', () => {
it('should use provided key, skip space creation, and update env', async () => {
vi.mocked(generateProject).mockResolvedValue(undefined);
vi.mocked(createEnvFile).mockResolvedValue(undefined);
vi.mocked(fetchBlueprintRepositories).mockResolvedValue([
{ name: 'React', value: 'react', template: '', location: 'https://localhost:5173/', description: '', updated_at: '' },
{ name: 'Vue', value: 'vue', template: '', location: 'https://localhost:5173/', description: '', updated_at: '' },
]);

await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react', '--key', 'my-access-token']);

// Should generate project
expect(generateProject).toHaveBeenCalledWith('react', 'my-project', expect.any(String));
// Should create .env file with provided key
expect(createEnvFile).toHaveBeenCalledWith(expect.any(String), 'my-access-token');
// Should NOT create space or open browser
expect(createSpace).not.toHaveBeenCalled();
expect(openSpaceInBrowser).not.toHaveBeenCalled();
// Should show success message
expect(konsola.ok).toHaveBeenCalledWith(
expect.stringContaining('Your react project is ready 🎉 !'),
);
expect(konsola.info).toHaveBeenCalledWith(expect.stringContaining('Next steps:'));
});
});
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
Expand Down Expand Up @@ -672,6 +699,23 @@ describe('createCommand', () => {
expect(createEnvFile).not.toHaveBeenCalled();
expect(openSpaceInBrowser).not.toHaveBeenCalled();
});

it('should NOT prompt for space creation when --skip-space is provided', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: nice extra test!

vi.mocked(select).mockResolvedValue('vue');
vi.mocked(input).mockResolvedValue('./my-vue-project');
vi.mocked(generateProject).mockResolvedValue(undefined);
vi.mocked(fetchBlueprintRepositories).mockResolvedValue([
{ name: 'React', value: 'react', template: '', location: 'https://localhost:5173/', description: '', updated_at: '' },
{ name: 'Vue', value: 'vue', template: '', location: 'https://localhost:5173/', description: '', updated_at: '' },
]);

await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react', '--skip-space']);

// Should NOT prompt for space creation location
expect(select).not.toHaveBeenCalledWith(expect.objectContaining({
message: 'Where would you like to create this space?',
}));
});
});

describe('space creation choices and location', () => {
Expand Down Expand Up @@ -997,7 +1041,7 @@ describe('createCommand', () => {
await createCommand.parseAsync(['node', 'test', 'my-project', '--blueprint', 'react']);

expect(konsola.error).toHaveBeenCalledWith('Failed to fetch user info. Please login again.', userError);
expect(generateProject).not.toHaveBeenCalled();
expect(generateProject).toHaveBeenCalled();
expect(createSpace).not.toHaveBeenCalled();
});

Expand Down
204 changes: 112 additions & 92 deletions packages/cli/src/commands/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@ import { mapiClient } from '../../api';
import type { User } from '../user/actions';
import { getUser } from '../user/actions';

// Helper to show next steps and project ready message
function showNextSteps(technologyTemplate: string, finalProjectPath: string) {
konsola.br();
konsola.ok(`Your ${chalk.hex(colorPalette.PRIMARY)(technologyTemplate)} project is ready 🎉 !`);
konsola.br();
konsola.info(`Next steps:\n cd ${finalProjectPath}\n npm install\n npm run dev\n `);
konsola.info(`Or check the dedicated guide at: ${chalk.hex(colorPalette.PRIMARY)(`https://www.storyblok.com/docs/guides/${technologyTemplate}`)}`);
}

// Helper to create .env file and handle errors
async function handleEnvFileCreation(resolvedPath: string, token: string) {
try {
await createEnvFile(resolvedPath, token);
konsola.ok(`Created .env file with Storyblok access token`, true);
return true;
}
catch (error) {
konsola.warn(`Failed to create .env file: ${(error as Error).message}`);
konsola.info(`You can manually add this token to your .env file: ${token}`);
return false;
}
}

const program = getProgram(); // Get the shared singleton instance

// Create root command
Expand All @@ -23,12 +46,13 @@ export const createCommand = program
.option('-t, --template <template>', 'technology starter template')
.option('-b, --blueprint <blueprint>', '[DEPRECATED] use --template instead')
.option('--skip-space', 'skip space creation')
.option('--key <key>', 'Storyblok access token (skip space creation and use this token)')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above (naming comment)

.action(async (projectPath: string, options: CreateOptions) => {
konsola.title(`${commands.CREATE}`, colorPalette.CREATE);
// Global options
const verbose = program.opts().verbose;
// Command options - handle backward compatibility
const { template, blueprint } = options;
const { template, blueprint, key } = options;

// Handle deprecated blueprint option
let selectedTemplate = template;
Expand Down Expand Up @@ -64,21 +88,6 @@ export const createCommand = program
verbose: !isVitest,
});

let userData: User;

try {
const user = await getUser(password, region);
if (!user) {
throw new Error('User data is undefined');
}
userData = user;
}
catch (error) {
konsola.error('Failed to fetch user info. Please login again.', error);
konsola.br();
return;
}

try {
spinnerBlueprints.start('Fetching starter templates...');
const templates = await fetchBlueprintRepositories();
Expand Down Expand Up @@ -148,107 +157,118 @@ export const createCommand = program
await generateProject(technologyTemplate!, projectName, targetDirectory);
konsola.ok(`Project ${chalk.hex(colorPalette.PRIMARY)(projectName)} created successfully in ${chalk.hex(colorPalette.PRIMARY)(finalProjectPath)}`, true);

// If key is provided, use it as the access token, skip space creation, and update env
let createdSpace;
const choices = [
{ name: 'My personal account', value: 'personal' },
];
if (userData.has_org) {
choices.push({ name: `Organization (${userData?.org?.name})`, value: 'org' });
}
if (userData.has_partner) {
choices.push({ name: 'Partner Portal', value: 'partner' });
}
let userData: User;
let whereToCreateSpace = 'personal';
if (region === 'eu' && (userData.has_partner || userData.has_org)) {
whereToCreateSpace = await select({
message: `Where would you like to create this space?`,
choices,
});
}
if (region !== 'eu' && userData.has_org) {
whereToCreateSpace = 'org';
if (key) {
await handleEnvFileCreation(resolvedPath, key);
showNextSteps(technologyTemplate!, finalProjectPath);
return;
}
if (region !== 'eu' && !userData.has_org) {
konsola.warn(`Space creation in this region is limited to Enterprise accounts. If you're part of an organization, please ensure you have the required permissions. For more information about Enterprise access, contact our Sales Team.`);
konsola.br();
if (options.skipSpace) {
showNextSteps(technologyTemplate!, finalProjectPath);
return;
}

if (!options.skipSpace) {
try {
try {
spinnerSpace.start(`Creating space "${toHumanReadable(projectName)}"`);

// Find the selected blueprint from the dynamic blueprints array
const selectedBlueprint = templates.find(bp => bp.value === technologyTemplate);
const blueprintDomain = selectedBlueprint?.location || 'https://localhost:3000/';
const spaceToCreate: SpaceCreate = {
name: toHumanReadable(projectName),
domain: blueprintDomain,
};
if (whereToCreateSpace === 'org') {
spaceToCreate.org = userData.org;
spaceToCreate.in_org = true;
const user = await getUser(password, region);
if (!user) {
throw new Error('User data is undefined');
}
else if (whereToCreateSpace === 'partner') {
spaceToCreate.assign_partner = true;
}
createdSpace = await createSpace(spaceToCreate);
spinnerSpace.succeed(`Space "${chalk.hex(colorPalette.PRIMARY)(toHumanReadable(projectName))}" created successfully`);
userData = user;
}
catch (error) {
spinnerSpace.failed();
konsola.error('Failed to fetch user info. Please login again.', error);
konsola.br();
handleError(error as Error, verbose);
return;
}
}

// Create .env file with the Storyblok token
if (createdSpace?.first_token) {
try {
await createEnvFile(resolvedPath, createdSpace.first_token);
konsola.ok(`Created .env file with Storyblok access token`, true);
// Prepare choices for space creation
const choices = [
{ name: 'My personal account', value: 'personal' },
];
if (userData.has_org) {
choices.push({ name: `Organization (${userData?.org?.name})`, value: 'org' });
}
catch (error) {
konsola.warn(`Failed to create .env file: ${(error as Error).message}`);
konsola.info(`You can manually add this token to your .env file: ${createdSpace.first_token}`);
if (userData.has_partner) {
choices.push({ name: 'Partner Portal', value: 'partner' });
}
}

// Open the space in the browser
if (createdSpace?.id) {
try {
await openSpaceInBrowser(createdSpace.id, region);
konsola.info(`Opened space in your browser`);
if (region === 'eu' && (userData.has_partner || userData.has_org)) {
whereToCreateSpace = await select({
message: `Where would you like to create this space?`,
choices,
});
}
catch (error) {
konsola.warn(`Failed to open browser: ${(error as Error).message}`);
const spaceUrl = generateSpaceUrl(createdSpace.id, region);
konsola.info(`You can manually open your space at: ${chalk.hex(colorPalette.PRIMARY)(spaceUrl)}`);
if (region !== 'eu' && userData.has_org) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion:

@dipankarmaikap shall we, instead of using the eu literal directly, reuse regions.EU constant for the constants.ts file?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: what is it about eu that makes it special? Is it the default region? Is it the only region allowed for non-enterprise customers? A combination of multiple things?

suggestion: depending on the answer, we might add a helper like isDefaultRegion or isEnterpriseRegion or whatever.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maoberlehner In this case it’s only used three times, and I think it’s fine as is. It makes the code easier to read than creating an abstraction. But if this ends up being used in more places in the CLI, we could create a helper and reuse it. What do you think?

whereToCreateSpace = 'org';
}
if (region !== 'eu' && !userData.has_org) {
konsola.warn(`Space creation in this region is limited to Enterprise accounts. If you're part of an organization, please ensure you have the required permissions. For more information about Enterprise access, contact our Sales Team.`);
konsola.br();
return;
}
}

// Show next steps
konsola.br();
konsola.ok(`Your ${chalk.hex(colorPalette.PRIMARY)(technologyTemplate)} project is ready 🎉 !`);
if (createdSpace?.first_token) {
spinnerSpace.start(`Creating space "${toHumanReadable(projectName)}"`);

// Find the selected blueprint from the dynamic blueprints array
const selectedBlueprint = templates.find(bp => bp.value === technologyTemplate);
const blueprintDomain = selectedBlueprint?.location || 'https://localhost:3000/';
const spaceToCreate: SpaceCreate = {
name: toHumanReadable(projectName),
domain: blueprintDomain,
};
if (whereToCreateSpace === 'org') {
konsola.ok(`Storyblok space created in organization ${chalk.hex(colorPalette.PRIMARY)(userData?.org?.name)}, preview url and .env configured automatically. You can now open your space in the browser at ${chalk.hex(colorPalette.PRIMARY)(generateSpaceUrl(createdSpace.id, region))}`);
spaceToCreate.org = userData.org;
spaceToCreate.in_org = true;
}
else if (whereToCreateSpace === 'partner') {
konsola.ok(`Storyblok space created in partner portal, preview url and .env configured automatically. You can now open your space in the browser at ${chalk.hex(colorPalette.PRIMARY)(generateSpaceUrl(createdSpace.id, region))}`);
spaceToCreate.assign_partner = true;
}
createdSpace = await createSpace(spaceToCreate);
spinnerSpace.succeed(`Space "${chalk.hex(colorPalette.PRIMARY)(toHumanReadable(projectName))}" created successfully`);

// Create .env file with the Storyblok token
if (createdSpace?.first_token) {
await handleEnvFileCreation(resolvedPath, createdSpace.first_token);
}
else {
konsola.ok(`Storyblok space created, preview url and .env configured automatically. You can now open your space in the browser at ${chalk.hex(colorPalette.PRIMARY)(generateSpaceUrl(createdSpace.id, region))}`);

// Open the space in the browser
if (createdSpace?.id) {
try {
await openSpaceInBrowser(createdSpace.id, region);
konsola.info(`Opened space in your browser`);
}
catch (error) {
konsola.warn(`Failed to open browser: ${(error as Error).message}`);
const spaceUrl = generateSpaceUrl(createdSpace.id, region);
konsola.info(`You can manually open your space at: ${chalk.hex(colorPalette.PRIMARY)(spaceUrl)}`);
}
}

// Show next steps and space info
showNextSteps(technologyTemplate!, finalProjectPath);
if (createdSpace?.first_token) {
if (whereToCreateSpace === 'org') {
konsola.ok(`Storyblok space created in organization ${chalk.hex(colorPalette.PRIMARY)(userData?.org?.name)}, preview url and .env configured automatically. You can now open your space in the browser at ${chalk.hex(colorPalette.PRIMARY)(generateSpaceUrl(createdSpace.id, region))}`);
}
else if (whereToCreateSpace === 'partner') {
konsola.ok(`Storyblok space created in partner portal, preview url and .env configured automatically. You can now open your space in the browser at ${chalk.hex(colorPalette.PRIMARY)(generateSpaceUrl(createdSpace.id, region))}`);
}
else {
konsola.ok(`Storyblok space created, preview url and .env configured automatically. You can now open your space in the browser at ${chalk.hex(colorPalette.PRIMARY)(generateSpaceUrl(createdSpace.id, region))}`);
}
}
}
konsola.br();
konsola.info(`Next steps:
cd ${finalProjectPath}
npm install
npm run dev
`);
konsola.info(`Or check the dedicated guide at: ${chalk.hex(colorPalette.PRIMARY)(`https://www.storyblok.com/docs/guides/${technologyTemplate}`)}`);
catch (error) {
spinnerSpace.failed();
konsola.br();
handleError(error as Error, verbose);
return;
}

// showNextSteps is already called in each relevant branch above; do not call it again here.
}
catch (error) {
spinnerSpace.failed();
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface StoryblokUser {
name: string;
};
has_partner: boolean;
created_at: string;
updated_at: string;
}

export interface StoryblokLoginResponse {
Expand Down
Loading