Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
106 changes: 98 additions & 8 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,110 @@ 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')
}
// If --org was supplied, validate it against the full list regardless of how many orgs
// exist. This prevents silent mismatches when there is only one org but the caller
// passed a wrong id or code.
let selectedOrg
if (flags.org) {
selectedOrg = organizations.find(o => o.id === flags.org || o.code === flags.org)
if (!selectedOrg) {
this.error(`--org ${flags.org} not found`)
}
} else if (organizations.length > 1 && !flags.yes) {
// Multiple orgs and no --org: prompt interactively (only when not in --yes mode).
selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, {})
} else {
// Single org, or --yes with no --org: auto-select the first (and likely only) org.
selectedOrg = organizations[0]
this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`)
}
await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id, flags.yes)
return selectedOrg
}

async selectOrCreateConsoleProject (consoleCLI, org, flags) {
// Fetch all projects in the org upfront. This list is used both for uniqueness
// checks (--yes path) and for the interactive selection prompt (non-yes path).
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.
// Non-interactive path: no prompts are shown. Behavior depends on whether
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] When flags.yes is true and there are multiple orgs, the code auto-selects organizations[0] (the first org) and ignores the --org flag entirely. If --org is also provided it should try to match the specified org rather than blindly picking the first one.

Suggested change
// Non-interactive path: no prompts are shown. Behavior depends on whether
if (organizations.length > 1) {
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 })
}
}

// --project was explicitly supplied by the caller.

if (flags.project) {
// --project was supplied. Try to find it in the existing list by id or name.
// Matching by id supports callers who pass a project id rather than a name.
const existing = projects.find(p => p.id === flags.project || p.name === flags.project)
if (existing) {
// Project already exists — return it as-is. isNew is intentionally NOT set
// so downstream code knows not to treat this as a newly created project.
this.log(`Using existing project: '${existing.name}'`)
Comment thread
purplecabbage marked this conversation as resolved.
return existing
}
// Project does not exist — create it using the caller-supplied name directly.
// title and description are derived from the name since no other info is available.
this.log(`Project '${flags.project}' not found, creating it`)
const project = await consoleCLI.createProject(org.id, {
name: flags.project,
title: flags.project,
description: `App Builder Project ${flags.project} - generated by an agent`
})
Comment thread
purplecabbage marked this conversation as resolved.
project.isNew = true
return project
}

// No --project supplied. Auto-generate a unique name of the form "App{N}"
// where N is the lowest positive integer not already used by an existing project.
// This mimics sequential behaviour (App1, App2, ...) and fills
// gaps left by deleted projects (e.g. if App2 was deleted, it is reused
// before App4 is attempted).
const existingNames = new Set(projects.map(p => p.name))
const MAX_SUFFIX = 10000
let suffix = 1
while (existingNames.has(`App${suffix}`)) {
suffix++
if (suffix > MAX_SUFFIX) {
this.error(`Could not find an available generated App name after ${MAX_SUFFIX} attempts`)
}
}

const generatedName = `App${suffix}`
const generatedTitle = `App Builder Project ${suffix}`
const generatedDescription = `App Builder Project ${suffix} - generated`

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: generatedDescription
})
project.isNew = true
return project
}

// Interactive path: prompt the user to select an existing project or create a new one.
// If --project was supplied it is used to pre-populate the selection (by id or name)
// but the prompt is still shown so the user can confirm or change it.
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) {
// promptForSelectProject returns null when the user selects "Create new project" or
// escapes the prompt. If --project was explicitly provided but not found/selected,
// always error — never silently create a different 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
}
// 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 +414,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
183 changes: 173 additions & 10 deletions test/commands/app/init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,11 @@ 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({})
mockConsoleCLIInstance.getProjects.mockResolvedValue([])
mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([{ name: 'Stage' }, { name: 'Production' }])
mockConsoleCLIInstance.getWorkspaceConfig.mockResolvedValue({
project: {
Expand Down Expand Up @@ -421,19 +423,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 @@ -544,6 +544,103 @@ describe('--login', () => {
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
})

test('--yes --project name45 selects existing project without creating', async () => {
mockConsoleCLIInstance.getProjects.mockResolvedValue([{ id: 'proj45id', name: 'name45' }])

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

expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled()
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
})

test('--yes --project name45 creates project when it does not exist', async () => {
mockConsoleCLIInstance.getProjects.mockResolvedValue([{ id: 'other', name: 'otherProject' }])
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newid', name: 'name45' })

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

expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
expect.anything(),
{ name: 'name45', title: 'name45', description: 'App Builder Project name45 - generated by an agent' }
)
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
})

test('--yes with no existing projects uses App1', async () => {
mockConsoleCLIInstance.getProjects.mockResolvedValue([])
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App1' })

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

expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
expect.anything(),
{ name: 'App1', title: 'App Builder Project 1', description: 'App Builder Project 1 - generated' }
)
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
})

test('--yes skips existing names and picks next available suffix', async () => {
mockConsoleCLIInstance.getProjects.mockResolvedValue([
{ name: 'App1' },
{ name: 'App2' },
{ name: 'App3' }
])
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App4' })

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

expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
expect.anything(),
{ name: 'App4', title: 'App Builder Project 4', description: 'App Builder Project 4 - generated' }
)
})

test('--yes skips non-sequential gaps and picks first available', async () => {
mockConsoleCLIInstance.getProjects.mockResolvedValue([
{ name: 'App1' },
{ name: 'App3' }
])
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App2' })

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

expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
expect.anything(),
{ name: 'App2', title: 'App Builder Project 2', description: 'App Builder Project 2 - generated' }
)
})

test('--yes errors when all App{N} names up to MAX_SUFFIX are taken', async () => {
const MAX_SUFFIX = 10000
const allTaken = Array.from({ length: MAX_SUFFIX }, (_, i) => ({ name: `App${i + 1}` }))
mockConsoleCLIInstance.getProjects.mockResolvedValue(allTaken)

command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
await expect(command.run()).rejects.toThrow(`Could not find an available generated App name after ${MAX_SUFFIX} attempts`)
expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled()
})

test('--yes with missing workspace auto-creates without confirm prompt', async () => {
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App1' })
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 @@ -787,6 +884,65 @@ 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()
})

test('--yes --org selects matching org by id without prompt', async () => {
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'org-a' }, { id: 'org-b' }])
command.argv = ['--yes', '--org', 'org-b', '--no-install', '--template', '@adobe/my-extension']
await command.run()
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
expect(mockConsoleCLIInstance.getEnabledServicesForOrg).toHaveBeenCalledWith('org-b')
})

test('--yes --org throws when org not found', async () => {
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'org-a' }, { id: 'org-b' }])
command.argv = ['--yes', '--org', 'non-existent-org', '--no-install', '--template', '@adobe/my-extension']
await expect(command.run()).rejects.toThrow('--org non-existent-org not found')
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
})

test('--org throws when mismatched against the only org (single org)', async () => {
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'only-org' }])
command.argv = ['--yes', '--org', 'wrong-org', '--no-install', '--template', '@adobe/my-extension']
await expect(command.run()).rejects.toThrow('--org wrong-org not found')
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.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 @@ -814,6 +970,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