diff --git a/src/commands/console/project/create.js b/src/commands/console/project/create.js new file mode 100644 index 0000000..d53f780 --- /dev/null +++ b/src/commands/console/project/create.js @@ -0,0 +1,101 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +// const aioConsoleLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-console:project:create', { provider: 'debug' }) +const { Flags } = require('@oclif/core') +const ConsoleCommand = require('../index') + +class CreateCommand extends ConsoleCommand { + async run () { + const { flags } = await this.parse(CreateCommand) + const orgId = flags.orgId || this.getConfig('org.id') + if (!orgId) { + this.error('You have not selected an Organization. Please select first.') + } + + const projectDetails = { + name: flags.name, + title: flags.title || flags.name, + description: flags.description || flags.name + } + + // first validate name, before calling server to check if it is already in use + // Project name allows only alphanumeric values + if (!/^[a-zA-Z0-9]+$/.test(projectDetails.name)) { + this.error(`Project name ${projectDetails.name} is invalid. It should only contain alphanumeric values.`) + } + // Project name must be between 3 and 45 characters long. + if (projectDetails.name.length < 3 || projectDetails.name.length > 45) { + this.error('Project name must be between 3 and 45 characters long.') + } + + // Project title should only contain English alphanumeric or Latin alphabet characters and spaces. + if (!/^[a-zA-Z0-9\s]+$/.test(projectDetails.title)) { + this.error(`Project title ${projectDetails.title} is invalid. It should only contain English alphanumeric or Latin alphabet characters and spaces.`) + } + // Project title must be between 3 and 45 characters long. + if (projectDetails.title.length < 3 || projectDetails.title.length > 45) { + this.error('Project title must be between 3 and 45 characters long.') + } + // Description cannot be over 1000 characters. + if (projectDetails.description.length > 1000) { + this.error('Project description cannot be over 1000 characters.') + } + + await this.initSdk() + // check name is not already in use + const projects = await this.consoleCLI.getProjects(orgId) + if (projects.find(project => project.name === projectDetails.name)) { + this.error(`Project ${projectDetails.name} already exists. Please choose a different name.`) + } + + // if we get here, all validation passed, so call server to create project + const project = await this.consoleCLI.createProject(orgId, projectDetails) + this.log(`Project ${project.name} created successfully.`) + return project + } +} + +CreateCommand.description = 'Create a new App Builder Project for the selected Organization' + +CreateCommand.flags = { + ...ConsoleCommand.flags, + orgId: Flags.string({ + description: 'OrgID to create the project in' + }), + name: Flags.string({ + description: 'Name of the project', + required: true + }), + title: Flags.string({ + description: 'Title of the project, defaults to the name' + }), + description: Flags.string({ + description: 'Description of the project, defaults to the name' + }), + json: Flags.boolean({ + description: 'Output json', + char: 'j', + exclusive: ['yml'] + }), + yml: Flags.boolean({ + description: 'Output yml', + char: 'y', + exclusive: ['json'] + }) +} + +CreateCommand.aliases = [ + 'console:project:create', + 'console:project:init' +] + +module.exports = CreateCommand diff --git a/src/commands/console/workspace/create.js b/src/commands/console/workspace/create.js new file mode 100644 index 0000000..f5eed8d --- /dev/null +++ b/src/commands/console/workspace/create.js @@ -0,0 +1,108 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +const { Flags } = require('@oclif/core') +const ConsoleCommand = require('../index') + +class CreateCommand extends ConsoleCommand { + async run () { + const { flags } = await this.parse(CreateCommand) + const orgId = flags.orgId || this.getConfig('org.id') + if (!orgId) { + this.error('You have not selected an Organization. Please select first.') + } + + const workspaceDetails = { + name: flags.name, + title: flags.title || flags.name + } + + // Workspace name allows only alphanumeric values + if (!/^[a-zA-Z0-9]+$/.test(workspaceDetails.name)) { + this.error(`Workspace name ${workspaceDetails.name} is invalid. It should only contain alphanumeric values.`) + } + if (workspaceDetails.name.length < 3 || workspaceDetails.name.length > 45) { + this.error('Workspace name must be between 3 and 45 characters long.') + } + + // Workspace title should only contain alphanumeric characters and spaces + if (!/^[a-zA-Z0-9\s]+$/.test(workspaceDetails.title)) { + this.error(`Workspace title ${workspaceDetails.title} is invalid. It should only contain alphanumeric characters and spaces.`) + } + if (workspaceDetails.title.length < 3 || workspaceDetails.title.length > 45) { + this.error('Workspace title must be between 3 and 45 characters long.') + } + + await this.initSdk() + + try { + // resolve project by name or title to project id + const projects = await this.consoleCLI.getProjects(orgId) + const project = projects.find(p => p.name === flags.projectName || p.title === flags.projectName) + if (!project) { + this.error(`Project ${flags.projectName} not found in the Organization.`) + } + const projectId = project.id + + const workspaces = await this.consoleCLI.getWorkspaces(orgId, projectId) + if (workspaces.find(ws => ws.name === workspaceDetails.name)) { + this.error(`Workspace ${workspaceDetails.name} already exists. Please choose a different name.`) + } + + const workspace = await this.consoleCLI.createWorkspace(orgId, projectId, workspaceDetails) + this.log(`Workspace ${workspace.name} created successfully.`) + return workspace + } catch (err) { + this.error(err.message) + } finally { + this.cleanOutput() + } + } +} + +CreateCommand.description = 'Create a new Workspace for the selected Project' + +CreateCommand.flags = { + ...ConsoleCommand.flags, + orgId: Flags.string({ + description: 'OrgID of the project to create the workspace in' + }), + projectName: Flags.string({ + description: 'Name or title of the project to create the workspace in', + required: true + }), + name: Flags.string({ + description: 'Name of the workspace', + required: true + }), + title: Flags.string({ + description: 'Title of the workspace, defaults to the name' + }), + json: Flags.boolean({ + description: 'Output json', + char: 'j', + exclusive: ['yml'] + }), + yml: Flags.boolean({ + description: 'Output yml', + char: 'y', + exclusive: ['json'] + }) +} + +CreateCommand.aliases = [ + 'console:workspace:create', + 'console:workspace:init', + 'console:ws:create', + 'console:ws:init' +] + +module.exports = CreateCommand diff --git a/test/commands/console/project/create.test.js b/test/commands/console/project/create.test.js new file mode 100644 index 0000000..f6eee0a --- /dev/null +++ b/test/commands/console/project/create.test.js @@ -0,0 +1,158 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const mockProject = { + appId: null, + date_created: '2020-04-29T10:14:17.000Z', + date_last_modified: '2020-04-29T10:14:17.000Z', + deleted: 0, + description: 'Description 1', + id: '1000000001', + name: 'name1', + org_id: 1234567890, + title: 'Title 1', + type: 'default' +} + +const mockConsoleCLIInstance = { + getProjects: jest.fn().mockResolvedValue([]), + createProject: jest.fn().mockResolvedValue(mockProject) +} + +jest.mock('@adobe/aio-cli-lib-console', () => ({ + init: jest.fn().mockResolvedValue(mockConsoleCLIInstance), + cleanStdOut: jest.fn() +})) + +const TheCommand = require('../../../../src/commands/console/project/create') + +describe('console:project:create', () => { + let command + + beforeEach(() => { + command = new TheCommand() + mockConsoleCLIInstance.createProject.mockReset() + }) + + afterEach(() => { + command = null + }) + + it('should create a project', async () => { + mockConsoleCLIInstance.createProject.mockResolvedValue(mockProject) + command.argv = ['--name', 'testproject', '--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] + const result = await command.run() + expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith('1234567890', { + name: 'testproject', + title: 'Test Project', + description: 'Test Project Description' + }) + expect(result).toEqual(mockProject) + }) + + it('should not create a project if the name is not provided', async () => { + command.argv = ['--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Missing required flag name') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) + + it('should not create a project if the orgId is not provided', async () => { + command.argv = ['--name', 'testproject', '--title', 'Test Project', '--description', 'Test Project Description'] + command.getConfig = jest.fn().mockReturnValue(null) + await expect(command.run()).rejects.toThrow('You have not selected an Organization. Please select first.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) + + it('should use config.org.id if no orgId is provided', async () => { + mockConsoleCLIInstance.createProject.mockResolvedValue(mockProject) + command.argv = ['--name', 'testproject', '--title', 'Test Project', '--description', 'Test Project Description'] + command.getConfig = jest.fn().mockReturnValue('0987654321') + const result = await command.run() + expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith('0987654321', { + name: 'testproject', + title: 'Test Project', + description: 'Test Project Description' + }) + expect(result).toEqual(mockProject) + }) + + it('should not create a project if the name is already in use', async () => { + mockConsoleCLIInstance.getProjects.mockResolvedValue([mockProject]) + mockConsoleCLIInstance.createProject.mockResolvedValue(mockProject) + command.argv = ['--name', 'name1', '--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project name1 already exists. Please choose a different name.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) + + it('should use name as title if no title is provided', async () => { + mockConsoleCLIInstance.getProjects.mockResolvedValue([]) + mockConsoleCLIInstance.createProject.mockResolvedValue(mockProject) + command.argv = ['--name', 'testproject', '--description', 'Test Project Description', '--orgId', '1234567890'] + const result = await command.run() + expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith('1234567890', { + name: 'testproject', + title: 'testproject', + description: 'Test Project Description' + }) + expect(result).toEqual(mockProject) + }) + + it('should use name as description if no description is provided', async () => { + mockConsoleCLIInstance.getProjects.mockResolvedValue([]) + mockConsoleCLIInstance.createProject.mockResolvedValue(mockProject) + command.argv = ['--name', 'testproject', '--title', 'Test Project', '--orgId', '1234567890'] + const result = await command.run() + expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith('1234567890', { + name: 'testproject', + title: 'Test Project', + description: 'testproject' + }) + expect(result).toEqual(mockProject) + }) + + it('should not create a project if the name is invalid', async () => { + command.argv = ['--name', 'test-project!', '--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project name test-project! is invalid. It should only contain alphanumeric values.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) + + it('should not create a project if the title is invalid', async () => { + command.argv = ['--name', 'testproject', '--title', 'Test Project!', '--description', 'Test Project Description', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project title Test Project! is invalid. It should only contain English alphanumeric or Latin alphabet characters and spaces.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) + + it('should not create a project if the description is too long', async () => { + command.argv = ['--name', 'testproject', '--title', 'Test Project', '--description', 'Test Project Description'.repeat(1000), '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project description cannot be over 1000 characters.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) + + it('should not create a project if the name is too long, or too short', async () => { + command.argv = ['--name', 'testproject'.repeat(50), '--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project name must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + + command.argv = ['--name', '1', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project name must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) + + it('should not create a project if the title is too long or too short', async () => { + command.argv = ['--name', 'testName', '--title', 'Test Project'.repeat(50), '--description', 'Test Project Description', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project title must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + + command.argv = ['--name', 'testName', '--title', 'HI', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project title must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) +}) diff --git a/test/commands/console/workspace/create.test.js b/test/commands/console/workspace/create.test.js new file mode 100644 index 0000000..586c085 --- /dev/null +++ b/test/commands/console/workspace/create.test.js @@ -0,0 +1,170 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const mockProject = { + id: '9999', + name: 'myproject', + title: 'My Project' +} + +const mockWorkspace = { + id: '1000000001', + name: 'TestWorkspace', + title: 'Test Workspace', + enabled: 1 +} + +const mockConsoleCLIInstance = { + getProjects: jest.fn().mockResolvedValue([mockProject]), + getWorkspaces: jest.fn().mockResolvedValue([]), + createWorkspace: jest.fn().mockResolvedValue(mockWorkspace) +} + +jest.mock('@adobe/aio-cli-lib-console', () => ({ + init: jest.fn().mockResolvedValue(mockConsoleCLIInstance), + cleanStdOut: jest.fn() +})) + +const TheCommand = require('../../../../src/commands/console/workspace/create') + +describe('console:workspace:create', () => { + let command + + beforeEach(() => { + command = new TheCommand() + mockConsoleCLIInstance.createWorkspace.mockReset() + mockConsoleCLIInstance.createWorkspace.mockResolvedValue(mockWorkspace) + mockConsoleCLIInstance.getWorkspaces.mockReset() + mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([]) + mockConsoleCLIInstance.getProjects.mockReset() + mockConsoleCLIInstance.getProjects.mockResolvedValue([mockProject]) + }) + + afterEach(() => { + command = null + }) + + it('should create a workspace', async () => { + command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace', '--projectName', 'myproject', '--orgId', '1234567890'] + const result = await command.run() + expect(mockConsoleCLIInstance.getProjects).toHaveBeenCalledWith('1234567890') + expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith('1234567890', '9999', { + name: 'testworkspace', + title: 'Test Workspace' + }) + expect(result).toEqual(mockWorkspace) + }) + + it('should not create a workspace if the name is not provided', async () => { + command.argv = ['--projectName', 'myproject', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Missing required flag name') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should not create a workspace if the projectName is not provided', async () => { + command.argv = ['--name', 'testworkspace', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Missing required flag projectName') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should not create a workspace if the orgId is not provided and no config', async () => { + command.argv = ['--name', 'testworkspace', '--projectName', 'myproject'] + command.getConfig = jest.fn().mockReturnValue(null) + await expect(command.run()).rejects.toThrow('You have not selected an Organization. Please select first.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should use config org.id if orgId flag is not provided', async () => { + command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace', '--projectName', 'myproject'] + command.getConfig = jest.fn().mockImplementation(key => { + if (key === 'org.id') return '0987654321' + return null + }) + const result = await command.run() + expect(mockConsoleCLIInstance.getProjects).toHaveBeenCalledWith('0987654321') + expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith('0987654321', '9999', { + name: 'testworkspace', + title: 'Test Workspace' + }) + expect(result).toEqual(mockWorkspace) + }) + + it('should resolve project by title', async () => { + command.argv = ['--name', 'testworkspace', '--projectName', 'My Project', '--orgId', '1234567890'] + const result = await command.run() + expect(mockConsoleCLIInstance.getProjects).toHaveBeenCalledWith('1234567890') + expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith('1234567890', '9999', { + name: 'testworkspace', + title: 'testworkspace' + }) + expect(result).toEqual(mockWorkspace) + }) + + it('should error if the project name is not found', async () => { + mockConsoleCLIInstance.getProjects.mockResolvedValue([]) + command.argv = ['--name', 'testworkspace', '--projectName', 'nonexistent', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project nonexistent not found in the Organization.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should not create a workspace if the name is already in use', async () => { + mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([mockWorkspace]) + command.argv = ['--name', 'TestWorkspace', '--projectName', 'myproject', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Workspace TestWorkspace already exists. Please choose a different name.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should use name as title if no title is provided', async () => { + command.argv = ['--name', 'testworkspace', '--projectName', 'myproject', '--orgId', '1234567890'] + const result = await command.run() + expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith('1234567890', '9999', { + name: 'testworkspace', + title: 'testworkspace' + }) + expect(result).toEqual(mockWorkspace) + }) + + it('should not create a workspace if the name is invalid', async () => { + command.argv = ['--name', 'test-workspace!', '--projectName', 'myproject', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Workspace name test-workspace! is invalid. It should only contain alphanumeric values.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should not create a workspace if the title is invalid', async () => { + command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace!', '--projectName', 'myproject', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Workspace title Test Workspace! is invalid. It should only contain alphanumeric characters and spaces.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should not create a workspace if the name is too short', async () => { + command.argv = ['--name', 'ab', '--projectName', 'myproject', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Workspace name must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should not create a workspace if the name is too long', async () => { + command.argv = ['--name', 'testworkspace'.repeat(50), '--projectName', 'myproject', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Workspace name must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should not create a workspace if the title is too short', async () => { + command.argv = ['--name', 'testworkspace', '--title', 'ab', '--projectName', 'myproject', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Workspace title must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) + + it('should not create a workspace if the title is too long', async () => { + command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace'.repeat(50), '--projectName', 'myproject', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Workspace title must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled() + }) +})