Skip to content

Commit c0bf8e0

Browse files
enable agentic workflows (#912)
* when --yes + --org, find the org by id/code or error * validate --org regardless of how many orgs exist * generate simple names app1 app2 * Changed title/description to be friendly and clean * if we cannot find a name after 10000 tries, just give up * Update src/commands/app/init.js
1 parent a1a985c commit c0bf8e0

2 files changed

Lines changed: 271 additions & 18 deletions

File tree

src/commands/app/init.js

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ class InitCommand extends TemplatesCommand {
192192
this.error(`Extension(s) '${notFound.join(', ')}' not found in the Template Registry.`)
193193
}
194194
return extensionTemplates.map(t => t.name)
195+
} else if (flags.yes) {
196+
// with --yes and no explicit template, default to standalone app (no prompts)
197+
return []
195198
} else if (!flags['standalone-app']) {
196199
const noLogin = flags.import || !flags.login
197200
let [searchCriteria, orderByCriteria] = await this.getSearchCriteria(orgSupportedServices)
@@ -210,9 +213,12 @@ class InitCommand extends TemplatesCommand {
210213
}
211214
}
212215

213-
async ensureDevTermAccepted (consoleCLI, orgId) {
216+
async ensureDevTermAccepted (consoleCLI, orgId, skipPrompts = false) {
214217
const isTermAccepted = await consoleCLI.checkDevTermsForOrg(orgId)
215218
if (!isTermAccepted) {
219+
if (skipPrompts) {
220+
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.')
221+
}
216222
const terms = await consoleCLI.getDevTermsForOrg()
217223
const confirmDevTerms = await consoleCLI.prompt.promptConfirm(`${terms.text}
218224
\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 {
294300

295301
async selectConsoleOrg (consoleCLI, flags) {
296302
const organizations = await consoleCLI.getOrganizations()
297-
const selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org })
298-
await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id)
303+
if (!organizations || organizations.length === 0) {
304+
this.error('No organizations found for the logged-in user')
305+
}
306+
// If --org was supplied, validate it against the full list regardless of how many orgs
307+
// exist. This prevents silent mismatches when there is only one org but the caller
308+
// passed a wrong id or code.
309+
let selectedOrg
310+
if (flags.org) {
311+
selectedOrg = organizations.find(o => o.id === flags.org || o.code === flags.org)
312+
if (!selectedOrg) {
313+
this.error(`--org ${flags.org} not found`)
314+
}
315+
} else if (organizations.length > 1 && !flags.yes) {
316+
// Multiple orgs and no --org: prompt interactively (only when not in --yes mode).
317+
selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, {})
318+
} else {
319+
// Single org, or --yes with no --org: auto-select the first (and likely only) org.
320+
selectedOrg = organizations[0]
321+
this.log(`Auto-selecting organization: '${selectedOrg.name || selectedOrg.id}'`)
322+
}
323+
await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id, flags.yes)
299324
return selectedOrg
300325
}
301326

302327
async selectOrCreateConsoleProject (consoleCLI, org, flags) {
328+
// Fetch all projects in the org upfront. This list is used both for uniqueness
329+
// checks (--yes path) and for the interactive selection prompt (non-yes path).
303330
const projects = await consoleCLI.getProjects(org.id)
331+
332+
if (flags.yes) {
333+
// Non-interactive path: no prompts are shown. Behavior depends on whether
334+
// --project was explicitly supplied by the caller.
335+
336+
if (flags.project) {
337+
// --project was supplied. Try to find it in the existing list by id or name.
338+
// Matching by id supports callers who pass a project id rather than a name.
339+
const existing = projects.find(p => p.id === flags.project || p.name === flags.project)
340+
if (existing) {
341+
// Project already exists — return it as-is. isNew is intentionally NOT set
342+
// so downstream code knows not to treat this as a newly created project.
343+
this.log(`Using existing project: '${existing.name}'`)
344+
return existing
345+
}
346+
// Project does not exist — create it using the caller-supplied name directly.
347+
// title and description are derived from the name since no other info is available.
348+
this.log(`Project '${flags.project}' not found, creating it`)
349+
const project = await consoleCLI.createProject(org.id, {
350+
name: flags.project,
351+
title: flags.project,
352+
description: `App Builder Project ${flags.project} - generated by an agent`
353+
})
354+
project.isNew = true
355+
return project
356+
}
357+
358+
// No --project supplied. Auto-generate a unique name of the form "App{N}"
359+
// where N is the lowest positive integer not already used by an existing project.
360+
// This mimics sequential behaviour (App1, App2, ...) and fills
361+
// gaps left by deleted projects (e.g. if App2 was deleted, it is reused
362+
// before App4 is attempted).
363+
const existingNames = new Set(projects.map(p => p.name))
364+
const MAX_SUFFIX = 10000
365+
let suffix = 1
366+
while (existingNames.has(`App${suffix}`)) {
367+
suffix++
368+
if (suffix > MAX_SUFFIX) {
369+
this.error(`Could not find an available generated App name after ${MAX_SUFFIX} attempts`)
370+
}
371+
}
372+
373+
const generatedName = `App${suffix}`
374+
const generatedTitle = `App Builder Project ${suffix}`
375+
const generatedDescription = `App Builder Project ${suffix} - generated`
376+
377+
this.log(`Auto-generating project name: '${generatedName}'`)
378+
const project = await consoleCLI.createProject(org.id, {
379+
name: generatedName,
380+
title: generatedTitle,
381+
description: generatedDescription
382+
})
383+
project.isNew = true
384+
return project
385+
}
386+
387+
// Interactive path: prompt the user to select an existing project or create a new one.
388+
// If --project was supplied it is used to pre-populate the selection (by id or name)
389+
// but the prompt is still shown so the user can confirm or change it.
304390
let project = await consoleCLI.promptForSelectProject(
305391
projects,
306392
{ projectId: flags.project, projectName: flags.project },
307393
{ allowCreate: true }
308394
)
309395
if (!project) {
396+
// promptForSelectProject returns null when the user selects "Create new project" or
397+
// escapes the prompt. If --project was explicitly provided but not found/selected,
398+
// always error — never silently create a different project.
310399
if (flags.project) {
311400
this.error(`--project ${flags.project} not found`)
401+
} else {
402+
// User chose to create a new project — collect details interactively and create it.
403+
const projectDetails = await consoleCLI.promptForCreateProjectDetails()
404+
project = await consoleCLI.createProject(org.id, projectDetails)
405+
project.isNew = true
312406
}
313-
// user has escaped project selection prompt, let's create a new one
314-
const projectDetails = await consoleCLI.promptForCreateProjectDetails()
315-
project = await consoleCLI.createProject(org.id, projectDetails)
316-
project.isNew = true
317407
}
318408
return project
319409
}
@@ -324,7 +414,7 @@ class InitCommand extends TemplatesCommand {
324414
const workspaces = await consoleCLI.getWorkspaces(org.id, project.id)
325415
let workspace = workspaces.find(w => w.name.toLowerCase() === workspaceName.toLowerCase())
326416
if (!workspace) {
327-
if (flags['confirm-new-workspace']) {
417+
if (!flags.yes && flags['confirm-new-workspace']) {
328418
const shouldNewWorkspace = await consoleCLI.prompt.promptConfirm(`Workspace '${workspaceName}' does not exist \n > Do you wish to create a new workspace?`)
329419
if (!shouldNewWorkspace) {
330420
this.error(`Workspace '${workspaceName}' does not exist and creation aborted`)

test/commands/app/init.test.js

Lines changed: 173 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,11 @@ beforeEach(() => {
158158

159159
resetMockConsoleCLI()
160160
mockConsoleCLIInstance.promptForSelectOrganization.mockResolvedValue({ id: 'my-org' })
161+
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'my-org' }, { id: 'other-org' }])
161162
mockConsoleCLIInstance.getDevTermsForOrg.mockResolvedValue({ text: 'These are the Dev Terms.' })
162163
mockConsoleCLIInstance.checkDevTermsForOrg.mockResolvedValue(true)
163164
mockConsoleCLIInstance.createProject.mockResolvedValue({})
165+
mockConsoleCLIInstance.getProjects.mockResolvedValue([])
164166
mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([{ name: 'Stage' }, { name: 'Production' }])
165167
mockConsoleCLIInstance.getWorkspaceConfig.mockResolvedValue({
166168
project: {
@@ -421,19 +423,17 @@ describe('--no-login', () => {
421423
expect(importHelperLib.importConfigJson).not.toHaveBeenCalled()
422424
})
423425

424-
test('--yes --no-install, select excshell', async () => {
425-
const installOptions = {
426-
useDefaultValues: true,
427-
installNpm: false,
428-
installConfig: false,
429-
templates: ['@adobe/my-extension']
430-
}
431-
command.selectTemplates.mockResolvedValue(['@adobe/my-extension'])
432-
426+
test('--yes --no-install without --template creates standalone app', async () => {
433427
command.argv = ['--no-login', '--yes', '--no-install']
434428
await command.run()
435429

436-
expect(command.installTemplates).toHaveBeenCalledWith(installOptions)
430+
expect(command.installTemplates).toHaveBeenCalledWith({
431+
useDefaultValues: true,
432+
installNpm: false,
433+
installConfig: false,
434+
templates: []
435+
})
436+
expect(command.selectTemplates).not.toHaveBeenCalled()
437437
expect(LibConsoleCLI.init).not.toHaveBeenCalled()
438438
expect(importHelperLib.importConfigJson).not.toHaveBeenCalled()
439439
})
@@ -544,6 +544,103 @@ describe('--login', () => {
544544
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
545545
})
546546

547+
test('--yes --project name45 selects existing project without creating', async () => {
548+
mockConsoleCLIInstance.getProjects.mockResolvedValue([{ id: 'proj45id', name: 'name45' }])
549+
550+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension', '--project', 'name45']
551+
await command.run()
552+
553+
expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled()
554+
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
555+
})
556+
557+
test('--yes --project name45 creates project when it does not exist', async () => {
558+
mockConsoleCLIInstance.getProjects.mockResolvedValue([{ id: 'other', name: 'otherProject' }])
559+
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newid', name: 'name45' })
560+
561+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension', '--project', 'name45']
562+
await command.run()
563+
564+
expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
565+
expect.anything(),
566+
{ name: 'name45', title: 'name45', description: 'App Builder Project name45 - generated by an agent' }
567+
)
568+
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
569+
})
570+
571+
test('--yes with no existing projects uses App1', async () => {
572+
mockConsoleCLIInstance.getProjects.mockResolvedValue([])
573+
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App1' })
574+
575+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
576+
await command.run()
577+
578+
expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
579+
expect.anything(),
580+
{ name: 'App1', title: 'App Builder Project 1', description: 'App Builder Project 1 - generated' }
581+
)
582+
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
583+
})
584+
585+
test('--yes skips existing names and picks next available suffix', async () => {
586+
mockConsoleCLIInstance.getProjects.mockResolvedValue([
587+
{ name: 'App1' },
588+
{ name: 'App2' },
589+
{ name: 'App3' }
590+
])
591+
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App4' })
592+
593+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
594+
await command.run()
595+
596+
expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
597+
expect.anything(),
598+
{ name: 'App4', title: 'App Builder Project 4', description: 'App Builder Project 4 - generated' }
599+
)
600+
})
601+
602+
test('--yes skips non-sequential gaps and picks first available', async () => {
603+
mockConsoleCLIInstance.getProjects.mockResolvedValue([
604+
{ name: 'App1' },
605+
{ name: 'App3' }
606+
])
607+
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App2' })
608+
609+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
610+
await command.run()
611+
612+
expect(mockConsoleCLIInstance.createProject).toHaveBeenCalledWith(
613+
expect.anything(),
614+
{ name: 'App2', title: 'App Builder Project 2', description: 'App Builder Project 2 - generated' }
615+
)
616+
})
617+
618+
test('--yes errors when all App{N} names up to MAX_SUFFIX are taken', async () => {
619+
const MAX_SUFFIX = 10000
620+
const allTaken = Array.from({ length: MAX_SUFFIX }, (_, i) => ({ name: `App${i + 1}` }))
621+
mockConsoleCLIInstance.getProjects.mockResolvedValue(allTaken)
622+
623+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
624+
await expect(command.run()).rejects.toThrow(`Could not find an available generated App name after ${MAX_SUFFIX} attempts`)
625+
expect(mockConsoleCLIInstance.createProject).not.toHaveBeenCalled()
626+
})
627+
628+
test('--yes with missing workspace auto-creates without confirm prompt', async () => {
629+
mockConsoleCLIInstance.createProject.mockResolvedValue({ id: 'newprojid', name: 'App1' })
630+
mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([{ name: 'Stage' }, { name: 'Production' }])
631+
mockConsoleCLIInstance.createWorkspace.mockResolvedValue({ id: 'newwsid', name: 'CustomWs' })
632+
633+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension', '-w', 'CustomWs']
634+
await command.run()
635+
636+
expect(mockConsoleCLIInstance.prompt.promptConfirm).not.toHaveBeenCalled()
637+
expect(mockConsoleCLIInstance.createWorkspace).toHaveBeenCalledWith(
638+
expect.anything(),
639+
expect.anything(),
640+
expect.objectContaining({ name: 'CustomWs' })
641+
)
642+
})
643+
547644
test('--import fakeconfig.json', async () => {
548645
importHelperLib.loadAndValidateConfigFile.mockReturnValue({ values: fakeConfig })
549646
importHelperLib.getServiceApiKey.mockReturnValue('fakeclientid')
@@ -787,6 +884,65 @@ describe('no args', () => {
787884
})
788885
})
789886

887+
describe('selectConsoleOrg', () => {
888+
test('no organizations returned', async () => {
889+
mockConsoleCLIInstance.getOrganizations.mockResolvedValue(null)
890+
await expect(command.run()).rejects.toThrow('No organizations found for the logged-in user')
891+
})
892+
893+
test('empty organizations list', async () => {
894+
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([])
895+
await expect(command.run()).rejects.toThrow('No organizations found for the logged-in user')
896+
})
897+
898+
test('single org is auto-selected without prompt', async () => {
899+
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'my-org', name: 'My Org' }])
900+
command.argv = ['--standalone-app']
901+
await command.run()
902+
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
903+
expect(LibConsoleCLI.init).toHaveBeenCalled()
904+
})
905+
906+
test('--yes with multiple orgs auto-selects first org without prompt', async () => {
907+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
908+
await command.run()
909+
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
910+
expect(importHelperLib.importConfigJson).toHaveBeenCalled()
911+
})
912+
913+
test('--yes --org selects matching org by id without prompt', async () => {
914+
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'org-a' }, { id: 'org-b' }])
915+
command.argv = ['--yes', '--org', 'org-b', '--no-install', '--template', '@adobe/my-extension']
916+
await command.run()
917+
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
918+
expect(mockConsoleCLIInstance.getEnabledServicesForOrg).toHaveBeenCalledWith('org-b')
919+
})
920+
921+
test('--yes --org throws when org not found', async () => {
922+
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'org-a' }, { id: 'org-b' }])
923+
command.argv = ['--yes', '--org', 'non-existent-org', '--no-install', '--template', '@adobe/my-extension']
924+
await expect(command.run()).rejects.toThrow('--org non-existent-org not found')
925+
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
926+
})
927+
928+
test('--org throws when mismatched against the only org (single org)', async () => {
929+
mockConsoleCLIInstance.getOrganizations.mockResolvedValue([{ id: 'only-org' }])
930+
command.argv = ['--yes', '--org', 'wrong-org', '--no-install', '--template', '@adobe/my-extension']
931+
await expect(command.run()).rejects.toThrow('--org wrong-org not found')
932+
expect(mockConsoleCLIInstance.promptForSelectOrganization).not.toHaveBeenCalled()
933+
})
934+
})
935+
936+
describe('ensureDevTermAccepted', () => {
937+
test('uses skipPrompts=false by default (terms already accepted)', async () => {
938+
mockConsoleCLIInstance.checkDevTermsForOrg.mockResolvedValue(true)
939+
// Call directly without the third argument to exercise the default parameter
940+
await command.ensureDevTermAccepted(mockConsoleCLIInstance, 'org-id')
941+
expect(mockConsoleCLIInstance.checkDevTermsForOrg).toHaveBeenCalledWith('org-id')
942+
expect(mockConsoleCLIInstance.prompt.promptConfirm).not.toHaveBeenCalled()
943+
})
944+
})
945+
790946
describe('dev terms', () => {
791947
test('not accepted', async () => {
792948
mockConsoleCLIInstance.checkDevTermsForOrg.mockResolvedValue(false)
@@ -814,6 +970,13 @@ describe('dev terms', () => {
814970

815971
await expect(command.run()).rejects.toThrow('The Developer Terms of Service could not be accepted')
816972
})
973+
974+
test('--yes errors without prompting when terms not accepted', async () => {
975+
mockConsoleCLIInstance.checkDevTermsForOrg.mockResolvedValue(false)
976+
command.argv = ['--yes', '--no-install', '--template', '@adobe/my-extension']
977+
await expect(command.run()).rejects.toThrow('Developer Terms of Service have not been accepted')
978+
expect(mockConsoleCLIInstance.prompt.promptConfirm).not.toHaveBeenCalled()
979+
})
817980
})
818981

819982
describe('template-options', () => {

0 commit comments

Comments
 (0)