diff --git a/src/commands/app/init.js b/src/commands/app/init.js index 5969fc22..ea0f16a5 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -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) @@ -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):`) @@ -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) { + // Non-interactive path: no prompts are shown. Behavior depends on whether + // --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}'`) + 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` + }) + 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, { + 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 } ) 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 } @@ -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']) { 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`) diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index 5ebe41fa..756b10e5 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -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: { @@ -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() }) @@ -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') @@ -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) @@ -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', () => {