Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions src/commands/app/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ class InitCommand extends TemplatesCommand {
this.error(`Extension(s) '${notFound.join(', ')}' not found in the Template Registry.`)
}
return extensionTemplates.map(t => t.name)
} else if (flags.yes) {
// with --yes and no explicit template, default to standalone app (no prompts)
return []
} else if (!flags['standalone-app']) {
const noLogin = flags.import || !flags.login
let [searchCriteria, orderByCriteria] = await this.getSearchCriteria(orgSupportedServices)
Expand All @@ -210,9 +213,12 @@ class InitCommand extends TemplatesCommand {
}
}

async ensureDevTermAccepted (consoleCLI, orgId) {
async ensureDevTermAccepted (consoleCLI, orgId, skipPrompts = false) {
const isTermAccepted = await consoleCLI.checkDevTermsForOrg(orgId)
if (!isTermAccepted) {
if (skipPrompts) {
this.error('Developer Terms of Service have not been accepted for this organization. Please run `aio app init` without --yes to accept the terms first.')
}
const terms = await consoleCLI.getDevTermsForOrg()
const confirmDevTerms = await consoleCLI.prompt.promptConfirm(`${terms.text}
\nYou have not accepted the Developer Terms of Service. Go to ${hyperlinker('https://www.adobe.com/go/developer-terms', 'https://www.adobe.com/go/developer-terms')} to view the terms. Do you accept the terms? (y/n):`)
Expand Down Expand Up @@ -294,26 +300,65 @@ class InitCommand extends TemplatesCommand {

async selectConsoleOrg (consoleCLI, flags) {
const organizations = await consoleCLI.getOrganizations()
const selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org })
await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id)
if (!organizations || organizations.length === 0) {
this.error('No organizations found for the logged-in user')
}
// initially select the first org, if multiple orgs are present, prompt user to select one
let selectedOrg = organizations[0]
if (organizations.length > 1) {
if (flags.yes) {
this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`)
} else {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When flags.yes is true and there are multiple orgs, the code auto-selects organizations[0] (the first org). However, if flags.org is also provided, it should try to match the specified org rather than blindly picking the first one. This silently ignores --org flag when --yes is set with multiple orgs.

Suggested change
} else {
if (flags.yes) {
if (flags.org) {
const matched = organizations.find(o => o.id === flags.org || o.code === flags.org)
if (matched) selectedOrg = matched
}
this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`)
} else {
selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org })
}

selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org })
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --yes is used and multiple organizations are returned, this logic always selects organizations[0] and skips promptForSelectOrganization, which means an explicit --org value is ignored. For non-interactive/CI usage, --org should still be honored (auto-select the matching org if present, otherwise error) to avoid initializing against an unintended organization.

Suggested change
// initially select the first org, if multiple orgs are present, prompt user to select one
let selectedOrg = organizations[0]
if (organizations.length > 1) {
if (flags.yes) {
this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`)
} else {
selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org })
}
let selectedOrg
if (flags.org) {
selectedOrg = organizations.find(org =>
org.id === flags.org ||
org.orgId === flags.org ||
org.code === flags.org ||
org.orgCode === flags.org ||
org.name === flags.org
)
if (!selectedOrg) {
this.error(`Organization '${flags.org}' not found for the logged-in user`)
}
if (flags.yes) {
this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`)
}
} else {
// initially select the first org, if multiple orgs are present, prompt user to select one
selectedOrg = organizations[0]
if (organizations.length > 1) {
if (flags.yes) {
this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`)
} else {
selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org })
}
}

Copilot uses AI. Check for mistakes.
}
await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id, flags.yes)
return selectedOrg
Comment thread
purplecabbage marked this conversation as resolved.
}

async selectOrCreateConsoleProject (consoleCLI, org, flags) {
const projects = await consoleCLI.getProjects(org.id)

if (flags.yes) {
Comment on lines 327 to +332
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getProjects(org.id) is executed before the --yes early-return branch but its result is unused when flags.yes is true, adding an avoidable network/API call to non-interactive runs. Consider moving the getProjects call into the non---yes branch.

Copilot uses AI. Check for mistakes.
// Use the aio-lib-console SDK to fetch a pre-generated unique project name (IOC-7430)
// Returns { name: '280TomatoGull', title: 'Project 289' }
let generatedName
let generatedTitle
try {
const data = await consoleCLI.getProjectNextAvailableIdentifiers(org.id)
generatedName = data.name || data.title.replace(/\s+/g, '')
generatedTitle = data.title || generatedName
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback name uses Date.now() which returns milliseconds since epoch (e.g., app1718000000000) — 16 chars. Project names may have length constraints. Consider a shorter fallback like a truncated timestamp or random suffix.

Suggested change
generatedTitle = data.title || generatedName
generatedName = `app${Date.now().toString(36)}`
generatedTitle = generatedName

aioLogger.debug(`next-available-identifiers response: ${JSON.stringify(data)}`)
} catch (e) {
aioLogger.debug(`Failed to fetch next-available-identifiers, falling back to timestamp name: ${e.message}`)
generatedName = `app${Date.now()}`
generatedTitle = generatedName
}
this.log(`Auto-generating project name: '${generatedName}'`)
const project = await consoleCLI.createProject(org.id, {
Comment on lines +332 to +378
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the --yes path you always create a new Console project, ignoring --project even if the user provided it. This can create unexpected projects in CI; consider: if --project is provided, resolve it from getProjects() and use it (or error if not found) instead of auto-creating.

Copilot uses AI. Check for mistakes.
name: generatedName,
title: generatedTitle,
description: generatedTitle
})
project.isNew = true
return project
}

let project = await consoleCLI.promptForSelectProject(
projects,
{ projectId: flags.project, projectName: flags.project },
{ allowCreate: true }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition if (flags.project && !flags.yes) combined with the else branch means: if flags.project is set AND flags.yes is true, it falls into the else branch and calls promptForCreateProjectDetails interactively — defeating the purpose of --yes. The intent should be: error if project not found regardless of --yes, or auto-create. The original code errored when flags.project was set; this change silently ignores a user-specified project that wasn't found.

Suggested change
{ allowCreate: true }
if (flags.project) {
this.error(`--project ${flags.project} not found`)
} else {
// user has escaped project selection prompt, let's create a new one
const projectDetails = await consoleCLI.promptForCreateProjectDetails()
project = await consoleCLI.createProject(org.id, projectDetails)
project.isNew = true
}

)
if (!project) {
if (flags.project) {
// if project is provided and not yes, error out
if (flags.project && !flags.yes) {
this.error(`--project ${flags.project} not found`)
} else {
// user has escaped project selection prompt, let's create a new one
const projectDetails = await consoleCLI.promptForCreateProjectDetails()
project = await consoleCLI.createProject(org.id, projectDetails)
project.isNew = true
}
// user has escaped project selection prompt, let's create a new one
const projectDetails = await consoleCLI.promptForCreateProjectDetails()
project = await consoleCLI.createProject(org.id, projectDetails)
project.isNew = true
}
return project
}
Comment on lines +401 to 409
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Re-raised] The condition if (flags.project && !flags.yes) is dead code in this branch because flags.yes is always false here (the flags.yes path returns early above). More importantly, this is a regression: if flags.project was provided but promptForSelectProject returned null (project not found), the original code errored. Now it silently falls into the else and creates a new project with interactively collected details, ignoring the user's explicit --project value.

Suggested change
} else {
// User chose to create a new project — collect details interactively and create it.
const projectDetails = await consoleCLI.promptForCreateProjectDetails()
project = await consoleCLI.createProject(org.id, projectDetails)
project.isNew = true
}
// user has escaped project selection prompt, let's create a new one
const projectDetails = await consoleCLI.promptForCreateProjectDetails()
project = await consoleCLI.createProject(org.id, projectDetails)
project.isNew = true
}
return project
}
if (flags.project) {
this.error(`--project ${flags.project} not found`)
} else {
// User chose to create a new project — collect details interactively and create it.
const projectDetails = await consoleCLI.promptForCreateProjectDetails()
project = await consoleCLI.createProject(org.id, projectDetails)
project.isNew = true
}

Expand All @@ -324,7 +369,7 @@ class InitCommand extends TemplatesCommand {
const workspaces = await consoleCLI.getWorkspaces(org.id, project.id)
let workspace = workspaces.find(w => w.name.toLowerCase() === workspaceName.toLowerCase())
if (!workspace) {
if (flags['confirm-new-workspace']) {
if (!flags.yes && flags['confirm-new-workspace']) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When flags.yes is true and a workspace doesn't exist, it silently auto-creates it. But if the user did NOT pass --confirm-new-workspace and also did NOT pass --yes, the original code would also skip the prompt and auto-create (only prompts when confirm-new-workspace is set). The condition !flags.yes && flags['confirm-new-workspace'] means: only prompt when NOT yes AND confirm-new-workspace is set. This looks correct, but it's worth verifying the else branch (auto-create) is safe when yes=false and confirm-new-workspace=false — which is the existing behavior preserved here.

Suggested change
if (!flags.yes && flags['confirm-new-workspace']) {
if (!flags.yes && flags['confirm-new-workspace']) {

const shouldNewWorkspace = await consoleCLI.prompt.promptConfirm(`Workspace '${workspaceName}' does not exist \n > Do you wish to create a new workspace?`)
if (!shouldNewWorkspace) {
this.error(`Workspace '${workspaceName}' does not exist and creation aborted`)
Expand Down
135 changes: 125 additions & 10 deletions test/commands/app/init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const mockConsoleCLIInstance = {
promptForSelectOrganization: jest.fn(),
getOrganizations: jest.fn(),
getProjects: jest.fn(),
Comment thread
purplecabbage marked this conversation as resolved.
getProjectNextAvailableIdentifiers: jest.fn(),
promptForSelectProject: jest.fn(),
promptForCreateProjectDetails: jest.fn(),
createProject: jest.fn(),
Expand Down Expand Up @@ -158,6 +159,7 @@ beforeEach(() => {

resetMockConsoleCLI()
mockConsoleCLIInstance.promptForSelectOrganization.mockResolvedValue({ id: 'my-org' })
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'my-org' }, { id: 'other-org' }])
mockConsoleCLIInstance.getDevTermsForOrg.mockResolvedValue({ text: 'These are the Dev Terms.' })
mockConsoleCLIInstance.checkDevTermsForOrg.mockResolvedValue(true)
mockConsoleCLIInstance.createProject.mockResolvedValue({})
Expand Down Expand Up @@ -420,19 +422,17 @@ describe('--no-login', () => {
expect(importHelperLib.importConfigJson).not.toHaveBeenCalled()
})

test('--yes --no-install, select excshell', async () => {
const installOptions = {
useDefaultValues: true,
installNpm: false,
installConfig: false,
templates: ['@adobe/my-extension']
}
command.selectTemplates.mockResolvedValue(['@adobe/my-extension'])

test('--yes --no-install without --template creates standalone app', async () => {
command.argv = ['--no-login', '--yes', '--no-install']
await command.run()

expect(command.installTemplates).toHaveBeenCalledWith(installOptions)
expect(command.installTemplates).toHaveBeenCalledWith({
useDefaultValues: true,
installNpm: false,
installConfig: false,
templates: []
})
expect(command.selectTemplates).not.toHaveBeenCalled()
expect(LibConsoleCLI.init).not.toHaveBeenCalled()
expect(importHelperLib.importConfigJson).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -543,6 +543,77 @@ describe('--login', () => {
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
})

test('--yes falls back to timestamp name when getProjectNextAvailableIdentifiers throws', async () => {
mockConsoleCLIInstance.getProjectNextAvailableIdentifiers.mockRejectedValue(new Error('API error'))
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'app12345' })

command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
await command.run()

expect(mockConsoleCLIInstance.getProjectNextAvailableIdentifiers).toHaveBeenCalled()
expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ name: expect.stringMatching(/^app\d+$/) })
)
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
})

test('--yes uses name and title from getProjectNextAvailableIdentifiers', async () => {
mockConsoleCLIInstance.getProjectNextAvailableIdentifiers.mockResolvedValue({ name: 'TomatoGull', title: 'Project 289' })
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'TomatoGull' })

command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
await command.run()

expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ name: 'TomatoGull', title: 'Project 289' })
)
})

test('--yes derives name from title when getProjectNextAvailableIdentifiers returns no name', async () => {
mockConsoleCLIInstance.getProjectNextAvailableIdentifiers.mockResolvedValue({ title: 'Project 289' })
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'Project289' })

command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
await command.run()

expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ name: 'Project289', title: 'Project 289' })
)
})

test('--yes uses name as title when getProjectNextAvailableIdentifiers returns no title', async () => {
mockConsoleCLIInstance.getProjectNextAvailableIdentifiers.mockResolvedValue({ name: 'TomatoGull' })
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'TomatoGull' })

command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
await command.run()

expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ name: 'TomatoGull', title: 'TomatoGull' })
)
})

test('--yes with missing workspace auto-creates without confirm prompt', async () => {
mockConsoleCLIInstance.getProjectNextAvailableIdentifiers.mockResolvedValue({ name: 'TomatoGull', title: 'Project 289' })
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'TomatoGull' })
mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([{ name: 'Stage' }, { name: 'Production' }])
mockConsoleCLIInstance.createWorkspace.mockResolvedValue({ id: 'newwsid', name: 'CustomWs' })

command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension', '-w', 'CustomWs']
await command.run()

expect(mockConsoleCLIInstance.prompt.promptConfirm).not.toHaveBeenCalled()
expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ name: 'CustomWs' })
)
})

test('--import fakeconfig.json', async () => {
importHelperLib.loadAndValidateConfigFile.mockReturnValue({ values: fakeConfig })
importHelperLib.getServiceApiKey.mockReturnValue('fakeclientid')
Expand Down Expand Up @@ -786,6 +857,43 @@ describe('no args', () => {
})
})

describe('selectConsoleOrg', () => {
test('no organizations returned', async () => {
mockConsoleCLIInstance.getOrganizations.mockResolvedValue(null)
await expect(command.run()).rejects.toThrow('No organizations found for the logged-in user')
})

test('empty organizations list', async () => {
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([])
await expect(command.run()).rejects.toThrow('No organizations found for the logged-in user')
})

test('single org is auto-selected without prompt', async () => {
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'my-org', name: 'My Org' }])
command.argv = ['--standalone-app']
await command.run()
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
expect(LibConsoleCLI.init).toHaveBeenCalled()
})

test('--yes with multiple orgs auto-selects first org without prompt', async () => {
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
await command.run()
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
})
})

describe('ensureDevTermAccepted', () => {
test('uses skipPrompts=false by default (terms already accepted)', async () => {
mockConsoleCLIInstance.checkDevTermsForOrg.mockResolvedValue(true)
// Call directly without the third argument to exercise the default parameter
await command.ensureDevTermAccepted(mockConsoleCLIInstance, 'org-id')
expect(mockConsoleCLIInstance.checkDevTermsForOrg).toHaveBeenCalledWith('org-id')
expect(mockConsoleCLIInstance.prompt.promptConfirm).not.toHaveBeenCalled()
})
})

describe('dev terms', () => {
test('not accepted', async () => {
mockConsoleCLIInstance.checkDevTermsForOrg.mockResolvedValue(false)
Expand Down Expand Up @@ -813,6 +921,13 @@ describe('dev terms', () => {

await expect(command.run()).rejects.toThrow('The Developer Terms of Service could not be accepted')
})

test('--yes errors without prompting when terms not accepted', async () => {
mockConsoleCLIInstance.checkDevTermsForOrg.mockResolvedValue(false)
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
await expect(command.run()).rejects.toThrow('Developer Terms of Service have not been accepted')
expect(mockConsoleCLIInstance.prompt.promptConfirm).not.toHaveBeenCalled()
})
})

describe('template-options', () => {
Expand Down
Loading