From 7a882def31dc5c8da62ef5917cb4c7d72a000152 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 9 Apr 2026 14:15:49 -0700 Subject: [PATCH 1/9] enable agentic workflows --- src/commands/app/init.js | 63 ++++++++++++--- test/commands/app/init.test.js | 135 ++++++++++++++++++++++++++++++--- 2 files changed, 179 insertions(+), 19 deletions(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index 5969fc22..c4fc103d 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,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 { + selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org }) + } + } + await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id, flags.yes) return selectedOrg } async selectOrCreateConsoleProject (consoleCLI, org, flags) { const projects = await consoleCLI.getProjects(org.id) + + if (flags.yes) { + // 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 + 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, { + 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 } ) 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 } @@ -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']) { 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 aa9abd98..a6b04abc 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -55,6 +55,7 @@ const mockConsoleCLIInstance = { promptForSelectOrganization: jest.fn(), getOrganizations: jest.fn(), getProjects: jest.fn(), + getProjectNextAvailableIdentifiers: jest.fn(), promptForSelectProject: jest.fn(), promptForCreateProjectDetails: jest.fn(), createProject: jest.fn(), @@ -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({}) @@ -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() }) @@ -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') @@ -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) @@ -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', () => { From dc0dde74dee89d54c585e5ff0e14291cfd0e1038 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 27 Apr 2026 18:49:16 -0700 Subject: [PATCH 2/9] removed dependency on new console api (they do not want to expose it) --- src/commands/app/init.js | 65 +++++++++++++++++++++++++--------- test/commands/app/init.test.js | 57 ++++++++++++++++++----------- 2 files changed, 86 insertions(+), 36 deletions(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index c4fc103d..d275252a 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -317,44 +317,77 @@ class InitCommand extends TemplatesCommand { } 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) { - // 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 - 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 + // 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 "generatedAppN" + // where N is the lowest positive integer not already used by an existing project. + // This mimics sequential behaviour (generatedApp1, generatedApp2, ...) and fills + // gaps left by deleted projects (e.g. if generatedApp2 was deleted, it is reused + // before generatedApp4 is attempted). + const existingNames = new Set(projects.map(p => p.name)) + let suffix = 1 + while (existingNames.has(`generatedApp${suffix}`)) { + suffix++ + } + + const generatedName = `generatedApp${suffix}` + const generatedTitle = `AB Project ${suffix}` + const generatedDescription = `App Builder Project ${suffix} - generated by an agent` + this.log(`Auto-generating project name: '${generatedName}'`) const project = await consoleCLI.createProject(org.id, { name: generatedName, title: generatedTitle, - description: 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) { - // if project is provided and not yes, error out + // promptForSelectProject returns null when the user escapes the prompt (e.g. presses + // Escape or selects "Create new project"). If --project was explicitly provided but + // not found, treat that as an error rather than silently creating a different project. 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 + // 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 diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index c22ee4c3..694334a1 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -163,6 +163,7 @@ beforeEach(() => { 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: { @@ -544,63 +545,79 @@ 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' }) + 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'] + 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.getProjectNextAvailableIdentifiers).toHaveBeenCalled() expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ name: expect.stringMatching(/^app\d+$/) }) + { name: 'name45', title: 'name45', description: 'App Builder Project name45 - generated by an agent' } ) 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' }) + test('--yes with no existing projects uses generatedApp1', async () => { + mockConsoleCLIInstance.getProjects.mockResolvedValue([]) + mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'generatedApp1' }) 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' }) + { name: 'generatedApp1', title: 'AB Project 1', description: 'App Builder Project 1 - generated by an agent' } ) + expect(importHelperLib.importConfigJson).toHaveBeenCalled() }) - 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' }) + test('--yes skips existing names and picks next available suffix', async () => { + mockConsoleCLIInstance.getProjects.mockResolvedValue([ + { name: 'generatedApp1' }, + { name: 'generatedApp2' }, + { name: 'generatedApp3' } + ]) + mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'generatedApp4' }) 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' }) + { name: 'generatedApp4', title: 'AB Project 4', description: 'App Builder Project 4 - generated by an agent' } ) }) - test('--yes uses name as title when getProjectNextAvailableIdentifiers returns no title', async () => { - mockConsoleCLIInstance.getProjectNextAvailableIdentifiers.mockResolvedValue({ name: 'TomatoGull' }) - mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'TomatoGull' }) + test('--yes skips non-sequential gaps and picks first available', async () => { + mockConsoleCLIInstance.getProjects.mockResolvedValue([ + { name: 'generatedApp1' }, + { name: 'generatedApp3' } + ]) + mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'generatedApp2' }) command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension'] await command.run() expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ name: 'TomatoGull', title: 'TomatoGull' }) + { name: 'generatedApp2', title: 'AB Project 2', description: 'App Builder Project 2 - generated by an agent' } ) }) 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.createProject.mockResolvedValue({ id: 'newprojid', name: 'generatedApp1' }) mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([{ name: 'Stage' }, { name: 'Production' }]) mockConsoleCLIInstance.createWorkspace.mockResolvedValue({ id: 'newwsid', name: 'CustomWs' }) From c618263e45de7a721dc8939150c1b8211c489819 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 27 Apr 2026 19:20:54 -0700 Subject: [PATCH 3/9] when --yes + --org, find the org by id/code or error --- src/commands/app/init.js | 17 +++++++++++++---- test/commands/app/init.test.js | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index d275252a..d96e544b 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -307,6 +307,15 @@ class InitCommand extends TemplatesCommand { let selectedOrg = organizations[0] if (organizations.length > 1) { if (flags.yes) { + if (flags.org) { + // --org was explicitly supplied — find it by id or code, error if not found. + // Never prompt in --yes mode; the caller must supply a valid org. + const found = organizations.find(o => o.id === flags.org || o.code === flags.org) + if (!found) { + this.error(`--org ${flags.org} not found`) + } + selectedOrg = found + } this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`) } else { selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org }) @@ -381,10 +390,10 @@ class InitCommand extends TemplatesCommand { { allowCreate: true } ) if (!project) { - // promptForSelectProject returns null when the user escapes the prompt (e.g. presses - // Escape or selects "Create new project"). If --project was explicitly provided but - // not found, treat that as an error rather than silently creating a different project. - if (flags.project && !flags.yes) { + // 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. diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index 694334a1..ebbd2216 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -900,6 +900,21 @@ describe('selectConsoleOrg', () => { 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() + }) }) describe('ensureDevTermAccepted', () => { From 16ad23c2fb17f9d39c4ea6a7c87534a9ec4a0cfc Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Tue, 28 Apr 2026 09:36:23 -0700 Subject: [PATCH 4/9] validate --org regardless of how many orgs exist --- src/commands/app/init.js | 33 +++++++++++++++++---------------- test/commands/app/init.test.js | 7 +++++++ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index d96e544b..9b8ee55a 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -303,23 +303,24 @@ class InitCommand extends TemplatesCommand { 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) { - if (flags.org) { - // --org was explicitly supplied — find it by id or code, error if not found. - // Never prompt in --yes mode; the caller must supply a valid org. - const found = organizations.find(o => o.id === flags.org || o.code === flags.org) - if (!found) { - this.error(`--org ${flags.org} not found`) - } - selectedOrg = found - } - this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`) - } else { - selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org }) + // 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] + } + if (flags.yes || organizations.length === 1) { + this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`) } await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id, flags.yes) return selectedOrg diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index ebbd2216..d62bd016 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -915,6 +915,13 @@ describe('selectConsoleOrg', () => { 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', () => { From 010c361dfbf2ba59498627daa9af0ad64eb5195e Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Tue, 28 Apr 2026 10:39:55 -0700 Subject: [PATCH 5/9] generate simple names app1 app2 --- src/commands/app/init.js | 12 ++++++------ test/commands/app/init.test.js | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index 9b8ee55a..0b5ba6ca 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -357,18 +357,18 @@ class InitCommand extends TemplatesCommand { return project } - // No --project supplied. Auto-generate a unique name of the form "generatedAppN" + // 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 (generatedApp1, generatedApp2, ...) and fills - // gaps left by deleted projects (e.g. if generatedApp2 was deleted, it is reused - // before generatedApp4 is attempted). + // 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)) let suffix = 1 - while (existingNames.has(`generatedApp${suffix}`)) { + while (existingNames.has(`App${suffix}`)) { suffix++ } - const generatedName = `generatedApp${suffix}` + const generatedName = `App${suffix}` const generatedTitle = `AB Project ${suffix}` const generatedDescription = `App Builder Project ${suffix} - generated by an agent` diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index d62bd016..e7fcea9b 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -569,55 +569,55 @@ describe('--login', () => { expect(importHelperLib.importConfigJson).toHaveBeenCalled() }) - test('--yes with no existing projects uses generatedApp1', async () => { + test('--yes with no existing projects uses App1', async () => { mockConsoleCLIInstance.getProjects.mockResolvedValue([]) - mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'generatedApp1' }) + 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: 'generatedApp1', title: 'AB Project 1', description: 'App Builder Project 1 - generated by an agent' } + { name: 'App1', title: 'AB Project 1', description: 'App Builder Project 1 - generated by an agent' } ) expect(importHelperLib.importConfigJson).toHaveBeenCalled() }) test('--yes skips existing names and picks next available suffix', async () => { mockConsoleCLIInstance.getProjects.mockResolvedValue([ - { name: 'generatedApp1' }, - { name: 'generatedApp2' }, - { name: 'generatedApp3' } + { name: 'App1' }, + { name: 'App2' }, + { name: 'App3' } ]) - mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'generatedApp4' }) + 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: 'generatedApp4', title: 'AB Project 4', description: 'App Builder Project 4 - generated by an agent' } + { name: 'App4', title: 'AB Project 4', description: 'App Builder Project 4 - generated by an agent' } ) }) test('--yes skips non-sequential gaps and picks first available', async () => { mockConsoleCLIInstance.getProjects.mockResolvedValue([ - { name: 'generatedApp1' }, - { name: 'generatedApp3' } + { name: 'App1' }, + { name: 'App3' } ]) - mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'generatedApp2' }) + 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: 'generatedApp2', title: 'AB Project 2', description: 'App Builder Project 2 - generated by an agent' } + { name: 'App2', title: 'AB Project 2', description: 'App Builder Project 2 - generated by an agent' } ) }) test('--yes with missing workspace auto-creates without confirm prompt', async () => { - mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'generatedApp1' }) + mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App1' }) mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([{ name: 'Stage' }, { name: 'Production' }]) mockConsoleCLIInstance.createWorkspace.mockResolvedValue({ id: 'newwsid', name: 'CustomWs' }) From 00d5e8674a1086a0b0eadccdf4dbddcae9c13341 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Tue, 28 Apr 2026 10:49:11 -0700 Subject: [PATCH 6/9] Changed title/description to be friendly and clean --- src/commands/app/init.js | 4 ++-- test/commands/app/init.test.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index 0b5ba6ca..afe665c5 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -369,8 +369,8 @@ class InitCommand extends TemplatesCommand { } const generatedName = `App${suffix}` - const generatedTitle = `AB Project ${suffix}` - const generatedDescription = `App Builder Project ${suffix} - generated by an agent` + 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, { diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index e7fcea9b..ccac0ffc 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -578,7 +578,7 @@ describe('--login', () => { expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith( expect.anything(), - { name: 'App1', title: 'AB Project 1', description: 'App Builder Project 1 - generated by an agent' } + { name: 'App1', title: 'App Builder Project 1', description: 'App Builder Project 1 - generated' } ) expect(importHelperLib.importConfigJson).toHaveBeenCalled() }) @@ -596,7 +596,7 @@ describe('--login', () => { expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith( expect.anything(), - { name: 'App4', title: 'AB Project 4', description: 'App Builder Project 4 - generated by an agent' } + { name: 'App4', title: 'App Builder Project 4', description: 'App Builder Project 4 - generated' } ) }) @@ -612,7 +612,7 @@ describe('--login', () => { expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith( expect.anything(), - { name: 'App2', title: 'AB Project 2', description: 'App Builder Project 2 - generated by an agent' } + { name: 'App2', title: 'App Builder Project 2', description: 'App Builder Project 2 - generated' } ) }) From eb6ffde6683619726e7d0cbe811fdafe234af643 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Tue, 28 Apr 2026 13:33:02 -0700 Subject: [PATCH 7/9] if we cannot find a name after 10000 tries, just give up --- src/commands/app/init.js | 4 ++++ test/commands/app/init.test.js | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index afe665c5..6103b001 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -363,9 +363,13 @@ class InitCommand extends TemplatesCommand { // 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}` diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index ccac0ffc..756b10e5 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -55,7 +55,6 @@ const mockConsoleCLIInstance = { promptForSelectOrganization: jest.fn(), getOrganizations: jest.fn(), getProjects: jest.fn(), - getProjectNextAvailableIdentifiers: jest.fn(), promptForSelectProject: jest.fn(), promptForCreateProjectDetails: jest.fn(), createProject: jest.fn(), @@ -616,6 +615,16 @@ describe('--login', () => { ) }) + 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' }]) From 27cc8bac2f62c555d02d5b8b65d8ea6231179ee6 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Tue, 28 Apr 2026 13:37:43 -0700 Subject: [PATCH 8/9] Update src/commands/app/init.js Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/commands/app/init.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index 6103b001..3e754f4f 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -320,10 +320,9 @@ class InitCommand extends TemplatesCommand { selectedOrg = organizations[0] } if (flags.yes || organizations.length === 1) { + if (flags.yes) { this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`) } - await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id, flags.yes) - return selectedOrg } async selectOrCreateConsoleProject (consoleCLI, org, flags) { From d37a99b541e4c1600c6b637361142b5c4b02309c Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Wed, 29 Apr 2026 12:19:00 -0700 Subject: [PATCH 9/9] fix: copilot introduced an error --- src/commands/app/init.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index 3e754f4f..ea0f16a5 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -318,11 +318,10 @@ class InitCommand extends TemplatesCommand { } else { // Single org, or --yes with no --org: auto-select the first (and likely only) org. selectedOrg = organizations[0] - } - if (flags.yes || organizations.length === 1) { - if (flags.yes) { this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`) } + await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id, flags.yes) + return selectedOrg } async selectOrCreateConsoleProject (consoleCLI, org, flags) {