From e8d6bbeb798d9d7b3853b857b912a648e552ad83 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 26 Mar 2026 13:15:20 -0700 Subject: [PATCH 1/6] wip: allow project creation by name --- src/commands/console/project/create.js | 89 ++++++++++++++++++++ test/commands/console/project/create.test.js | 58 +++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 src/commands/console/project/create.js create mode 100644 test/commands/console/project/create.test.js diff --git a/src/commands/console/project/create.js b/src/commands/console/project/create.js new file mode 100644 index 0000000..1a6b0df --- /dev/null +++ b/src/commands/console/project/create.js @@ -0,0 +1,89 @@ +/* +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.log('You have not selected an Organization. Please select first.') + this.printConsoleConfig() + this.exit(1) + } + + if (!flags.name) { + this.log('You have not provided a name for the project. Please provide a name.') + this.exit(1) + } + + const projectDetails = { + name: flags.name, + title: flags.title || flags.name, + description: flags.description || flags.name + } + + await this.initSdk() + const project = await this.consoleCLI.createProject(orgId, projectDetails) + this.log(`Project ${project.name} created successfully.`) + return project + } + + /** + * Retrieve projects from an Org + * + * @param {string} orgId organization id + * @returns {Promise} Projects + */ + async getConsoleOrgProjects (orgId) { + const response = await this.consoleCLI.getProjects(orgId) + return response + } +} + +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/test/commands/console/project/create.test.js b/test/commands/console/project/create.test.js new file mode 100644 index 0000000..c81aa33 --- /dev/null +++ b/test/commands/console/project/create.test.js @@ -0,0 +1,58 @@ +/* +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 { Command } = require('@oclif/core') +const { stdout } = require('stdout-stderr') + +const TheCommand = require('../../../../src/commands/console/project/create') + +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 = {} + +function setDefaultMockConsoleCLI () { + mockConsoleCLIInstance.getProjects = jest.fn().mockResolvedValue(mockProject) +} +jest.mock('@adobe/aio-cli-lib-console', () => ({ + init: jest.fn().mockResolvedValue(mockConsoleCLIInstance), + cleanStdOut: jest.fn() + })) + +describe('console:project:create', () => { + let command + + beforeEach(() => { + jest.resetModules() + command = new TheCommand() + setDefaultMockConsoleCLI() + }) + + afterEach(() => { + command = null + }) + + it('should create a project', async () => { + command.argv = ['--name', 'test-project', '--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] + const result = await command.run() + expect(result).toContain('Project test-project created successfully.') // TODO: Add more specific test for the result + }) +}) \ No newline at end of file From 0b4d8f27227aa8d3f13d57c1e81429b4bbf4dcdb Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 26 Mar 2026 16:17:59 -0700 Subject: [PATCH 2/6] fix: name,title,desc validation. + tests full coverage --- src/commands/console/project/create.js | 51 ++++--- test/commands/console/project/create.test.js | 146 +++++++++++++++---- 2 files changed, 150 insertions(+), 47 deletions(-) diff --git a/src/commands/console/project/create.js b/src/commands/console/project/create.js index 1a6b0df..0c3d03c 100644 --- a/src/commands/console/project/create.js +++ b/src/commands/console/project/create.js @@ -9,7 +9,7 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA 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 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') @@ -18,14 +18,7 @@ class CreateCommand extends ConsoleCommand { const { flags } = await this.parse(CreateCommand) const orgId = flags.orgId || this.getConfig('org.id') if (!orgId) { - this.log('You have not selected an Organization. Please select first.') - this.printConsoleConfig() - this.exit(1) - } - - if (!flags.name) { - this.log('You have not provided a name for the project. Please provide a name.') - this.exit(1) + this.error('You have not selected an Organization. Please select first.') } const projectDetails = { @@ -35,21 +28,39 @@ class CreateCommand extends ConsoleCommand { } await this.initSdk() + + // 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 is too long. It must be between 3 and 45 characters long.') + } + // 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.`) + } + + // 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 is too long. It must be between 3 and 45 characters long.') + } + // Description cannot be over 1000 characters. + if (projectDetails.description.length > 1000) { + this.error('Project description is too long. It cannot be over 1000 characters.') + } + // 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 } - - /** - * Retrieve projects from an Org - * - * @param {string} orgId organization id - * @returns {Promise} Projects - */ - async getConsoleOrgProjects (orgId) { - const response = await this.consoleCLI.getProjects(orgId) - return response - } } CreateCommand.description = 'Create a new App Builder Project for the selected Organization' diff --git a/test/commands/console/project/create.test.js b/test/commands/console/project/create.test.js index c81aa33..a72d8e1 100644 --- a/test/commands/console/project/create.test.js +++ b/test/commands/console/project/create.test.js @@ -9,41 +9,37 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const { Command } = require('@oclif/core') -const { stdout } = require('stdout-stderr') - -const TheCommand = require('../../../../src/commands/console/project/create') - 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' + 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 = {} - -function setDefaultMockConsoleCLI () { - mockConsoleCLIInstance.getProjects = jest.fn().mockResolvedValue(mockProject) +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() - })) + init: jest.fn().mockResolvedValue(mockConsoleCLIInstance), + cleanStdOut: jest.fn() +})) + +const TheCommand = require('../../../../src/commands/console/project/create') describe('console:project:create', () => { let command beforeEach(() => { - jest.resetModules() command = new TheCommand() - setDefaultMockConsoleCLI() + mockConsoleCLIInstance.createProject.mockReset() }) afterEach(() => { @@ -51,8 +47,104 @@ describe('console:project:create', () => { }) it('should create a project', async () => { - command.argv = ['--name', 'test-project', '--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] + 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(result).toContain('Project test-project created successfully.') // TODO: Add more specific test for the result + 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 is too long. It cannot be over 1000 characters.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() + }) + + it('should not create a project if the name is too long', async () => { + command.argv = ['--name', 'testproject'.repeat(50), '--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project name is too long. It 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', async () => { + command.argv = ['--name', 'testName', '--title', 'Test Project'.repeat(50), '--description', 'Test Project Description', '--orgId', '1234567890'] + await expect(command.run()).rejects.toThrow('Project title is too long. It must be between 3 and 45 characters long.') + expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) From 69aaa25635b5e3c1d3992ced595486639cffdcde Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 26 Mar 2026 17:12:17 -0700 Subject: [PATCH 3/6] nit: don't say it is too long if it is too short and don't say it is too short if it is too long. --- src/commands/console/project/create.js | 6 +++--- test/commands/console/project/create.test.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/console/project/create.js b/src/commands/console/project/create.js index 0c3d03c..082f1b3 100644 --- a/src/commands/console/project/create.js +++ b/src/commands/console/project/create.js @@ -36,7 +36,7 @@ class CreateCommand extends ConsoleCommand { } // Project name must be between 3 and 45 characters long. if (projectDetails.name.length < 3 || projectDetails.name.length > 45) { - this.error('Project name is too long. It must be between 3 and 45 characters long.') + this.error('Project name must be between 3 and 45 characters long.') } // check name is not already in use const projects = await this.consoleCLI.getProjects(orgId) @@ -50,11 +50,11 @@ class CreateCommand extends ConsoleCommand { } // Project title must be between 3 and 45 characters long. if (projectDetails.title.length < 3 || projectDetails.title.length > 45) { - this.error('Project title is too long. It must be between 3 and 45 characters long.') + 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 is too long. It cannot be over 1000 characters.') + this.error('Project description cannot be over 1000 characters.') } // if we get here, all validation passed, so call server to create project const project = await this.consoleCLI.createProject(orgId, projectDetails) diff --git a/test/commands/console/project/create.test.js b/test/commands/console/project/create.test.js index a72d8e1..4d34faa 100644 --- a/test/commands/console/project/create.test.js +++ b/test/commands/console/project/create.test.js @@ -132,19 +132,19 @@ describe('console:project:create', () => { 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 is too long. It cannot be over 1000 characters.') + 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', async () => { command.argv = ['--name', 'testproject'.repeat(50), '--title', 'Test Project', '--description', 'Test Project Description', '--orgId', '1234567890'] - await expect(command.run()).rejects.toThrow('Project name is too long. It must be between 3 and 45 characters long.') + 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', async () => { command.argv = ['--name', 'testName', '--title', 'Test Project'.repeat(50), '--description', 'Test Project Description', '--orgId', '1234567890'] - await expect(command.run()).rejects.toThrow('Project title is too long. It must be between 3 and 45 characters long.') + await expect(command.run()).rejects.toThrow('Project title must be between 3 and 45 characters long.') expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() }) }) From ecab13140f0858a319c5c143bb95ba92884fac0a Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 26 Mar 2026 17:21:11 -0700 Subject: [PATCH 4/6] delay initSDK till after validation --- src/commands/console/project/create.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/commands/console/project/create.js b/src/commands/console/project/create.js index 082f1b3..d53f780 100644 --- a/src/commands/console/project/create.js +++ b/src/commands/console/project/create.js @@ -27,8 +27,6 @@ class CreateCommand extends ConsoleCommand { description: flags.description || flags.name } - await this.initSdk() - // 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)) { @@ -38,11 +36,6 @@ class CreateCommand extends ConsoleCommand { if (projectDetails.name.length < 3 || projectDetails.name.length > 45) { this.error('Project name must be between 3 and 45 characters long.') } - // 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.`) - } // Project title should only contain English alphanumeric or Latin alphabet characters and spaces. if (!/^[a-zA-Z0-9\s]+$/.test(projectDetails.title)) { @@ -56,6 +49,14 @@ class CreateCommand extends ConsoleCommand { 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.`) From 7fb27c876569245c117bf8a3e9fe63c28b6747cf Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 26 Mar 2026 17:21:43 -0700 Subject: [PATCH 5/6] nit: test for too short name/title --- test/commands/console/project/create.test.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/commands/console/project/create.test.js b/test/commands/console/project/create.test.js index 4d34faa..f6eee0a 100644 --- a/test/commands/console/project/create.test.js +++ b/test/commands/console/project/create.test.js @@ -136,15 +136,23 @@ describe('console:project:create', () => { expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled() }) - it('should not create a project if the name is too long', async () => { + 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', async () => { + 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() }) }) From 4ee4c1f470120a4def70f323af1a9447bbdfc02a Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 26 Mar 2026 17:33:31 -0700 Subject: [PATCH 6/6] feat: add console:workspace:create command Allow creating new workspaces by project name or title, enabling agent-driven workspace creation without user interaction. --- src/commands/console/workspace/create.js | 108 +++++++++++ .../commands/console/workspace/create.test.js | 170 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/commands/console/workspace/create.js create mode 100644 test/commands/console/workspace/create.test.js 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/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() + }) +})