Skip to content

Commit 1ec9d85

Browse files
authored
Allow workspace creation by name (#244)
* allow project creation by name * name,title,desc validation. + tests full coverage * Allow creating new workspaces by project name, enabling agent-driven workspace creation without user interaction.
1 parent 6499469 commit 1ec9d85

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
Copyright 2026 Adobe. All rights reserved.
3+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License. You may obtain a copy
5+
of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
7+
Unless required by applicable law or agreed to in writing, software distributed under
8+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
OF ANY KIND, either express or implied. See the License for the specific language
10+
governing permissions and limitations under the License.
11+
*/
12+
const { Flags } = require('@oclif/core')
13+
const ConsoleCommand = require('../index')
14+
15+
class CreateCommand extends ConsoleCommand {
16+
async run () {
17+
const { flags } = await this.parse(CreateCommand)
18+
const orgId = flags.orgId || this.getConfig('org.id')
19+
if (!orgId) {
20+
this.log('You have not selected an Organization. Please select one first.')
21+
this.printConsoleConfig()
22+
this.exit(1)
23+
}
24+
25+
const workspaceDetails = {
26+
name: flags.name,
27+
title: flags.title || flags.name
28+
}
29+
30+
// Workspace name allows only alphanumeric values
31+
if (!/^[a-zA-Z0-9]+$/.test(workspaceDetails.name)) {
32+
this.error(`Workspace name ${workspaceDetails.name} is invalid. It should only contain alphanumeric values.`)
33+
}
34+
if (workspaceDetails.name.length < 3 || workspaceDetails.name.length > 45) {
35+
this.error('Workspace name must be between 3 and 45 characters long.')
36+
}
37+
38+
// Workspace title should only contain alphanumeric characters and spaces
39+
if (!/^[a-zA-Z0-9 ]+$/.test(workspaceDetails.title)) {
40+
this.error(`Workspace title ${workspaceDetails.title} is invalid. It should only contain alphanumeric characters and spaces.`)
41+
}
42+
if (workspaceDetails.title.length < 3 || workspaceDetails.title.length > 45) {
43+
this.error('Workspace title must be between 3 and 45 characters long.')
44+
}
45+
46+
await this.initSdk()
47+
48+
try {
49+
// resolve project by name to project id
50+
const projects = await this.consoleCLI.getProjects(orgId)
51+
const project = projects.find(p => p.name === flags.projectName)
52+
if (!project) {
53+
this.error(`Project ${flags.projectName} not found in the Organization.`)
54+
}
55+
const projectId = project.id
56+
57+
const workspaces = await this.consoleCLI.getWorkspaces(orgId, projectId)
58+
if (workspaces.find(ws => ws.name === workspaceDetails.name)) {
59+
this.error(`Workspace ${workspaceDetails.name} already exists. Please choose a different name.`)
60+
}
61+
62+
const workspace = await this.consoleCLI.createWorkspace(orgId, projectId, workspaceDetails)
63+
if (flags.json) {
64+
this.printJson(workspace)
65+
} else if (flags.yml) {
66+
this.printYaml(workspace)
67+
} else {
68+
this.log(`Workspace ${workspace.name} created successfully.`)
69+
}
70+
return workspace
71+
} catch (err) {
72+
this.error(err.message)
73+
} finally {
74+
this.cleanOutput()
75+
}
76+
}
77+
}
78+
79+
CreateCommand.description = 'Create a new Workspace in the specified Project'
80+
81+
CreateCommand.flags = {
82+
...ConsoleCommand.flags,
83+
orgId: Flags.string({
84+
description: 'OrgID of the organization that contains the project to create the workspace in'
85+
}),
86+
projectName: Flags.string({
87+
description: 'Name of the project to create the workspace in',
88+
required: true
89+
}),
90+
name: Flags.string({
91+
description: 'Name of the workspace',
92+
required: true
93+
}),
94+
title: Flags.string({
95+
description: 'Title of the workspace, defaults to the name'
96+
}),
97+
json: Flags.boolean({
98+
description: 'Output json',
99+
char: 'j',
100+
exclusive: ['yml']
101+
}),
102+
yml: Flags.boolean({
103+
description: 'Output yml',
104+
char: 'y',
105+
exclusive: ['json']
106+
})
107+
}
108+
109+
CreateCommand.aliases = [
110+
'console:workspace:init',
111+
'console:ws:create',
112+
'console:ws:init'
113+
]
114+
115+
module.exports = CreateCommand
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
Copyright 2026 Adobe. All rights reserved.
3+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License. You may obtain a copy
5+
of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
Unless required by applicable law or agreed to in writing, software distributed under
7+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8+
OF ANY KIND, either express or implied. See the License for the specific language
9+
governing permissions and limitations under the License.
10+
*/
11+
12+
const mockProject = {
13+
id: '9999',
14+
name: 'myproject',
15+
title: 'My Project'
16+
}
17+
18+
const mockWorkspace = {
19+
id: '1000000001',
20+
name: 'TestWorkspace',
21+
title: 'Test Workspace',
22+
enabled: 1
23+
}
24+
25+
const mockConsoleCLIInstance = {
26+
getProjects: jest.fn().mockResolvedValue([mockProject]),
27+
getWorkspaces: jest.fn().mockResolvedValue([]),
28+
createWorkspace: jest.fn().mockResolvedValue(mockWorkspace)
29+
}
30+
31+
jest.mock('@adobe/aio-cli-lib-console', () => ({
32+
init: jest.fn().mockResolvedValue(mockConsoleCLIInstance),
33+
cleanStdOut: jest.fn()
34+
}))
35+
36+
const TheCommand = require('../../../../src/commands/console/workspace/create')
37+
38+
describe('console:workspace:create', () => {
39+
let command
40+
41+
beforeEach(() => {
42+
command = new TheCommand([])
43+
mockConsoleCLIInstance.createWorkspace.mockReset()
44+
mockConsoleCLIInstance.createWorkspace.mockResolvedValue(mockWorkspace)
45+
mockConsoleCLIInstance.getWorkspaces.mockReset()
46+
mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([])
47+
mockConsoleCLIInstance.getProjects.mockReset()
48+
mockConsoleCLIInstance.getProjects.mockResolvedValue([mockProject])
49+
})
50+
51+
afterEach(() => {
52+
command = null
53+
})
54+
55+
it('should create a workspace', async () => {
56+
command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace', '--projectName', 'myproject', '--orgId', '1234567890']
57+
const result = await command.run()
58+
expect(mockConsoleCLIInstance.getProjects).toHaveBeenCalledWith('1234567890')
59+
expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith('1234567890', '9999', {
60+
name: 'testworkspace',
61+
title: 'Test Workspace'
62+
})
63+
expect(result).toEqual(mockWorkspace)
64+
})
65+
66+
it('should output JSON when --json is used', async () => {
67+
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true)
68+
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
69+
70+
try {
71+
command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace', '--projectName', 'myproject', '--orgId', '1234567890', '--json']
72+
await command.run()
73+
74+
const stdout = stdoutSpy.mock.calls.map(args => args[0]).join('')
75+
const stderr = stderrSpy.mock.calls.map(args => args[0]).join('')
76+
77+
expect(stderr).toBe('')
78+
let parsed
79+
expect(() => { parsed = JSON.parse(stdout) }).not.toThrow()
80+
expect(parsed).toMatchObject(mockWorkspace)
81+
} finally {
82+
stdoutSpy.mockRestore()
83+
stderrSpy.mockRestore()
84+
}
85+
})
86+
87+
it('should output YAML when --yml is used', async () => {
88+
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true)
89+
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
90+
91+
try {
92+
command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace', '--projectName', 'myproject', '--orgId', '1234567890', '--yml']
93+
await command.run()
94+
95+
const stdout = stdoutSpy.mock.calls.map(args => args[0]).join('')
96+
const stderr = stderrSpy.mock.calls.map(args => args[0]).join('')
97+
98+
expect(stderr).toBe('')
99+
expect(stdout).toContain('id:')
100+
expect(stdout).toContain('name:')
101+
expect(stdout).toContain('title:')
102+
expect(stdout).toContain('Test Workspace')
103+
expect(stdout).toContain('TestWorkspace')
104+
} finally {
105+
stdoutSpy.mockRestore()
106+
stderrSpy.mockRestore()
107+
}
108+
})
109+
it('should not create a workspace if the name is not provided', async () => {
110+
command.argv = ['--projectName', 'myproject', '--orgId', '1234567890']
111+
await expect(command.run()).rejects.toThrow('Missing required flag name')
112+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
113+
})
114+
115+
it('should not create a workspace if the projectName is not provided', async () => {
116+
command.argv = ['--name', 'testworkspace', '--orgId', '1234567890']
117+
await expect(command.run()).rejects.toThrow('Missing required flag projectName')
118+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
119+
})
120+
121+
it('should not create a workspace if the orgId is not provided and no config', async () => {
122+
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true)
123+
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
124+
125+
try {
126+
command.argv = ['--name', 'testworkspace', '--projectName', 'myproject']
127+
command.getConfig = jest.fn().mockReturnValue(null)
128+
129+
await expect(command.run()).rejects.toThrow()
130+
131+
const stdout = stdoutSpy.mock.calls.map(args => args[0]).join('')
132+
const stderr = stderrSpy.mock.calls.map(args => args[0]).join('')
133+
134+
const combinedOutput = stdout + stderr
135+
expect(combinedOutput).toContain('You have not selected an Organization. Please select one first.')
136+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
137+
} finally {
138+
stdoutSpy.mockRestore()
139+
stderrSpy.mockRestore()
140+
}
141+
})
142+
143+
it('should use config org.id if orgId flag is not provided', async () => {
144+
command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace', '--projectName', 'myproject']
145+
command.getConfig = jest.fn().mockImplementation(key => {
146+
if (key === 'org.id') return '0987654321'
147+
return null
148+
})
149+
const result = await command.run()
150+
expect(mockConsoleCLIInstance.getProjects).toHaveBeenCalledWith('0987654321')
151+
expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith('0987654321', '9999', {
152+
name: 'testworkspace',
153+
title: 'Test Workspace'
154+
})
155+
expect(result).toEqual(mockWorkspace)
156+
})
157+
158+
it('should error if the project name is not found', async () => {
159+
mockConsoleCLIInstance.getProjects.mockResolvedValue([])
160+
command.argv = ['--name', 'testworkspace', '--projectName', 'nonexistent', '--orgId', '1234567890']
161+
await expect(command.run()).rejects.toThrow('Project nonexistent not found in the Organization.')
162+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
163+
})
164+
165+
it('should not create a workspace if the name is already in use', async () => {
166+
mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([mockWorkspace])
167+
command.argv = ['--name', 'TestWorkspace', '--projectName', 'myproject', '--orgId', '1234567890']
168+
await expect(command.run()).rejects.toThrow('Workspace TestWorkspace already exists. Please choose a different name.')
169+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
170+
})
171+
172+
it('should use name as title if no title is provided', async () => {
173+
command.argv = ['--name', 'testworkspace', '--projectName', 'myproject', '--orgId', '1234567890']
174+
const result = await command.run()
175+
expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith('1234567890', '9999', {
176+
name: 'testworkspace',
177+
title: 'testworkspace'
178+
})
179+
expect(result).toEqual(mockWorkspace)
180+
})
181+
182+
it('should not create a workspace if the name is invalid', async () => {
183+
command.argv = ['--name', 'test-workspace!', '--projectName', 'myproject', '--orgId', '1234567890']
184+
await expect(command.run()).rejects.toThrow('Workspace name test-workspace! is invalid. It should only contain alphanumeric values.')
185+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
186+
})
187+
188+
it('should not create a workspace if the title is invalid', async () => {
189+
command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace!', '--projectName', 'myproject', '--orgId', '1234567890']
190+
await expect(command.run()).rejects.toThrow('Workspace title Test Workspace! is invalid. It should only contain alphanumeric characters and spaces.')
191+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
192+
})
193+
194+
it('should not create a workspace if the name is too short', async () => {
195+
command.argv = ['--name', 'ab', '--projectName', 'myproject', '--orgId', '1234567890']
196+
await expect(command.run()).rejects.toThrow('Workspace name must be between 3 and 45 characters long.')
197+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
198+
})
199+
200+
it('should not create a workspace if the name is too long', async () => {
201+
command.argv = ['--name', 'testworkspace'.repeat(50), '--projectName', 'myproject', '--orgId', '1234567890']
202+
await expect(command.run()).rejects.toThrow('Workspace name must be between 3 and 45 characters long.')
203+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
204+
})
205+
206+
it('should not create a workspace if the title is too short', async () => {
207+
command.argv = ['--name', 'testworkspace', '--title', 'ab', '--projectName', 'myproject', '--orgId', '1234567890']
208+
await expect(command.run()).rejects.toThrow('Workspace title must be between 3 and 45 characters long.')
209+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
210+
})
211+
212+
it('should not create a workspace if the title is too long', async () => {
213+
command.argv = ['--name', 'testworkspace', '--title', 'Test Workspace'.repeat(50), '--projectName', 'myproject', '--orgId', '1234567890']
214+
await expect(command.run()).rejects.toThrow('Workspace title must be between 3 and 45 characters long.')
215+
expect(mockConsoleCLIInstance.createWorkspace).not.toHaveBeenCalled()
216+
})
217+
})

0 commit comments

Comments
 (0)