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/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;
token?: string; // Access token for Storyblok
}

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('--token option', () => {
it('should use provided token, 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', '--token', 'my-access-token']);

// Should generate project
expect(generateProject).toHaveBeenCalledWith('react', 'my-project', expect.any(String));
// Should create .env file with provided token
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
206 changes: 113 additions & 93 deletions packages/cli/src/commands/create/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { handleError, isVitest, konsola, requireAuthentication, toHumanReadable } from '../../utils';
import { colorPalette, commands } from '../../constants';
import { colorPalette, commands, regions } from '../../constants';
import { getProgram } from '../../program';
import type { CreateOptions } from './constants';
import { session } from '../../session';
Expand All @@ -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('--token <token>', 'Storyblok access token (skip space creation and use this token)')
.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, token } = 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 token 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 (token) {
await handleEnvFileCreation(resolvedPath, token);
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 === regions.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 !== regions.EU && userData.has_org) {
whereToCreateSpace = 'org';
}
if (region !== regions.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