From 9cd90ad712355a6b1a81bc7fc7ac5434785fe355 Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 09:14:58 -0700 Subject: [PATCH 01/10] fix: ACNA-4617 surface JIL subscription errors, match profiles by productId `aio console workspace api add` previously treated any HTTP 200 from the JIL subscribe endpoint as success and dumped the body through `--json`, including responses like `{ error: [...], errorDetails: [...] }` that indicate a partial or total failure. Scripts that check exit code saw a green run while the workspace was never actually subscribed. Detect a non-empty `error[]` or `errorDetails[]` and throw a CLI error instead, so `--json` exits non-zero and the JIL message (e.g. "Service FrameioAPISDK requires selection of a product") is visible to the user. Also extend `--license-config` profile matching to accept the licenseConfig `productId`, in addition to `id` and `name`. Users looking at the output of `aio console api list --json` reasonably try the `productId` they see under `properties.licenseConfigs[].productId`; matching by it is harmless when the (id, name, productId) tuple is unambiguous within a service and unblocks Frame.io-style services where each product has a single profile. --- README.md | 6 +- src/commands/console/workspace/api/add.js | 45 ++++++++++++-- .../console/workspace/api/add.test.js | 62 ++++++++++++++++++- 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cc1fe1a..4473991 100644 --- a/README.md +++ b/README.md @@ -610,7 +610,8 @@ FLAGS -y, --yml Output yml --help Show help --license-config=... Product profile(s) for a service, format: - '=[,...]'. Repeat for multiple services. + '=[,...]'. Repeat for + multiple services. --orgId= Organization id --projectName= (required) Name of the project containing the workspace --service-code= (required) Comma-separated list of API service codes to add (e.g. @@ -941,7 +942,8 @@ FLAGS -y, --yml Output yml --help Show help --license-config=... Product profile(s) for a service, format: - '=[,...]'. Repeat for multiple services. + '=[,...]'. Repeat for + multiple services. --orgId= Organization id --projectName= (required) Name of the project containing the workspace --service-code= (required) Comma-separated list of API service codes to add (e.g. diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index d578b23..7f22525 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -46,10 +46,15 @@ function parseLicenseConfigFlags (values) { /** * Match requested profile identifiers against a service's available - * licenseConfigs by either id or name. + * licenseConfigs by id, name, or productId. + * + * Matching by productId lets users pass the value they see in + * `properties.licenseConfigs[].productId` from `aio console api list`, + * which is convenient for services like Frame.io that expose a single + * profile per product. * * @param {Array<{id: string, name: string, productId: string}>} available - * @param {string[]} requested profile names or ids + * @param {string[]} requested profile names, ids, or productIds * @param {string} sdkCode service code for error messages * @returns {Array} selected licenseConfig objects */ @@ -57,7 +62,7 @@ function resolveLicenseConfigs (available, requested, sdkCode) { const selected = [] const notFound = [] for (const id of requested) { - const match = available.find(lc => lc.id === id || lc.name === id) + const match = available.find(lc => lc.id === id || lc.name === id || lc.productId === id) if (match) { selected.push(match) } else { @@ -74,6 +79,35 @@ function resolveLicenseConfigs (available, requested, sdkCode) { return selected } +/** + * Detect JIL subscription errors embedded in a 200 response and throw + * a CLI-friendly error if any are found. + * + * JIL returns `{ error: [...], errorDetails: [{ sdkCode, domain, code, message }...] }` + * for partial/total failures inside an otherwise successful HTTP response, + * so without this check `--json` output silently looks like success. + * + * @param {object} response the subscribe response body + */ +function assertSubscribeSuccess (response) { + if (!response || typeof response !== 'object') { + return + } + const errorDetails = Array.isArray(response.errorDetails) ? response.errorDetails : [] + const errorCodes = Array.isArray(response.error) ? response.error : [] + if (errorDetails.length === 0 && errorCodes.length === 0) { + return + } + const formatted = errorDetails.length > 0 + ? errorDetails.map(d => { + const where = d && d.sdkCode ? `${d.sdkCode}: ` : '' + const message = (d && d.message) || JSON.stringify(d) + return ` ${where}${message}` + }).join('\n') + : ` ${errorCodes.join(', ')}` + throw new Error(`Failed to add API service(s):\n${formatted}`) +} + class AddCommand extends ConsoleCommand { async run () { const { flags } = await this.parse(AddCommand) @@ -162,6 +196,8 @@ class AddCommand extends ConsoleCommand { credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL }) + assertSubscribeSuccess(result) + if (flags.json) { this.printJson(result) } else if (flags.yml) { @@ -200,7 +236,7 @@ AddCommand.flags = { required: true }), 'license-config': Flags.string({ - description: 'Product profile(s) for a service, format: \'=[,...]\'. Repeat for multiple services.', + description: 'Product profile(s) for a service, format: \'=[,...]\'. Repeat for multiple services.', multiple: true }), json: Flags.boolean({ @@ -222,3 +258,4 @@ AddCommand.aliases = [ module.exports = AddCommand module.exports.parseLicenseConfigFlags = parseLicenseConfigFlags module.exports.resolveLicenseConfigs = resolveLicenseConfigs +module.exports.assertSubscribeSuccess = assertSubscribeSuccess diff --git a/test/commands/console/workspace/api/add.test.js b/test/commands/console/workspace/api/add.test.js index 6802071..7045d54 100644 --- a/test/commands/console/workspace/api/add.test.js +++ b/test/commands/console/workspace/api/add.test.js @@ -56,7 +56,7 @@ jest.mock('@adobe/aio-cli-lib-console', () => ({ })) const TheCommand = require('../../../../../src/commands/console/workspace/api/add') -const { parseLicenseConfigFlags, resolveLicenseConfigs } = TheCommand +const { parseLicenseConfigFlags, resolveLicenseConfigs, assertSubscribeSuccess } = TheCommand describe('parseLicenseConfigFlags', () => { it('should parse a single sdkCode with one profile', () => { @@ -121,6 +121,13 @@ describe('resolveLicenseConfigs', () => { expect(resolveLicenseConfigs(available, ['ProfileB'], 'X')).toEqual([available[1]]) }) + it('should match a profile by productId', () => { + const single = [ + { id: '875473476', name: 'Default Frame.io Enterprise', productId: 'AA458DFE4F7020A0441A' } + ] + expect(resolveLicenseConfigs(single, ['AA458DFE4F7020A0441A'], 'FrameioAPISDK')).toEqual([single[0]]) + }) + it('should match multiple profiles mixing id and name', () => { expect(resolveLicenseConfigs(available, ['lc1', 'ProfileB'], 'X')).toEqual(available) }) @@ -131,6 +138,40 @@ describe('resolveLicenseConfigs', () => { }) }) +describe('assertSubscribeSuccess', () => { + it('should not throw on a normal success response', () => { + expect(() => assertSubscribeSuccess({ sdkList: ['AdobeAnalyticsSDK'] })).not.toThrow() + }) + + it('should not throw on null/undefined', () => { + expect(() => assertSubscribeSuccess(null)).not.toThrow() + expect(() => assertSubscribeSuccess(undefined)).not.toThrow() + }) + + it('should not throw on empty error arrays', () => { + expect(() => assertSubscribeSuccess({ sdkList: [], error: [], errorDetails: [] })).not.toThrow() + }) + + it('should surface the JIL "requires selection of a product" error', () => { + const jilErrorResponse = { + error: ['FrameioAPISDK'], + errorDetails: [{ + sdkCode: 'FrameioAPISDK', + domain: 'JIL', + code: 400, + message: 'Service FrameioAPISDK requires selection of a product' + }] + } + expect(() => assertSubscribeSuccess(jilErrorResponse)) + .toThrow(/Failed to add API service\(s\)[\s\S]*FrameioAPISDK: Service FrameioAPISDK requires selection of a product/) + }) + + it('should fall back to error[] when errorDetails is missing', () => { + expect(() => assertSubscribeSuccess({ error: ['SomeSDK'] })) + .toThrow(/Failed to add API service\(s\)[\s\S]*SomeSDK/) + }) +}) + describe('console:workspace:api:add', () => { let command @@ -351,6 +392,25 @@ describe('console:workspace:api:add', () => { await expect(command.run()).rejects.toThrow('SDK subscribe failed') }) + it('should surface JIL embedded errors as a CLI error', async () => { + mockConsoleCLIInstance.subscribeToServicesWithCredentialType.mockResolvedValue({ + error: ['AppBuilderDataServicesSDK'], + errorDetails: [{ + sdkCode: 'AppBuilderDataServicesSDK', + domain: 'JIL', + code: 400, + message: 'Service AppBuilderDataServicesSDK requires selection of a product' + }] + }) + command.argv = [ + '--service-code', 'AppBuilderDataServicesSDK', + '--projectName', 'myproject', + '--workspaceName', 'Stage', + '--orgId', '12345' + ] + await expect(command.run()).rejects.toThrow('requires selection of a product') + }) + it('should output JSON when --json is used', async () => { const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true) const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true) From 7ef15c42d2a2cf461f8532f9f548dc7dc90145d6 Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 12:11:02 -0700 Subject: [PATCH 02/10] fix: ACNA-4617 dedupe duplicate service records by sdkCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the Frame.io subscription failure: getEnabledServicesForOrg returns FrameioAPISDK twice in this org — once as type: 'adobeid' with no licenseConfigs, and once as type: 'entp' with the product profile metadata required for OAuth Server-to-Server. The previous Array.find by code returned whichever the API listed first (the adobeid record), so availableProfiles was null, --license-config was silently dropped, and JIL rejected the subscription with "requires selection of a product". Replace the find-by-code with pickServiceForCode, which prefers the entp record with populated licenseConfigs when a code appears more than once. This command always uses OAuth Server-to-Server credentials, so picking the entp record matches the credential type and aligns with how the existing interactive prompt filters services (servicesToPromptChoices in aio-cli-lib-console). Verified end-to-end against the reporter's org on fresh Stage and Production workspaces: Frame.io now subscribes cleanly and the resulting OAuth Server-to-Server credential carries the frame.s2s.all scope. --- src/commands/console/workspace/api/add.js | 36 +++++++++- .../console/workspace/api/add.test.js | 70 ++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index 7f22525..9c92acc 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -79,6 +79,39 @@ function resolveLicenseConfigs (available, requested, sdkCode) { return selected } +/** + * Pick the best service record to subscribe when multiple records share + * the same sdkCode. + * + * Some services (notably Frame.io) appear twice in `getEnabledServicesForOrg`: + * once as `type: 'adobeid'` with no licenseConfigs (browser/SPA flow) and + * once as `type: 'entp'` with the product profile metadata required for + * OAuth Server-to-Server. `Array.find` returns whichever the API lists + * first, which silently drops `--license-config` when the adobeid record + * wins and causes JIL to reject the subscription. Since this command + * always uses OAuth Server-to-Server credentials, prefer the `entp` record + * (and, within that, the one that actually carries licenseConfigs). + * + * @param {Array} services full enabled-services list + * @param {string} code sdkCode to look up + * @returns {object|undefined} the chosen service record, or undefined + */ +function pickServiceForCode (services, code) { + const matches = services.filter(s => s.code === code) + if (matches.length === 0) { + return undefined + } + const entpWithProfiles = matches.find(s => s.type === 'entp' && s.properties && Array.isArray(s.properties.licenseConfigs) && s.properties.licenseConfigs.length > 0) + if (entpWithProfiles) { + return entpWithProfiles + } + const entp = matches.find(s => s.type === 'entp') + if (entp) { + return entp + } + return matches[0] +} + /** * Detect JIL subscription errors embedded in a 200 response and throw * a CLI-friendly error if any are found. @@ -148,7 +181,7 @@ class AddCommand extends ConsoleCommand { const notFound = [] const missingProfiles = [] for (const code of requestedCodes) { - const service = enabledServices.find(s => s.code === code) + const service = pickServiceForCode(enabledServices, code) if (!service) { notFound.push(code) continue @@ -259,3 +292,4 @@ module.exports = AddCommand module.exports.parseLicenseConfigFlags = parseLicenseConfigFlags module.exports.resolveLicenseConfigs = resolveLicenseConfigs module.exports.assertSubscribeSuccess = assertSubscribeSuccess +module.exports.pickServiceForCode = pickServiceForCode diff --git a/test/commands/console/workspace/api/add.test.js b/test/commands/console/workspace/api/add.test.js index 7045d54..e8ee206 100644 --- a/test/commands/console/workspace/api/add.test.js +++ b/test/commands/console/workspace/api/add.test.js @@ -56,7 +56,7 @@ jest.mock('@adobe/aio-cli-lib-console', () => ({ })) const TheCommand = require('../../../../../src/commands/console/workspace/api/add') -const { parseLicenseConfigFlags, resolveLicenseConfigs, assertSubscribeSuccess } = TheCommand +const { parseLicenseConfigFlags, resolveLicenseConfigs, assertSubscribeSuccess, pickServiceForCode } = TheCommand describe('parseLicenseConfigFlags', () => { it('should parse a single sdkCode with one profile', () => { @@ -138,6 +138,40 @@ describe('resolveLicenseConfigs', () => { }) }) +describe('pickServiceForCode', () => { + it('should return undefined when no service matches the code', () => { + expect(pickServiceForCode([{ code: 'A', type: 'entp' }], 'B')).toBeUndefined() + }) + + it('should return the single match when there is no duplicate', () => { + const s = { code: 'A', type: 'entp', properties: { licenseConfigs: [{ id: 'lc1' }] } } + expect(pickServiceForCode([s], 'A')).toBe(s) + }) + + it('should prefer the entp record with populated licenseConfigs when a code appears twice', () => { + // Repro of the Frame.io case in getEnabledServicesForOrg + const adobeid = { code: 'FrameioAPISDK', type: 'adobeid', properties: null } + const entp = { + code: 'FrameioAPISDK', + type: 'entp', + properties: { licenseConfigs: [{ id: '875473476', productId: 'AA458DFE4F7020A0441A' }] } + } + expect(pickServiceForCode([adobeid, entp], 'FrameioAPISDK')).toBe(entp) + }) + + it('should fall back to an entp record without profiles before an adobeid record', () => { + const adobeid = { code: 'X', type: 'adobeid', properties: null } + const entp = { code: 'X', type: 'entp', properties: null } + expect(pickServiceForCode([adobeid, entp], 'X')).toBe(entp) + }) + + it('should fall back to the first match when no entp record exists', () => { + const a = { code: 'Y', type: 'adobeid' } + const b = { code: 'Y', type: 'adobeid' } + expect(pickServiceForCode([a, b], 'Y')).toBe(a) + }) +}) + describe('assertSubscribeSuccess', () => { it('should not throw on a normal success response', () => { expect(() => assertSubscribeSuccess({ sdkList: ['AdobeAnalyticsSDK'] })).not.toThrow() @@ -392,6 +426,40 @@ describe('console:workspace:api:add', () => { await expect(command.run()).rejects.toThrow('SDK subscribe failed') }) + it('should prefer the entp duplicate service record when adobeid is listed first', async () => { + // Repro of the Frame.io shape in getEnabledServicesForOrg: same sdkCode + // appears once as adobeid (no licenseConfigs) and once as entp (with + // licenseConfigs). The pre-dedup code subscribed against the adobeid + // record and dropped --license-config silently. + mockConsoleCLIInstance.getEnabledServicesForOrg.mockResolvedValue([ + { name: 'Frame.io API', code: 'FrameioAPISDK', type: 'adobeid', properties: null }, + { + name: 'Frame.io API', + code: 'FrameioAPISDK', + type: 'entp', + properties: { + roles: [{ id: 1102, code: 'frame.s2s.all', name: null }], + licenseConfigs: [{ id: '875473476', name: 'Default Frame.io Enterprise', productId: 'AA458DFE4F7020A0441A' }] + } + } + ]) + command.argv = [ + '--service-code', 'FrameioAPISDK', + '--projectName', 'myproject', + '--workspaceName', 'Stage', + '--orgId', '12345', + '--license-config', 'FrameioAPISDK=875473476' + ] + await command.run() + + const call = mockConsoleCLIInstance.subscribeToServicesWithCredentialType.mock.calls[0][0] + expect(call.serviceProperties).toHaveLength(1) + expect(call.serviceProperties[0].sdkCode).toBe('FrameioAPISDK') + expect(call.serviceProperties[0].licenseConfigs).toEqual([ + { id: '875473476', name: 'Default Frame.io Enterprise', productId: 'AA458DFE4F7020A0441A' } + ]) + }) + it('should surface JIL embedded errors as a CLI error', async () => { mockConsoleCLIInstance.subscribeToServicesWithCredentialType.mockResolvedValue({ error: ['AppBuilderDataServicesSDK'], From de23e97cb2225cbccbcd3d8f9c4db73362b8f3c4 Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 12:21:21 -0700 Subject: [PATCH 03/10] fix: GH-258 make api add additive instead of overwriting existing services JIL's PUT-services endpoint replaces the credential's service list rather than merging into it, so any second call to `aio console workspace api add` silently wiped the services attached by an earlier call. The success message still said "added", but the prior subscriptions (and their credential scopes) were gone, with no deploy-time signal that anything broke. Fetch the credential's current serviceProperties via getServicePropertiesFromWorkspaceWithCredentialType, merge with the requested adds (requested entry wins on sdkCode overlap so users can still update licenseConfigs), and submit the union. If the fetch fails (e.g. a brand-new workspace with no credential yet) treat it as "no existing services" so the first-time path still works. Dedupe the enabled-services list up front via dedupeServicesByCode so the existing-services helper picks the entp record for FrameioAPISDK in fixServiceProperties and preserves the licenseConfig on round-trip. Verified end-to-end against the reporter's org: after three sequential single-service `api add` calls (AdobeIOManagementAPISDK, FrameioAPISDK, AppBuilderDataServicesSDK), the credential ends up subscribed to all three with their scopes intact, instead of just the last one. Closes #258 --- src/commands/console/workspace/api/add.js | 68 +++++++++++++++- .../console/workspace/api/add.test.js | 80 ++++++++++++++++++- 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index 9c92acc..ef77e82 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -112,6 +112,45 @@ function pickServiceForCode (services, code) { return matches[0] } +/** + * Reduce the enabled-services list to one record per sdkCode using + * pickServiceForCode. Preserves the original ordering of the chosen records. + * + * @param {Array} services enabled services + * @returns {Array} deduplicated services + */ +function dedupeServicesByCode (services) { + const seen = new Set() + const result = [] + for (const s of services) { + if (seen.has(s.code)) continue + seen.add(s.code) + const picked = pickServiceForCode(services, s.code) + if (picked) result.push(picked) + } + return result +} + +/** + * Merge new service-subscription requests with existing services on the + * credential. JIL's PUT-services endpoint replaces the credential's + * service list rather than appending to it, so without this merge a + * subsequent `aio console workspace api add` call would silently wipe + * the services subscribed by an earlier call. + * + * For codes present in both, the new entry wins (the user is overriding + * the existing subscription, including any licenseConfig changes). + * + * @param {Array} existing serviceProperties currently on the credential + * @param {Array} requested serviceProperties the user is adding + * @returns {Array} merged serviceProperties + */ +function mergeServiceProperties (existing, requested) { + const requestedCodes = new Set(requested.map(sp => sp.sdkCode)) + const kept = existing.filter(sp => !requestedCodes.has(sp.sdkCode)) + return [...kept, ...requested] +} + /** * Detect JIL subscription errors embedded in a 200 response and throw * a CLI-friendly error if any are found. @@ -175,13 +214,14 @@ class AddCommand extends ConsoleCommand { const licenseConfigMap = parseLicenseConfigFlags(flags['license-config'] || []) const enabledServices = await this.consoleCLI.getEnabledServicesForOrg(orgId) - aioConsoleLogger.debug(`Enabled services: ${JSON.stringify(enabledServices.map(s => s.code))}`) + const supportedServices = dedupeServicesByCode(enabledServices) + aioConsoleLogger.debug(`Enabled services (deduped): ${JSON.stringify(supportedServices.map(s => s.code))}`) const serviceProperties = [] const notFound = [] const missingProfiles = [] for (const code of requestedCodes) { - const service = pickServiceForCode(enabledServices, code) + const service = supportedServices.find(s => s.code === code) if (!service) { notFound.push(code) continue @@ -221,11 +261,31 @@ class AddCommand extends ConsoleCommand { ) } + // JIL's PUT-services endpoint replaces the credential's service list, + // so fetch what's already subscribed and submit the union — otherwise + // a later `api add` call silently wipes services attached by an earlier + // one. Treat any failure as "no existing services" so a brand-new + // workspace (no credential yet) still works. + let existingProperties = [] + try { + existingProperties = await this.consoleCLI.getServicePropertiesFromWorkspaceWithCredentialType({ + orgId, + projectId: project.id, + workspace, + supportedServices, + credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL + }) || [] + } catch (err) { + aioConsoleLogger.debug(`Could not fetch existing services for workspace ${workspace.name}: ${err.message}`) + } + const mergedProperties = mergeServiceProperties(existingProperties, serviceProperties) + aioConsoleLogger.debug(`Submitting service list: ${JSON.stringify(mergedProperties.map(sp => sp.sdkCode))}`) + const result = await this.consoleCLI.subscribeToServicesWithCredentialType({ orgId, project, workspace, - serviceProperties, + serviceProperties: mergedProperties, credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL }) @@ -293,3 +353,5 @@ module.exports.parseLicenseConfigFlags = parseLicenseConfigFlags module.exports.resolveLicenseConfigs = resolveLicenseConfigs module.exports.assertSubscribeSuccess = assertSubscribeSuccess module.exports.pickServiceForCode = pickServiceForCode +module.exports.dedupeServicesByCode = dedupeServicesByCode +module.exports.mergeServiceProperties = mergeServiceProperties diff --git a/test/commands/console/workspace/api/add.test.js b/test/commands/console/workspace/api/add.test.js index e8ee206..abe6dcd 100644 --- a/test/commands/console/workspace/api/add.test.js +++ b/test/commands/console/workspace/api/add.test.js @@ -45,6 +45,7 @@ const mockConsoleCLIInstance = { getProjects: jest.fn().mockResolvedValue([mockProject]), getWorkspaces: jest.fn().mockResolvedValue([mockWorkspace]), getEnabledServicesForOrg: jest.fn().mockResolvedValue(mockEnabledServices), + getServicePropertiesFromWorkspaceWithCredentialType: jest.fn().mockResolvedValue([]), subscribeToServicesWithCredentialType: jest.fn().mockResolvedValue(mockSubscribeResponse) } @@ -56,7 +57,7 @@ jest.mock('@adobe/aio-cli-lib-console', () => ({ })) const TheCommand = require('../../../../../src/commands/console/workspace/api/add') -const { parseLicenseConfigFlags, resolveLicenseConfigs, assertSubscribeSuccess, pickServiceForCode } = TheCommand +const { parseLicenseConfigFlags, resolveLicenseConfigs, assertSubscribeSuccess, pickServiceForCode, dedupeServicesByCode, mergeServiceProperties } = TheCommand describe('parseLicenseConfigFlags', () => { it('should parse a single sdkCode with one profile', () => { @@ -172,6 +173,48 @@ describe('pickServiceForCode', () => { }) }) +describe('dedupeServicesByCode', () => { + it('should return a single record per code, preferring entp+licenseConfigs', () => { + const services = [ + { code: 'FrameioAPISDK', type: 'adobeid', properties: null }, + { code: 'FrameioAPISDK', type: 'entp', properties: { licenseConfigs: [{ id: 'lc1' }] } }, + { code: 'OtherSDK', type: 'entp' } + ] + const deduped = dedupeServicesByCode(services) + expect(deduped).toHaveLength(2) + expect(deduped.find(s => s.code === 'FrameioAPISDK')).toBe(services[1]) + expect(deduped.find(s => s.code === 'OtherSDK')).toBe(services[2]) + }) + + it('should be a no-op when no duplicates exist', () => { + const services = [{ code: 'A', type: 'entp' }, { code: 'B', type: 'entp' }] + expect(dedupeServicesByCode(services)).toEqual(services) + }) +}) + +describe('mergeServiceProperties', () => { + it('should append new services when there is no overlap', () => { + const existing = [{ sdkCode: 'A' }] + const requested = [{ sdkCode: 'B' }] + expect(mergeServiceProperties(existing, requested)).toEqual([{ sdkCode: 'A' }, { sdkCode: 'B' }]) + }) + + it('should let the requested entry win for overlapping sdkCodes', () => { + const existing = [{ sdkCode: 'A', licenseConfigs: [{ id: 'old' }] }] + const requested = [{ sdkCode: 'A', licenseConfigs: [{ id: 'new' }] }] + expect(mergeServiceProperties(existing, requested)).toEqual([{ sdkCode: 'A', licenseConfigs: [{ id: 'new' }] }]) + }) + + it('should preserve existing services not in the requested list', () => { + const existing = [{ sdkCode: 'KeepMe' }, { sdkCode: 'Override' }] + const requested = [{ sdkCode: 'Override', updated: true }] + expect(mergeServiceProperties(existing, requested)).toEqual([ + { sdkCode: 'KeepMe' }, + { sdkCode: 'Override', updated: true } + ]) + }) +}) + describe('assertSubscribeSuccess', () => { it('should not throw on a normal success response', () => { expect(() => assertSubscribeSuccess({ sdkList: ['AdobeAnalyticsSDK'] })).not.toThrow() @@ -215,6 +258,7 @@ describe('console:workspace:api:add', () => { mockConsoleCLIInstance.getProjects.mockResolvedValue([mockProject]) mockConsoleCLIInstance.getWorkspaces.mockResolvedValue([mockWorkspace]) mockConsoleCLIInstance.getEnabledServicesForOrg.mockResolvedValue(mockEnabledServices) + mockConsoleCLIInstance.getServicePropertiesFromWorkspaceWithCredentialType.mockResolvedValue([]) mockConsoleCLIInstance.subscribeToServicesWithCredentialType.mockResolvedValue(mockSubscribeResponse) }) @@ -460,6 +504,40 @@ describe('console:workspace:api:add', () => { ]) }) + it('should merge new services with services already on the credential', async () => { + // JIL's PUT-services replaces the credential's service list, so the + // command must submit the union of existing + new. + mockConsoleCLIInstance.getServicePropertiesFromWorkspaceWithCredentialType.mockResolvedValue([ + { name: 'Existing SDK', sdkCode: 'ExistingSDK', roles: null, licenseConfigs: null } + ]) + command.argv = [ + '--service-code', 'AppBuilderDataServicesSDK', + '--projectName', 'myproject', + '--workspaceName', 'Stage', + '--orgId', '12345' + ] + await command.run() + + const call = mockConsoleCLIInstance.subscribeToServicesWithCredentialType.mock.calls[0][0] + expect(call.serviceProperties.map(sp => sp.sdkCode)).toEqual(['ExistingSDK', 'AppBuilderDataServicesSDK']) + }) + + it('should not fail when fetching existing services errors out', async () => { + // A brand-new workspace has no credential yet; the lib's getServiceProperties + // call may reject. The command should fall back to "no existing services". + mockConsoleCLIInstance.getServicePropertiesFromWorkspaceWithCredentialType.mockRejectedValue(new Error('No credential')) + command.argv = [ + '--service-code', 'AppBuilderDataServicesSDK', + '--projectName', 'myproject', + '--workspaceName', 'Stage', + '--orgId', '12345' + ] + await command.run() + + const call = mockConsoleCLIInstance.subscribeToServicesWithCredentialType.mock.calls[0][0] + expect(call.serviceProperties.map(sp => sp.sdkCode)).toEqual(['AppBuilderDataServicesSDK']) + }) + it('should surface JIL embedded errors as a CLI error', async () => { mockConsoleCLIInstance.subscribeToServicesWithCredentialType.mockResolvedValue({ error: ['AppBuilderDataServicesSDK'], From 5fa83f41672fa00cce308b5166d48264c6c63c8c Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 12:45:21 -0700 Subject: [PATCH 04/10] refactor: extract hasLicenseConfigs predicate in pickServiceForCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR reviewer suggestion. Pure readability change — same behavior, the entp+profiles condition is easier to scan. --- src/commands/console/workspace/api/add.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index ef77e82..585cd68 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -101,7 +101,8 @@ function pickServiceForCode (services, code) { if (matches.length === 0) { return undefined } - const entpWithProfiles = matches.find(s => s.type === 'entp' && s.properties && Array.isArray(s.properties.licenseConfigs) && s.properties.licenseConfigs.length > 0) + const hasLicenseConfigs = s => s.properties && Array.isArray(s.properties.licenseConfigs) && s.properties.licenseConfigs.length > 0 + const entpWithProfiles = matches.find(s => s.type === 'entp' && hasLicenseConfigs(s)) if (entpWithProfiles) { return entpWithProfiles } From 6607f2a9b047c501591a5e8d3388e7d5d97cf8f8 Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 12:52:08 -0700 Subject: [PATCH 05/10] test: cover remaining branches in add.js to satisfy 100% gate CI's Jest branch-coverage threshold (100%) was failing at 98.84%. Three uncovered branches: - dedupeServicesByCode: the if (picked) guard was structurally unreachable (pickServiceForCode always finds at least the iterating element via its matches[0] fallback). Dropped the guard, added a comment explaining why. - existingProperties || []: the fallback was defensive against the lib returning a non-array, but getServicePropertiesFromWorkspaceWithCredentialType always returns either an array of services or [] for a missing credential. Dropped the fallback. - assertSubscribeSuccess: defensive d && d.sdkCode / d && d.message branches for malformed JIL responses were uncovered. Added three tests covering errorDetails entries that lack sdkCode, lack message, or are null. --- src/commands/console/workspace/api/add.js | 7 ++++--- .../commands/console/workspace/api/add.test.js | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index 585cd68..ea0bf97 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -126,8 +126,9 @@ function dedupeServicesByCode (services) { for (const s of services) { if (seen.has(s.code)) continue seen.add(s.code) - const picked = pickServiceForCode(services, s.code) - if (picked) result.push(picked) + // pickServiceForCode is guaranteed to return a record because s itself + // is in services and matches by code. + result.push(pickServiceForCode(services, s.code)) } return result } @@ -275,7 +276,7 @@ class AddCommand extends ConsoleCommand { workspace, supportedServices, credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL - }) || [] + }) } catch (err) { aioConsoleLogger.debug(`Could not fetch existing services for workspace ${workspace.name}: ${err.message}`) } diff --git a/test/commands/console/workspace/api/add.test.js b/test/commands/console/workspace/api/add.test.js index abe6dcd..09b5e89 100644 --- a/test/commands/console/workspace/api/add.test.js +++ b/test/commands/console/workspace/api/add.test.js @@ -247,6 +247,24 @@ describe('assertSubscribeSuccess', () => { expect(() => assertSubscribeSuccess({ error: ['SomeSDK'] })) .toThrow(/Failed to add API service\(s\)[\s\S]*SomeSDK/) }) + + it('should format error details that lack a sdkCode', () => { + expect(() => assertSubscribeSuccess({ + errorDetails: [{ domain: 'JIL', code: 500, message: 'kaboom' }] + })).toThrow(/Failed to add API service\(s\)[\s\S]*kaboom/) + }) + + it('should fall back to JSON.stringify when an error detail lacks a message', () => { + expect(() => assertSubscribeSuccess({ + errorDetails: [{ sdkCode: 'WeirdSDK', code: 418 }] + })).toThrow(/Failed to add API service\(s\)[\s\S]*WeirdSDK:[\s\S]*"code":\s*418/) + }) + + it('should tolerate a null entry inside errorDetails', () => { + expect(() => assertSubscribeSuccess({ + errorDetails: [null] + })).toThrow(/Failed to add API service\(s\)/) + }) }) describe('console:workspace:api:add', () => { From abdd2614569571e908b9a1d22c824369dd7128b3 Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 12:55:40 -0700 Subject: [PATCH 06/10] style: ACNA-4617 satisfy lint + apply re-raised reviewer suggestion - eslint --fix on the assertSubscribeSuccess formatter (stylistic/indent) - Multi-line hasLicenseConfigs per re-raised PR reviewer suggestion - JSDoc tweaks (Object -> {[k:K]:V}, missing @param description) --- src/commands/console/workspace/api/add.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index ea0bf97..bfcf13d 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -21,7 +21,7 @@ const LibConsoleCLI = require('@adobe/aio-cli-lib-console') * Format: "=[,...]" * * @param {string[]} values raw flag values - * @returns {Object} map of sdkCode to list of profile identifiers + * @returns {{[sdkCode: string]: string[]}} map of sdkCode to list of profile identifiers */ function parseLicenseConfigFlags (values) { const result = {} @@ -53,7 +53,7 @@ function parseLicenseConfigFlags (values) { * which is convenient for services like Frame.io that expose a single * profile per product. * - * @param {Array<{id: string, name: string, productId: string}>} available + * @param {Array<{id: string, name: string, productId: string}>} available licenseConfigs reported for the service * @param {string[]} requested profile names, ids, or productIds * @param {string} sdkCode service code for error messages * @returns {Array} selected licenseConfig objects @@ -101,7 +101,10 @@ function pickServiceForCode (services, code) { if (matches.length === 0) { return undefined } - const hasLicenseConfigs = s => s.properties && Array.isArray(s.properties.licenseConfigs) && s.properties.licenseConfigs.length > 0 + const hasLicenseConfigs = s => + s.properties && + Array.isArray(s.properties.licenseConfigs) && + s.properties.licenseConfigs.length > 0 const entpWithProfiles = matches.find(s => s.type === 'entp' && hasLicenseConfigs(s)) if (entpWithProfiles) { return entpWithProfiles @@ -174,10 +177,10 @@ function assertSubscribeSuccess (response) { } const formatted = errorDetails.length > 0 ? errorDetails.map(d => { - const where = d && d.sdkCode ? `${d.sdkCode}: ` : '' - const message = (d && d.message) || JSON.stringify(d) - return ` ${where}${message}` - }).join('\n') + const where = d && d.sdkCode ? `${d.sdkCode}: ` : '' + const message = (d && d.message) || JSON.stringify(d) + return ` ${where}${message}` + }).join('\n') : ` ${errorCodes.join(', ')}` throw new Error(`Failed to add API service(s):\n${formatted}`) } From 93f04aebb5bf8d0244a99c1a4a0eb769d3a2a9ae Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 13:26:44 -0700 Subject: [PATCH 07/10] fix: validate --license-config keys map to --service-code values Per PR review feedback: parseLicenseConfigFlags accepts any sdkCode key, but the downstream loop only iterates --service-code, so a mismatched key (typo or wrong casing like FrameIOAPISDK vs FrameioAPISDK) was silently ignored while the rest of the command exited 0 with partial state. That's the same silent-drop class of bug ACNA-4617 was filed against. Fail fast if any --license-config key isn't in --service-code, listing the requested service codes in the error so casing typos are visible. --- src/commands/console/workspace/api/add.js | 12 ++++++++++++ test/commands/console/workspace/api/add.test.js | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index bfcf13d..671c60a 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -218,6 +218,18 @@ class AddCommand extends ConsoleCommand { const licenseConfigMap = parseLicenseConfigFlags(flags['license-config'] || []) + // Fail fast if --license-config references a service code that isn't in + // --service-code. Otherwise the entry is silently ignored, which is the + // exact silent-drop class of bug this command was patched against. + const requestedSet = new Set(requestedCodes) + const orphanLicenseConfigs = Object.keys(licenseConfigMap).filter(c => !requestedSet.has(c)) + if (orphanLicenseConfigs.length > 0) { + this.error( + `--license-config given for service code(s) not in --service-code: ${orphanLicenseConfigs.join(', ')}. ` + + `Requested service codes: ${requestedCodes.join(', ')}.` + ) + } + const enabledServices = await this.consoleCLI.getEnabledServicesForOrg(orgId) const supportedServices = dedupeServicesByCode(enabledServices) aioConsoleLogger.debug(`Enabled services (deduped): ${JSON.stringify(supportedServices.map(s => s.code))}`) diff --git a/test/commands/console/workspace/api/add.test.js b/test/commands/console/workspace/api/add.test.js index 09b5e89..fb9f269 100644 --- a/test/commands/console/workspace/api/add.test.js +++ b/test/commands/console/workspace/api/add.test.js @@ -383,6 +383,21 @@ describe('console:workspace:api:add', () => { await expect(command.run()).rejects.toThrow('Product profile(s) not found for service AdobeAnalyticsSDK: UnknownProfile') }) + it('should error when --license-config references a code not in --service-code', async () => { + // Catches typos / casing mismatches that would otherwise silently + // drop the license-config entry while the unrelated --service-code + // succeeds. + command.argv = [ + '--service-code', 'AppBuilderDataServicesSDK', + '--projectName', 'myproject', + '--workspaceName', 'Stage', + '--orgId', '12345', + '--license-config', 'FrameIOAPISDK=875473476' + ] + await expect(command.run()).rejects.toThrow(/--license-config given for service code\(s\) not in --service-code: FrameIOAPISDK[\s\S]*Requested service codes: AppBuilderDataServicesSDK/) + expect(mockConsoleCLIInstance.subscribeToServicesWithCredentialType).not.toHaveBeenCalled() + }) + it('should error on malformed --license-config value', async () => { command.argv = [ '--service-code', 'AdobeAnalyticsSDK', From b30c43761f865fadf61e9b9a89a15afa66349b06 Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 13:31:14 -0700 Subject: [PATCH 08/10] fix: surface fetch-existing-services failures at warn level The lib returns [] (not throws) for a missing credential, so a thrown error here is something real (auth, network, server) and is worth surfacing to the operator. Silently proceeding with an empty list could overwrite the workspace's actual services. Per PR review feedback. --- src/commands/console/workspace/api/add.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index 671c60a..0a2f21c 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -293,7 +293,10 @@ class AddCommand extends ConsoleCommand { credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL }) } catch (err) { - aioConsoleLogger.debug(`Could not fetch existing services for workspace ${workspace.name}: ${err.message}`) + // Lib returns [] (not throws) for a missing credential, so a thrown + // error here is something real (auth, network, server) and worth + // surfacing — proceeding with an empty list could overwrite state. + aioConsoleLogger.warn(`Could not fetch existing services for workspace ${workspace.name} (proceeding with empty list): ${err.message}`) } const mergedProperties = mergeServiceProperties(existingProperties, serviceProperties) aioConsoleLogger.debug(`Submitting service list: ${JSON.stringify(mergedProperties.map(sp => sp.sdkCode))}`) From c75dfbc2cd5398b5183561b27b7028e3122a1aef Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 15:08:53 -0700 Subject: [PATCH 09/10] fix: propagate existing-services fetch failures instead of swallowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review feedback. The lib's getServicePropertiesFromWorkspaceWithCredentialType returns [] (not throws) for a workspace without a credential yet, so any thrown error from this call is a real auth/network/server failure. The previous try/catch swallowed those and proceeded with [], which meant JIL's replace-style PUT would wipe the credential's actual services and replace them with just the requested adds — the exact overwrite this merge is supposed to prevent. Remove the catch and let the error propagate. The user sees the failure and can retry instead of getting a silent state corruption. --- src/commands/console/workspace/api/add.js | 28 ++++++++----------- .../console/workspace/api/add.test.js | 17 +++++------ 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index 0a2f21c..de17a4e 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -281,23 +281,17 @@ class AddCommand extends ConsoleCommand { // JIL's PUT-services endpoint replaces the credential's service list, // so fetch what's already subscribed and submit the union — otherwise // a later `api add` call silently wipes services attached by an earlier - // one. Treat any failure as "no existing services" so a brand-new - // workspace (no credential yet) still works. - let existingProperties = [] - try { - existingProperties = await this.consoleCLI.getServicePropertiesFromWorkspaceWithCredentialType({ - orgId, - projectId: project.id, - workspace, - supportedServices, - credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL - }) - } catch (err) { - // Lib returns [] (not throws) for a missing credential, so a thrown - // error here is something real (auth, network, server) and worth - // surfacing — proceeding with an empty list could overwrite state. - aioConsoleLogger.warn(`Could not fetch existing services for workspace ${workspace.name} (proceeding with empty list): ${err.message}`) - } + // one. The lib returns [] (not throws) for a workspace without a + // credential yet, so any thrown error here is real (auth, network, + // server) and we let it propagate: proceeding with an empty list + // would cause the very overwrite this merge is supposed to prevent. + const existingProperties = await this.consoleCLI.getServicePropertiesFromWorkspaceWithCredentialType({ + orgId, + projectId: project.id, + workspace, + supportedServices, + credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL + }) const mergedProperties = mergeServiceProperties(existingProperties, serviceProperties) aioConsoleLogger.debug(`Submitting service list: ${JSON.stringify(mergedProperties.map(sp => sp.sdkCode))}`) diff --git a/test/commands/console/workspace/api/add.test.js b/test/commands/console/workspace/api/add.test.js index fb9f269..b751d83 100644 --- a/test/commands/console/workspace/api/add.test.js +++ b/test/commands/console/workspace/api/add.test.js @@ -555,20 +555,21 @@ describe('console:workspace:api:add', () => { expect(call.serviceProperties.map(sp => sp.sdkCode)).toEqual(['ExistingSDK', 'AppBuilderDataServicesSDK']) }) - it('should not fail when fetching existing services errors out', async () => { - // A brand-new workspace has no credential yet; the lib's getServiceProperties - // call may reject. The command should fall back to "no existing services". - mockConsoleCLIInstance.getServicePropertiesFromWorkspaceWithCredentialType.mockRejectedValue(new Error('No credential')) + it('should propagate failures to fetch existing services instead of overwriting silently', async () => { + // The lib returns [] (not throws) for a workspace without a credential + // yet, so a thrown error here is a real auth/network/server failure. + // Swallowing it and proceeding with [] would cause the credential's + // current services to be replaced by just the requested adds — the + // exact overwrite this merge is supposed to prevent. + mockConsoleCLIInstance.getServicePropertiesFromWorkspaceWithCredentialType.mockRejectedValue(new Error('network blew up')) command.argv = [ '--service-code', 'AppBuilderDataServicesSDK', '--projectName', 'myproject', '--workspaceName', 'Stage', '--orgId', '12345' ] - await command.run() - - const call = mockConsoleCLIInstance.subscribeToServicesWithCredentialType.mock.calls[0][0] - expect(call.serviceProperties.map(sp => sp.sdkCode)).toEqual(['AppBuilderDataServicesSDK']) + await expect(command.run()).rejects.toThrow('network blew up') + expect(mockConsoleCLIInstance.subscribeToServicesWithCredentialType).not.toHaveBeenCalled() }) it('should surface JIL embedded errors as a CLI error', async () => { From 5df21779f110d56e058f86f246b2f894fb2f0cfa Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 21 May 2026 15:14:19 -0700 Subject: [PATCH 10/10] style: render null entries in errorDetails as (unknown error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per re-raised PR reviewer suggestion. Previously a null entry in errorDetails formatted as the literal string "null" via JSON.stringify fallback — accurate but unclear. Explicit handling produces clearer output for the (unlikely) malformed-response case. --- src/commands/console/workspace/api/add.js | 2 +- test/commands/console/workspace/api/add.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/console/workspace/api/add.js b/src/commands/console/workspace/api/add.js index de17a4e..c469c56 100644 --- a/src/commands/console/workspace/api/add.js +++ b/src/commands/console/workspace/api/add.js @@ -178,7 +178,7 @@ function assertSubscribeSuccess (response) { const formatted = errorDetails.length > 0 ? errorDetails.map(d => { const where = d && d.sdkCode ? `${d.sdkCode}: ` : '' - const message = (d && d.message) || JSON.stringify(d) + const message = d == null ? '(unknown error)' : (d.message || JSON.stringify(d)) return ` ${where}${message}` }).join('\n') : ` ${errorCodes.join(', ')}` diff --git a/test/commands/console/workspace/api/add.test.js b/test/commands/console/workspace/api/add.test.js index b751d83..2c9f7f3 100644 --- a/test/commands/console/workspace/api/add.test.js +++ b/test/commands/console/workspace/api/add.test.js @@ -260,10 +260,10 @@ describe('assertSubscribeSuccess', () => { })).toThrow(/Failed to add API service\(s\)[\s\S]*WeirdSDK:[\s\S]*"code":\s*418/) }) - it('should tolerate a null entry inside errorDetails', () => { + it('should render a null entry inside errorDetails as "(unknown error)"', () => { expect(() => assertSubscribeSuccess({ errorDetails: [null] - })).toThrow(/Failed to add API service\(s\)/) + })).toThrow(/Failed to add API service\(s\)[\s\S]*\(unknown error\)/) }) })