From a0ea4afbcf424197fdde2df5b28e63c7f42cbf22 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 14 May 2026 22:52:53 -0400 Subject: [PATCH 1/3] feat: support dev orgs with runtime, inaddition to entp --- lib/index.js | 75 ++++++++++++++++++++++++++++++- lib/pure-helpers.js | 8 ++-- test/data-mocks.js | 32 +++++++++++--- test/lib/index.test.js | 83 ++++++++++++++++++++++++++++++++++- test/lib/pure-helpers.test.js | 2 +- 5 files changed, 186 insertions(+), 14 deletions(-) diff --git a/lib/index.js b/lib/index.js index 5efff18..0128fa7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -38,6 +38,13 @@ const getSpinner = () => { const PROJECT_TYPE = 'jaeger' const CERT_VALID_DAYS = 365 +// Org filtering. Enterprise orgs (`entp`) can always be used by App Builder +// flows. Developer-type orgs (e.g. App Builder trial orgs) are only usable +// when the `RUNTIME` feature flag is enabled on the org. +const ORG_TYPE_ENTERPRISE = 'entp' +const ORG_TYPE_DEVELOPER = 'developer' +const ORG_FEATURE_RUNTIME = 'RUNTIME' + /** * @typedef {object} ConsoleCredentials * @property {string} accessToken access token entitled to access the Console Transporter API @@ -77,9 +84,73 @@ class LibConsoleCLI { async getOrganizations () { getSpinner().start('Getting Organizations...') const organizations = (await this.sdkClient.getOrganizations()).body - getSpinner().stop() logger.debug(`Get Organizations response: ${JSON.stringify(organizations, null, 2)}`) - return organizations + const selectable = await this.filterToSelectableOrgs(organizations) + getSpinner().stop() + logger.debug(`Selectable Organizations: ${JSON.stringify(selectable, null, 2)}`) + return selectable + } + + /** + * Filter an org list to only those usable by App Builder / Console + * flows (entp and developer orgs with runtime) + * + * If the feature lookup fails for a dev org we conservatively keep + * the org in the list so a transient outage doesn't block trial users + * + * @private + * @param {Array} organizations raw org list from the Console API + * @returns {Promise>} selectable orgs preserving input order + */ + async filterToSelectableOrgs (organizations) { + if (!Array.isArray(organizations) || organizations.length === 0) { + return organizations || [] + } + const checks = await Promise.all(organizations.map(async org => { + if (org.type === ORG_TYPE_ENTERPRISE) { + return true + } + if (org.type === ORG_TYPE_DEVELOPER) { + return this.hasOrgFeature(org.id, ORG_FEATURE_RUNTIME, { defaultOnError: true }) + } + return false + })) + return organizations.filter((_, i) => checks[i]) + } + + /** + * Fetch the feature flags enabled on an org. + * + * @param {string} orgId Organization AMS ID + * @returns {Promise>} the feature list, or `[]` on error + */ + async getOrganizationFeatures (orgId) { + try { + return (await this.sdkClient.getOrganizationFeatures(orgId)).body || [] + } catch (err) { + logger.debug(`getOrganizationFeatures failed for ${orgId}: ${err}`) + return [] + } + } + + /** + * Check whether an org has a given feature flag enabled. + * + * @param {string} orgId Organization AMS ID + * @param {string} featureName Feature flag name (e.g. `RUNTIME`) + * @param {object} [options] options bag + * @param {boolean} [options.defaultOnError] value to return when the + * feature lookup itself fails (defaults to `false`) + * @returns {Promise} true when the feature is enabled + */ + async hasOrgFeature (orgId, featureName, options = {}) { + try { + const features = (await this.sdkClient.getOrganizationFeatures(orgId)).body || [] + return features.some(f => f && f.name === featureName) + } catch (err) { + logger.debug(`hasOrgFeature(${orgId}, ${featureName}) failed: ${err}`) + return options.defaultOnError === true + } } async promptForSelectOrganization (organizations, data = { orgId: undefined, orgCode: undefined }) { diff --git a/lib/pure-helpers.js b/lib/pure-helpers.js index 4cb82b7..f779224 100644 --- a/lib/pure-helpers.js +++ b/lib/pure-helpers.js @@ -23,10 +23,10 @@ const logger = require('@adobe/aio-lib-core-logging')( /** @private */ function orgsToPromptChoices (orgs) { - return orgs - // we only support entp orgs for now - .filter(item => item.type === 'entp') - .map(item => ({ name: item.name, value: item })) + // Type-based filtering is intentionally not done here: the list is already + // filtered by `LibConsoleCLI.getOrganizations` to the orgs that can be used + // (entp + developer orgs with the Runtime feature) + return orgs.map(item => ({ name: item.name, value: item })) } /** @private */ diff --git a/test/data-mocks.js b/test/data-mocks.js index bac8589..449d0bf 100644 --- a/test/data-mocks.js +++ b/test/data-mocks.js @@ -41,9 +41,29 @@ const organizations = [ type: 'entp', roles: [], // no need to mock roles for now role: 'DEVELOPER' + }, + { + id: '67891', + code: '33333333333MMMMMMMMMDDDD@AdobeOrg', + name: 'Trial App Builder org', + description: 'developer-type org with the Runtime feature', + type: 'developer', + roles: [], + role: 'DEVELOPER' } ] const org = organizations[0] +// mocked feature-flag map keyed by org id; only orgs of type 'developer' +// that need to appear as selectable should have RUNTIME. +const orgFeaturesById = { + 67891: [{ name: 'RUNTIME', description: 'OpenWhisk runtime' }] +} +// orgs the lib should expose to callers: entp ∪ developer-with-RUNTIME +const selectableOrganizations = [ + organizations[0], + organizations[2], + organizations[3] +] const projects = [{ name: 'myFirstProject', @@ -501,12 +521,12 @@ const subscribeServicesResponseOAuthServerToServer = { sdkList: integrationOAuthServerToServer.sdkList } -// expected prompt choices, based on data above and filters +// expected prompt choices, based on data above and filters. +// `orgsToPromptChoices` no longer filters by org type — that filtering now +// happens once in `LibConsoleCLI.getOrganizations` — so the prompt choices +// reflect every org in the input list. const promptChoices = { - orgs: [ - { name: organizations[0].name, value: organizations[0] }, - { name: organizations[2].name, value: organizations[2] } - ], + orgs: organizations.map(o => ({ name: o.name, value: o })), projects: [ { name: projects[4].title, value: projects[4] }, { name: projects[1].title, value: projects[1] } @@ -647,6 +667,8 @@ const applicationExtensions = { module.exports = { organizations, + selectableOrganizations, + orgFeaturesById, projects, workspaces, org, diff --git a/test/lib/index.test.js b/test/lib/index.test.js index ad38c63..862852f 100644 --- a/test/lib/index.test.js +++ b/test/lib/index.test.js @@ -18,6 +18,7 @@ jest.mock('@adobe/aio-lib-console') const consoleSDK = require('@adobe/aio-lib-console') const mockConsoleSDKInstance = { getOrganizations: jest.fn(), + getOrganizationFeatures: jest.fn(), getProjectsForOrg: jest.fn(), getWorkspacesForProject: jest.fn(), getServicesForOrg: jest.fn(), @@ -56,6 +57,9 @@ function resetMockConsoleSDK () { /** @private */ function setDefaultMockConsoleSdk () { mockConsoleSDKInstance.getOrganizations.mockResolvedValue({ body: dataMocks.organizations }) + mockConsoleSDKInstance.getOrganizationFeatures.mockImplementation(orgId => Promise.resolve({ + body: dataMocks.orgFeaturesById[orgId] || [] + })) mockConsoleSDKInstance.getProjectsForOrg.mockResolvedValue({ body: dataMocks.projects }) mockConsoleSDKInstance.getWorkspacesForProject.mockResolvedValue({ body: dataMocks.workspaces }) mockConsoleSDKInstance.getServicesForOrg.mockResolvedValue({ body: dataMocks.services }) @@ -189,6 +193,9 @@ test('instance methods definitions', async () => { expect(typeof consoleCli.getFirstEntpCredentials).toBe('function') expect(typeof consoleCli.getFirstOAuthServerToServerCredentials).toBe('function') expect(typeof consoleCli.getOrganizations).toBe('function') + expect(typeof consoleCli.getOrganizationFeatures).toBe('function') + expect(typeof consoleCli.hasOrgFeature).toBe('function') + expect(typeof consoleCli.filterToSelectableOrgs).toBe('function') expect(typeof consoleCli.getProjects).toBe('function') expect(typeof consoleCli.getProject).toBe('function') expect(typeof consoleCli.getApplicationExtensions).toBe('function') @@ -227,14 +234,86 @@ describe('instance methods tests', () => { consoleCli = await LibConsoleCli.init(consoleCredentials) }) - test('getOrganizations', async () => { + test('getOrganizations returns entp + developer-with-RUNTIME orgs', async () => { const organizations = await consoleCli.getOrganizations() - expect(organizations).toEqual(dataMocks.organizations) + expect(organizations).toEqual(dataMocks.selectableOrganizations) expect(mockConsoleSDKInstance.getOrganizations).toHaveBeenCalled() + // features endpoint should only be hit for non-entp orgs + expect(mockConsoleSDKInstance.getOrganizationFeatures).toHaveBeenCalledWith('55555') + expect(mockConsoleSDKInstance.getOrganizationFeatures).toHaveBeenCalledWith('67891') + expect(mockConsoleSDKInstance.getOrganizationFeatures).not.toHaveBeenCalledWith('12345') + expect(mockConsoleSDKInstance.getOrganizationFeatures).not.toHaveBeenCalledWith('67890') expect(mockOraObject.start).toHaveBeenCalled() expect(mockOraObject.stop).toHaveBeenCalled() }) + test('getOrganizations keeps developer orgs when the feature lookup throws (fail-open)', async () => { + mockConsoleSDKInstance.getOrganizationFeatures.mockImplementation(orgId => { + if (orgId === '55555') { + return Promise.reject(new Error('features endpoint down')) + } + return Promise.resolve({ body: dataMocks.orgFeaturesById[orgId] || [] }) + }) + const organizations = await consoleCli.getOrganizations() + // 55555 is conservatively included because we couldn't determine feature state + expect(organizations.map(o => o.id)).toEqual(['12345', '55555', '67890', '67891']) + }) + + test('getOrganizations excludes developer orgs without RUNTIME', async () => { + mockConsoleSDKInstance.getOrganizationFeatures.mockResolvedValue({ body: [] }) + const organizations = await consoleCli.getOrganizations() + expect(organizations.map(o => o.id)).toEqual(['12345', '67890']) + }) + + test('getOrganizations excludes orgs whose type is neither entp nor developer', async () => { + mockConsoleSDKInstance.getOrganizations.mockResolvedValue({ + body: [ + { id: '12345', type: 'entp' }, + { id: 'something-else', type: 'other-org-type' } + ] + }) + const organizations = await consoleCli.getOrganizations() + expect(organizations.map(o => o.id)).toEqual(['12345']) + }) + + test('getOrganizations passes through empty / falsy raw responses', async () => { + mockConsoleSDKInstance.getOrganizations.mockResolvedValue({ body: [] }) + await expect(consoleCli.getOrganizations()).resolves.toEqual([]) + mockConsoleSDKInstance.getOrganizations.mockResolvedValue({ body: null }) + await expect(consoleCli.getOrganizations()).resolves.toEqual([]) + }) + + test('getOrganizationFeatures returns the body on success', async () => { + await expect(consoleCli.getOrganizationFeatures('67891')) + .resolves.toEqual(dataMocks.orgFeaturesById['67891']) + }) + + test('getOrganizationFeatures returns [] when the SDK throws', async () => { + mockConsoleSDKInstance.getOrganizationFeatures.mockRejectedValue(new Error('boom')) + await expect(consoleCli.getOrganizationFeatures('67891')).resolves.toEqual([]) + }) + + test('getOrganizationFeatures returns [] when the SDK response has no body', async () => { + mockConsoleSDKInstance.getOrganizationFeatures.mockResolvedValue({ body: null }) + await expect(consoleCli.getOrganizationFeatures('67891')).resolves.toEqual([]) + }) + + test('hasOrgFeature reflects RUNTIME presence', async () => { + await expect(consoleCli.hasOrgFeature('67891', 'RUNTIME')).resolves.toBe(true) + await expect(consoleCli.hasOrgFeature('55555', 'RUNTIME')).resolves.toBe(false) + }) + + test('hasOrgFeature returns false when the SDK response has no body', async () => { + mockConsoleSDKInstance.getOrganizationFeatures.mockResolvedValue({ body: null }) + await expect(consoleCli.hasOrgFeature('67891', 'RUNTIME')).resolves.toBe(false) + }) + + test('hasOrgFeature returns defaultOnError when the SDK throws', async () => { + mockConsoleSDKInstance.getOrganizationFeatures.mockRejectedValue(new Error('boom')) + await expect(consoleCli.hasOrgFeature('67891', 'RUNTIME')).resolves.toBe(false) + await expect(consoleCli.hasOrgFeature('67891', 'RUNTIME', { defaultOnError: true })).resolves.toBe(true) + }) + test('getProjects', async () => { const projects = await consoleCli.getProjects('orgid') expect(projects).toEqual(dataMocks.projects) diff --git a/test/lib/pure-helpers.test.js b/test/lib/pure-helpers.test.js index ae11386..f8f4e81 100644 --- a/test/lib/pure-helpers.test.js +++ b/test/lib/pure-helpers.test.js @@ -35,7 +35,7 @@ test('exports', () => { }) describe('orgsToPromptChoices', () => { - test('with input that has entp and non entp orgs', () => { + test('returns prompt choices for every input org (no type filtering)', () => { expect(helpers.orgsToPromptChoices(dataMocks.organizations)) .toEqual(dataMocks.promptChoices.orgs) }) From 97001e52e7cf0e114c794d662f60bd9bc27286b8 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 14 May 2026 23:06:39 -0400 Subject: [PATCH 2/3] fix: lint --- lib/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index 0128fa7..51fb96d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -92,7 +92,7 @@ class LibConsoleCLI { } /** - * Filter an org list to only those usable by App Builder / Console + * Filter an org list to only those usable by App Builder / Console * flows (entp and developer orgs with runtime) * * If the feature lookup fails for a dev org we conservatively keep From 6dc07ffee415f549961c906a99367a4acb0e9e6d Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 14 May 2026 23:10:46 -0400 Subject: [PATCH 3/3] fix: only normalize the orgs body in one place --- lib/index.js | 19 +++++++++++-------- test/lib/index.test.js | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/index.js b/lib/index.js index 51fb96d..471130b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -121,21 +121,24 @@ class LibConsoleCLI { /** * Fetch the feature flags enabled on an org. * + * Lets SDK errors bubble (matching the rest of the lib) so callers can + * decide their own retry / fail-open strategy. Returns `[]` only when the + * SDK returns a successful response with no body. + * * @param {string} orgId Organization AMS ID - * @returns {Promise>} the feature list, or `[]` on error + * @returns {Promise>} the feature list */ async getOrganizationFeatures (orgId) { - try { - return (await this.sdkClient.getOrganizationFeatures(orgId)).body || [] - } catch (err) { - logger.debug(`getOrganizationFeatures failed for ${orgId}: ${err}`) - return [] - } + return (await this.sdkClient.getOrganizationFeatures(orgId)).body || [] } /** * Check whether an org has a given feature flag enabled. * + * Delegates to `getOrganizationFeatures` so the SDK call and body + * normalisation stay in one place. The catch here only handles the + * feature-lookup itself failing + * * @param {string} orgId Organization AMS ID * @param {string} featureName Feature flag name (e.g. `RUNTIME`) * @param {object} [options] options bag @@ -145,7 +148,7 @@ class LibConsoleCLI { */ async hasOrgFeature (orgId, featureName, options = {}) { try { - const features = (await this.sdkClient.getOrganizationFeatures(orgId)).body || [] + const features = await this.getOrganizationFeatures(orgId) return features.some(f => f && f.name === featureName) } catch (err) { logger.debug(`hasOrgFeature(${orgId}, ${featureName}) failed: ${err}`) diff --git a/test/lib/index.test.js b/test/lib/index.test.js index 862852f..eb06be2 100644 --- a/test/lib/index.test.js +++ b/test/lib/index.test.js @@ -288,9 +288,9 @@ describe('instance methods tests', () => { .resolves.toEqual(dataMocks.orgFeaturesById['67891']) }) - test('getOrganizationFeatures returns [] when the SDK throws', async () => { + test('getOrganizationFeatures lets SDK errors bubble', async () => { mockConsoleSDKInstance.getOrganizationFeatures.mockRejectedValue(new Error('boom')) - await expect(consoleCli.getOrganizationFeatures('67891')).resolves.toEqual([]) + await expect(consoleCli.getOrganizationFeatures('67891')).rejects.toThrow('boom') }) test('getOrganizationFeatures returns [] when the SDK response has no body', async () => {