-
Notifications
You must be signed in to change notification settings - Fork 17
feat(cli): allow storyblok create to use an existing space #385
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
9444f7e
85605e6
38f2e71
322ae1a
943e9ad
4a2bc2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
|
@@ -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 () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', () => { | ||
|
|
@@ -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(); | ||
| }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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)') | ||
|
||
| .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; | ||
|
|
@@ -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(); | ||
|
|
@@ -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) { | ||
|
||
| whereToCreateSpace = 'org'; | ||
| } | ||
| if (region !== 'eu' && !userData.has_org) { | ||
dipankarmaikap marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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(); | ||
|
|
||
There was a problem hiding this comment.
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, oraccessToken? I know it used to be calledkey, but I believe those 2 are more descriptive and here we have a chance to improve the DX ;)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Going with
token