From 82b793868e91d9635fc812726bfb9fe0afc50d08 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 6 Aug 2026 16:09:54 +0200 Subject: [PATCH 1/4] feat: add app console where/open commands Adds `aio app console where` and `aio app console open`, local-app equivalents of the global `aio console where`/`aio console open` commands, to inspect and jump to the org/project/workspace an app is currently linked to via `aio app use`. Extracts the shared config loading/formatting logic out of `use.js` into `src/lib/console-helper.js` so both commands and `use.js` share the same implementation. --- src/commands/app/console/index.js | 26 ++++++ src/commands/app/console/open.js | 44 ++++++++++ src/commands/app/console/where.js | 54 ++++++++++++ src/commands/app/use.js | 43 ++-------- src/lib/console-helper.js | 61 +++++++++++++ src/lib/defaults.js | 4 + test/commands/app/console/index.test.js | 22 +++++ test/commands/app/console/open.test.js | 91 ++++++++++++++++++++ test/commands/app/console/where.test.js | 96 +++++++++++++++++++++ test/lib/console-helper.test.js | 109 ++++++++++++++++++++++++ 10 files changed, 515 insertions(+), 35 deletions(-) create mode 100644 src/commands/app/console/index.js create mode 100644 src/commands/app/console/open.js create mode 100644 src/commands/app/console/where.js create mode 100644 src/lib/console-helper.js create mode 100644 test/commands/app/console/index.test.js create mode 100644 test/commands/app/console/open.test.js create mode 100644 test/commands/app/console/where.test.js create mode 100644 test/lib/console-helper.test.js diff --git a/src/commands/app/console/index.js b/src/commands/app/console/index.js new file mode 100644 index 000000000..f6ea53679 --- /dev/null +++ b/src/commands/app/console/index.js @@ -0,0 +1,26 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { Help } = require('@oclif/core') +const BaseCommand = require('../../../BaseCommand') + +class ConsoleCommand extends BaseCommand { + async run () { + const help = new Help(this.config) + await help.showHelp(['app:console', '--help']) + } +} + +ConsoleCommand.description = 'Display Adobe Developer Console org/project/workspace configuration for this app' + +ConsoleCommand.args = {} + +module.exports = ConsoleCommand diff --git a/src/commands/app/console/open.js b/src/commands/app/console/open.js new file mode 100644 index 000000000..e013c92bf --- /dev/null +++ b/src/commands/app/console/open.js @@ -0,0 +1,44 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const open = require('open') +const { getCliEnv } = require('@adobe/aio-lib-env') +const BaseCommand = require('../../../BaseCommand') +const { loadCurrentConfiguration } = require('../../../lib/console-helper') +const { OPEN_URLS } = require('../../../lib/defaults') + +class OpenCommand extends BaseCommand { + async run () { + await this.parse(OpenCommand) + const { org, project, workspace } = loadCurrentConfiguration() + + if (!org.id || !project.id) { + this.error( + 'Incomplete .aio configuration, cannot open the Developer Console.' + + ' Please import a valid Adobe Developer Console configuration file via `aio app use .json`.' + ) + } + + let url = `${OPEN_URLS[getCliEnv()]}/${org.id}/${project.id}/` + url += workspace.id ? `workspaces/${workspace.id}/details` : 'overview' + await open(url) + } +} + +OpenCommand.description = 'Open the Adobe Developer Console workspace this app is set to use (as set by `aio app use`) in the default web browser' + +OpenCommand.flags = { + ...BaseCommand.flags +} + +OpenCommand.args = {} + +module.exports = OpenCommand diff --git a/src/commands/app/console/where.js b/src/commands/app/console/where.js new file mode 100644 index 000000000..a3f60d164 --- /dev/null +++ b/src/commands/app/console/where.js @@ -0,0 +1,54 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { Flags } = require('@oclif/core') +const yaml = require('js-yaml') +const BaseCommand = require('../../../BaseCommand') +const { loadCurrentConfiguration, configString } = require('../../../lib/console-helper') +const { EOL } = require('os') + +class WhereCommand extends BaseCommand { + async run () { + const { flags } = await this.parse(WhereCommand) + const currentConfig = loadCurrentConfiguration() + + if (flags.json) { + this.log(JSON.stringify(currentConfig, null, 2)) + return + } + if (flags.yml) { + this.log(yaml.dump(JSON.parse(JSON.stringify(currentConfig)), {})) + return + } + + this.log(`This app is set to use:${EOL}${configString(currentConfig)}`) + } +} + +WhereCommand.description = 'Display the Adobe Developer Console org/project/workspace configuration this app is set to use (as set by `aio app use`)' + +WhereCommand.flags = { + ...BaseCommand.flags, + json: Flags.boolean({ + description: 'Output json', + char: 'j', + exclusive: ['yml'] + }), + yml: Flags.boolean({ + description: 'Output yml', + char: 'y', + exclusive: ['json'] + }) +} + +WhereCommand.args = {} + +module.exports = WhereCommand diff --git a/src/commands/app/use.js b/src/commands/app/use.js index 896f6c0db..e42fb5e7d 100644 --- a/src/commands/app/use.js +++ b/src/commands/app/use.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. const BaseCommand = require('../../BaseCommand') const { CONSOLE_CONFIG_KEY, getProjectCredentialType } = require('../../lib/import-helper') const { importConsoleConfig, downloadConsoleConfigToBuffer } = require('../../lib/import') +const { loadCurrentConfiguration, configString, isCompleteConfig } = require('../../lib/console-helper') const { Flags, Args } = require('@oclif/core') const inquirer = require('inquirer') const config = require('@adobe/aio-lib-core-config') @@ -38,9 +39,9 @@ class Use extends BaseCommand { const prompt = inquirer.createPromptModule({ output: process.stderr }) // load local config - const currentConfig = this.loadCurrentConfiguration() - const currentConfigString = this.configString(currentConfig) - const currentConfigIsComplete = this.isCompleteConfig(currentConfig) + const currentConfig = loadCurrentConfiguration() + const currentConfigString = configString(currentConfig) + const currentConfigIsComplete = isCompleteConfig(currentConfig) this.log(`You are currently in:${EOL}${currentConfigString}${EOL}`) if (args.config_file_path) { @@ -55,7 +56,7 @@ class Use extends BaseCommand { // load global console config const globalConfig = this.loadGlobalConfiguration() - const globalConfigString = this.configString(globalConfig, 4) + const globalConfigString = configString(globalConfig, 4) // load from global configuration or select workspace ? const globalOperationFromFlag = flags.global ? 'global' : null @@ -71,7 +72,7 @@ class Use extends BaseCommand { // load the new workspace, project, org config let newConfig if (useOperation === 'global') { - if (!this.isCompleteConfig(globalConfig)) { + if (!isCompleteConfig(globalConfig)) { const message = `Your global Console configuration is incomplete.${EOL}` + 'Use the `aio console` commands to select your Organization, Project, and Workspace.' this.error(message) @@ -129,31 +130,10 @@ class Use extends BaseCommand { } } - loadCurrentConfiguration () { - const projectConfig = config.get('project') || {} - const org = (projectConfig.org && { id: projectConfig.org.id, name: projectConfig.org.name }) || {} - const project = { name: projectConfig.name, id: projectConfig.id } - const workspace = (projectConfig.workspace && { ...projectConfig.workspace }) || {} - return { org, project, workspace } - } - loadGlobalConfiguration () { return config.get(CONSOLE_CONFIG_KEY) || {} } - configString (config, spaces = 0) { - const { org = {}, project = {}, workspace = {} } = config - const list = [ - `1. Org: ${org.name || ''}`, - `2. Project: ${project.name || ''}`, - `3. Workspace: ${workspace.name || ''}` - ] - - return list - .map(line => ' '.repeat(spaces) + line) - .join(EOL) - } - async promptForUseOperation (prompt, globalConfigString) { const op = await prompt([ { @@ -169,13 +149,6 @@ class Use extends BaseCommand { return op.res } - isCompleteConfig (config) { - return config && - config.org && config.org.id && config.org.name && - config.project && config.project.id && config.project.name && - config.workspace && config.workspace.id && config.workspace.name - } - /** * @param {LibConsoleCLI} consoleCLI lib console config * @param {object} config local configuration @@ -322,9 +295,9 @@ class Use extends BaseCommand { async finalLogMessage (consoleConfig) { const config = { org: consoleConfig.project.org, project: consoleConfig.project, workspace: consoleConfig.project.workspace } - const configString = this.configString(config) + const configStr = configString(config) this.log(chalk.green(chalk.bold( - `${EOL}✔ Successfully imported configuration for:${EOL}${configString}.` + `${EOL}✔ Successfully imported configuration for:${EOL}${configStr}.` ))) } diff --git a/src/lib/console-helper.js b/src/lib/console-helper.js new file mode 100644 index 000000000..818c0bc7c --- /dev/null +++ b/src/lib/console-helper.js @@ -0,0 +1,61 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const config = require('@adobe/aio-lib-core-config') +const { EOL } = require('os') + +/** + * Loads the local (per-app) org/project/workspace configuration, as set by `aio app use`. + * + * @returns {object} { org, project, workspace } + */ +function loadCurrentConfiguration () { + const projectConfig = config.get('project') || {} + const org = (projectConfig.org && { id: projectConfig.org.id, name: projectConfig.org.name }) || {} + const project = { name: projectConfig.name, id: projectConfig.id } + const workspace = (projectConfig.workspace && { ...projectConfig.workspace }) || {} + return { org, project, workspace } +} + +/** + * @param {object} config { org, project, workspace } + * @param {number} spaces number of leading spaces for each line + * @returns {string} human readable representation of the org/project/workspace configuration + */ +function configString (config, spaces = 0) { + const { org = {}, project = {}, workspace = {} } = config + const list = [ + `1. Org: ${org.name || ''}`, + `2. Project: ${project.name || ''}`, + `3. Workspace: ${workspace.name || ''}` + ] + + return list + .map(line => ' '.repeat(spaces) + line) + .join(EOL) +} + +/** + * @param {object} config { org, project, workspace } + * @returns {boolean} true if org, project, and workspace are all fully defined + */ +function isCompleteConfig (config) { + return config && + config.org && config.org.id && config.org.name && + config.project && config.project.id && config.project.name && + config.workspace && config.workspace.id && config.workspace.name +} + +module.exports = { + loadCurrentConfiguration, + configString, + isCompleteConfig +} diff --git a/src/lib/defaults.js b/src/lib/defaults.js index 9c7cf0289..bb1258da8 100644 --- a/src/lib/defaults.js +++ b/src/lib/defaults.js @@ -28,6 +28,10 @@ module.exports = { prod: 'aio-cli-console-auth', stage: 'aio-cli-console-auth-stage' }, + OPEN_URLS: { + prod: 'https://developer.adobe.com/console/projects', + stage: 'https://developer-stage.adobe.com/console/projects' + }, defaultHttpServerPort: 9080, AIO_CONFIG_WORKSPACE_SERVICES: 'project.workspace.details.services', AIO_CONFIG_ORG_SERVICES: 'project.org.details.services', diff --git a/test/commands/app/console/index.test.js b/test/commands/app/console/index.test.js new file mode 100644 index 000000000..c91aeca6c --- /dev/null +++ b/test/commands/app/console/index.test.js @@ -0,0 +1,22 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const TheCommand = require('../../../../src/commands/app/console/index.js') +const { Help } = require('@oclif/core') + +test('returns help file for app:console command', () => { + const command = new TheCommand([]) + command.config = global.createOclifMockConfig() + const spy = jest.spyOn(Help.prototype, 'showHelp').mockReturnValue(true) + return command.run().then(() => { + expect(spy).toHaveBeenCalledWith(['app:console', '--help']) + }) +}) diff --git a/test/commands/app/console/open.test.js b/test/commands/app/console/open.test.js new file mode 100644 index 000000000..6ed13d8e0 --- /dev/null +++ b/test/commands/app/console/open.test.js @@ -0,0 +1,91 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +jest.mock('open', () => jest.fn()) +jest.mock('@adobe/aio-lib-core-config') + +const TheCommand = require('../../../../src/commands/app/console/open') +const BaseCommand = require('../../../../src/BaseCommand') +const open = require('open') +const mockConfig = require('@adobe/aio-lib-core-config') +const libEnv = require('@adobe/aio-lib-env') + +let fakeCurrentConfig = {} +/** @private */ +function setConfigMock () { + mockConfig.get.mockImplementation(k => { + if (k === 'project') { + return fakeCurrentConfig + } + }) +} + +beforeEach(() => { + jest.clearAllMocks() + libEnv.getCliEnv.mockReturnValue('prod') + fakeCurrentConfig = { + name: 'projectname', + id: 'projectid', + org: { name: 'org name', id: 'org-id' }, + workspace: { name: 'workspacename', id: 'workspaceid' } + } + setConfigMock() +}) + +test('exports', async () => { + expect(typeof TheCommand).toEqual('function') + expect(TheCommand.prototype instanceof BaseCommand).toBeTruthy() + expect(typeof TheCommand.description).toBe('string') +}) + +test('opens the workspace details url for a complete config (prod)', async () => { + await TheCommand.run([]) + expect(open).toHaveBeenCalledWith( + 'https://developer.adobe.com/console/projects/org-id/projectid/workspaces/workspaceid/details' + ) +}) + +test('opens the stage url when the cli env is stage', async () => { + libEnv.getCliEnv.mockReturnValue('stage') + await TheCommand.run([]) + expect(open).toHaveBeenCalledWith( + 'https://developer-stage.adobe.com/console/projects/org-id/projectid/workspaces/workspaceid/details' + ) +}) + +test('opens the project overview url when there is no workspace selected', async () => { + delete fakeCurrentConfig.workspace + setConfigMock() + await TheCommand.run([]) + expect(open).toHaveBeenCalledWith( + 'https://developer.adobe.com/console/projects/org-id/projectid/overview' + ) +}) + +test('errors when org is missing', async () => { + delete fakeCurrentConfig.org + setConfigMock() + await expect(TheCommand.run([])).rejects.toThrow( + 'Incomplete .aio configuration, cannot open the Developer Console.' + + ' Please import a valid Adobe Developer Console configuration file via `aio app use .json`.' + ) + expect(open).not.toHaveBeenCalled() +}) + +test('errors when project is missing', async () => { + fakeCurrentConfig = {} + setConfigMock() + await expect(TheCommand.run([])).rejects.toThrow( + 'Incomplete .aio configuration, cannot open the Developer Console.' + + ' Please import a valid Adobe Developer Console configuration file via `aio app use .json`.' + ) + expect(open).not.toHaveBeenCalled() +}) diff --git a/test/commands/app/console/where.test.js b/test/commands/app/console/where.test.js new file mode 100644 index 000000000..14bc41c12 --- /dev/null +++ b/test/commands/app/console/where.test.js @@ -0,0 +1,96 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const TheCommand = require('../../../../src/commands/app/console/where') +const BaseCommand = require('../../../../src/BaseCommand') +const { EOL } = require('os') + +jest.mock('@adobe/aio-lib-core-config') +const mockConfig = require('@adobe/aio-lib-core-config') + +let fakeCurrentConfig = {} +/** @private */ +function setConfigMock () { + mockConfig.get.mockImplementation(k => { + if (k === 'project') { + return fakeCurrentConfig + } + }) +} + +beforeEach(() => { + jest.clearAllMocks() + fakeCurrentConfig = { + name: 'projectname', + id: 'projectid', + org: { name: 'org name', id: 'org-id' }, + workspace: { name: 'workspacename', id: 'workspaceid' } + } + setConfigMock() +}) + +test('exports', async () => { + expect(typeof TheCommand).toEqual('function') + expect(TheCommand.prototype instanceof BaseCommand).toBeTruthy() + expect(typeof TheCommand.description).toBe('string') +}) + +test('flags', async () => { + expect(TheCommand.flags.json.char).toEqual('j') + expect(TheCommand.flags.json.exclusive).toEqual(['yml']) + expect(TheCommand.flags.yml.char).toEqual('y') + expect(TheCommand.flags.yml.exclusive).toEqual(['json']) +}) + +test('--json and --yml cannot be used together', async () => { + await expect(TheCommand.run(['--json', '--yml'])).rejects.toThrow() +}) + +describe('text output', () => { + test('complete config', async () => { + const logSpy = jest.spyOn(TheCommand.prototype, 'log').mockReturnValue() + await TheCommand.run([]) + expect(logSpy).toHaveBeenCalledWith(`This app is set to use:${EOL}` + [ + '1. Org: org name', + '2. Project: projectname', + '3. Workspace: workspacename' + ].join(EOL)) + }) + + test('no config set', async () => { + fakeCurrentConfig = {} + setConfigMock() + const logSpy = jest.spyOn(TheCommand.prototype, 'log').mockReturnValue() + await TheCommand.run([]) + expect(logSpy).toHaveBeenCalledWith(`This app is set to use:${EOL}` + [ + '1. Org: ', + '2. Project: ', + '3. Workspace: ' + ].join(EOL)) + }) +}) + +test('--json output', async () => { + const logSpy = jest.spyOn(TheCommand.prototype, 'log').mockReturnValue() + await TheCommand.run(['--json']) + expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ + org: { name: 'org name', id: 'org-id' }, + project: { name: 'projectname', id: 'projectid' }, + workspace: { name: 'workspacename', id: 'workspaceid' } + }, null, 2)) +}) + +test('--yml output', async () => { + const logSpy = jest.spyOn(TheCommand.prototype, 'log').mockReturnValue() + await TheCommand.run(['--yml']) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('org:')) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('name: org name')) +}) diff --git a/test/lib/console-helper.test.js b/test/lib/console-helper.test.js new file mode 100644 index 000000000..22b4dc9ed --- /dev/null +++ b/test/lib/console-helper.test.js @@ -0,0 +1,109 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { EOL } = require('os') + +jest.mock('@adobe/aio-lib-core-config') +const mockConfig = require('@adobe/aio-lib-core-config') + +const { loadCurrentConfiguration, configString, isCompleteConfig } = require('../../src/lib/console-helper') + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('loadCurrentConfiguration', () => { + test('complete project config', () => { + mockConfig.get.mockImplementation(k => { + if (k === 'project') { + return { + name: 'projectname', + id: 'projectid', + org: { name: 'org name', id: 'org-id' }, + workspace: { name: 'workspacename', id: 'workspaceid' } + } + } + }) + expect(loadCurrentConfiguration()).toEqual({ + org: { name: 'org name', id: 'org-id' }, + project: { name: 'projectname', id: 'projectid' }, + workspace: { name: 'workspacename', id: 'workspaceid' } + }) + }) + + test('no project config set', () => { + mockConfig.get.mockReturnValue(undefined) + expect(loadCurrentConfiguration()).toEqual({ + org: {}, + project: { name: undefined, id: undefined }, + workspace: {} + }) + }) + + test('project config without org/workspace', () => { + mockConfig.get.mockReturnValue({ name: 'projectname', id: 'projectid' }) + expect(loadCurrentConfiguration()).toEqual({ + org: {}, + project: { name: 'projectname', id: 'projectid' }, + workspace: {} + }) + }) +}) + +describe('configString', () => { + test('complete config', () => { + const result = configString({ + org: { name: 'my org' }, + project: { name: 'my project' }, + workspace: { name: 'my workspace' } + }) + expect(result).toEqual([ + '1. Org: my org', + '2. Project: my project', + '3. Workspace: my workspace' + ].join(EOL)) + }) + + test('empty config uses placeholders', () => { + const result = configString({}) + expect(result).toEqual([ + '1. Org: ', + '2. Project: ', + '3. Workspace: ' + ].join(EOL)) + }) + + test('indents by the given number of spaces', () => { + const result = configString({ org: { name: 'my org' } }, 4) + expect(result.split(EOL)[0]).toEqual(' 1. Org: my org') + }) +}) + +describe('isCompleteConfig', () => { + test('complete config', () => { + expect(isCompleteConfig({ + org: { id: 'oid', name: 'oname' }, + project: { id: 'pid', name: 'pname' }, + workspace: { id: 'wid', name: 'wname' } + })).toBeTruthy() + }) + + test('missing org', () => { + expect(isCompleteConfig({ + project: { id: 'pid', name: 'pname' }, + workspace: { id: 'wid', name: 'wname' } + })).toBeFalsy() + }) + + test('null config', () => { + expect(isCompleteConfig(null)).toBeFalsy() + }) +}) From 88deb065c1e56c79d2e4b730e0d579d8476012ed Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 6 Aug 2026 16:28:40 +0200 Subject: [PATCH 2/4] fix: error out when app console where/open lack local .aio config Previously these commands silently fell back to the global console config when no local .aio existed for the app, which could show or open an unrelated org/project/workspace. Now they fail fast with a clear message pointing to `aio app use` instead. --- src/commands/app/console/open.js | 7 ++++++- src/commands/app/console/where.js | 7 ++++++- src/lib/console-helper.js | 13 ++++++++++++- test/commands/app/console/open.test.js | 14 +++++++++++--- test/commands/app/console/where.test.js | 13 ++++++++++--- test/lib/console-helper.test.js | 22 +++++++++++++++++++++- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/commands/app/console/open.js b/src/commands/app/console/open.js index e013c92bf..09bba6d01 100644 --- a/src/commands/app/console/open.js +++ b/src/commands/app/console/open.js @@ -12,12 +12,17 @@ governing permissions and limitations under the License. const open = require('open') const { getCliEnv } = require('@adobe/aio-lib-env') const BaseCommand = require('../../../BaseCommand') -const { loadCurrentConfiguration } = require('../../../lib/console-helper') +const { loadCurrentConfiguration, hasLocalConfiguration } = require('../../../lib/console-helper') const { OPEN_URLS } = require('../../../lib/defaults') class OpenCommand extends BaseCommand { async run () { await this.parse(OpenCommand) + + if (!hasLocalConfiguration()) { + this.error('No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.') + } + const { org, project, workspace } = loadCurrentConfiguration() if (!org.id || !project.id) { diff --git a/src/commands/app/console/where.js b/src/commands/app/console/where.js index a3f60d164..4fefdd660 100644 --- a/src/commands/app/console/where.js +++ b/src/commands/app/console/where.js @@ -12,12 +12,17 @@ governing permissions and limitations under the License. const { Flags } = require('@oclif/core') const yaml = require('js-yaml') const BaseCommand = require('../../../BaseCommand') -const { loadCurrentConfiguration, configString } = require('../../../lib/console-helper') +const { loadCurrentConfiguration, configString, hasLocalConfiguration } = require('../../../lib/console-helper') const { EOL } = require('os') class WhereCommand extends BaseCommand { async run () { const { flags } = await this.parse(WhereCommand) + + if (!hasLocalConfiguration()) { + this.error('No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.') + } + const currentConfig = loadCurrentConfiguration() if (flags.json) { diff --git a/src/lib/console-helper.js b/src/lib/console-helper.js index 818c0bc7c..c0e032ed7 100644 --- a/src/lib/console-helper.js +++ b/src/lib/console-helper.js @@ -54,8 +54,19 @@ function isCompleteConfig (config) { config.workspace && config.workspace.id && config.workspace.name } +/** + * Checks whether a local `.aio` file (as written by `aio app use`) defines the + * project configuration, as opposed to it only being present in the global config. + * + * @returns {boolean} true if a local `.aio` file defines the project configuration + */ +function hasLocalConfiguration () { + return !!config.get('project', 'local') +} + module.exports = { loadCurrentConfiguration, configString, - isCompleteConfig + isCompleteConfig, + hasLocalConfiguration } diff --git a/test/commands/app/console/open.test.js b/test/commands/app/console/open.test.js index 6ed13d8e0..061341414 100644 --- a/test/commands/app/console/open.test.js +++ b/test/commands/app/console/open.test.js @@ -20,10 +20,10 @@ const libEnv = require('@adobe/aio-lib-env') let fakeCurrentConfig = {} /** @private */ -function setConfigMock () { - mockConfig.get.mockImplementation(k => { +function setConfigMock (localConfig = fakeCurrentConfig) { + mockConfig.get.mockImplementation((k, source) => { if (k === 'project') { - return fakeCurrentConfig + return source === 'local' ? localConfig : fakeCurrentConfig } }) } @@ -89,3 +89,11 @@ test('errors when project is missing', async () => { ) expect(open).not.toHaveBeenCalled() }) + +test('errors when no local .aio configuration is found', async () => { + setConfigMock(undefined) + await expect(TheCommand.run([])).rejects.toThrow( + 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' + ) + expect(open).not.toHaveBeenCalled() +}) diff --git a/test/commands/app/console/where.test.js b/test/commands/app/console/where.test.js index 14bc41c12..583428df8 100644 --- a/test/commands/app/console/where.test.js +++ b/test/commands/app/console/where.test.js @@ -18,10 +18,10 @@ const mockConfig = require('@adobe/aio-lib-core-config') let fakeCurrentConfig = {} /** @private */ -function setConfigMock () { - mockConfig.get.mockImplementation(k => { +function setConfigMock (localConfig = fakeCurrentConfig) { + mockConfig.get.mockImplementation((k, source) => { if (k === 'project') { - return fakeCurrentConfig + return source === 'local' ? localConfig : fakeCurrentConfig } }) } @@ -54,6 +54,13 @@ test('--json and --yml cannot be used together', async () => { await expect(TheCommand.run(['--json', '--yml'])).rejects.toThrow() }) +test('errors when no local .aio configuration is found', async () => { + setConfigMock(undefined) + await expect(TheCommand.run([])).rejects.toThrow( + 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' + ) +}) + describe('text output', () => { test('complete config', async () => { const logSpy = jest.spyOn(TheCommand.prototype, 'log').mockReturnValue() diff --git a/test/lib/console-helper.test.js b/test/lib/console-helper.test.js index 22b4dc9ed..d2a4900d3 100644 --- a/test/lib/console-helper.test.js +++ b/test/lib/console-helper.test.js @@ -14,7 +14,7 @@ const { EOL } = require('os') jest.mock('@adobe/aio-lib-core-config') const mockConfig = require('@adobe/aio-lib-core-config') -const { loadCurrentConfiguration, configString, isCompleteConfig } = require('../../src/lib/console-helper') +const { loadCurrentConfiguration, configString, isCompleteConfig, hasLocalConfiguration } = require('../../src/lib/console-helper') beforeEach(() => { jest.clearAllMocks() @@ -107,3 +107,23 @@ describe('isCompleteConfig', () => { expect(isCompleteConfig(null)).toBeFalsy() }) }) + +describe('hasLocalConfiguration', () => { + test('local .aio defines the project config', () => { + mockConfig.get.mockImplementation((k, source) => { + if (k === 'project' && source === 'local') { + return { id: 'projectid', name: 'projectname' } + } + }) + expect(hasLocalConfiguration()).toBe(true) + }) + + test('no local .aio file (only global config)', () => { + mockConfig.get.mockImplementation((k, source) => { + if (k === 'project' && source === 'local') { + return undefined + } + }) + expect(hasLocalConfiguration()).toBe(false) + }) +}) From 53158ec1419220e0cd766ede1407a46df0dfa26c Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 6 Aug 2026 16:53:42 +0200 Subject: [PATCH 3/4] fix: app console where/open never fall back to global config fields Previously loadCurrentConfiguration() merged the local .aio file with the global console config, so a local .aio missing e.g. workspace would silently inherit a stale/unrelated workspace from the global config. These commands now read local-only config exclusively, and hasLocalConfiguration() only requires org+project locally (workspace remains a legitimate optional/partial state). --- src/commands/app/console/open.js | 9 +----- src/commands/app/console/where.js | 2 +- src/lib/console-helper.js | 20 ++++++++----- test/commands/app/console/open.test.js | 32 +++++++++++++++++---- test/commands/app/console/where.test.js | 38 +++++++++++++++++++++++-- test/lib/console-helper.test.js | 31 ++++++++++++++++++-- 6 files changed, 105 insertions(+), 27 deletions(-) diff --git a/src/commands/app/console/open.js b/src/commands/app/console/open.js index 09bba6d01..36e746413 100644 --- a/src/commands/app/console/open.js +++ b/src/commands/app/console/open.js @@ -23,14 +23,7 @@ class OpenCommand extends BaseCommand { this.error('No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.') } - const { org, project, workspace } = loadCurrentConfiguration() - - if (!org.id || !project.id) { - this.error( - 'Incomplete .aio configuration, cannot open the Developer Console.' + - ' Please import a valid Adobe Developer Console configuration file via `aio app use .json`.' - ) - } + const { org, project, workspace } = loadCurrentConfiguration('local') let url = `${OPEN_URLS[getCliEnv()]}/${org.id}/${project.id}/` url += workspace.id ? `workspaces/${workspace.id}/details` : 'overview' diff --git a/src/commands/app/console/where.js b/src/commands/app/console/where.js index 4fefdd660..6f5ff6e65 100644 --- a/src/commands/app/console/where.js +++ b/src/commands/app/console/where.js @@ -23,7 +23,7 @@ class WhereCommand extends BaseCommand { this.error('No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.') } - const currentConfig = loadCurrentConfiguration() + const currentConfig = loadCurrentConfiguration('local') if (flags.json) { this.log(JSON.stringify(currentConfig, null, 2)) diff --git a/src/lib/console-helper.js b/src/lib/console-helper.js index c0e032ed7..affe798bd 100644 --- a/src/lib/console-helper.js +++ b/src/lib/console-helper.js @@ -13,12 +13,14 @@ const config = require('@adobe/aio-lib-core-config') const { EOL } = require('os') /** - * Loads the local (per-app) org/project/workspace configuration, as set by `aio app use`. + * Loads the per-app org/project/workspace configuration, as set by `aio app use`. * + * @param {string} [source] pass 'local' to only read from the local `.aio` file, + * bypassing the merge with the global config * @returns {object} { org, project, workspace } */ -function loadCurrentConfiguration () { - const projectConfig = config.get('project') || {} +function loadCurrentConfiguration (source) { + const projectConfig = config.get('project', source) || {} const org = (projectConfig.org && { id: projectConfig.org.id, name: projectConfig.org.name }) || {} const project = { name: projectConfig.name, id: projectConfig.id } const workspace = (projectConfig.workspace && { ...projectConfig.workspace }) || {} @@ -55,13 +57,17 @@ function isCompleteConfig (config) { } /** - * Checks whether a local `.aio` file (as written by `aio app use`) defines the - * project configuration, as opposed to it only being present in the global config. + * Checks whether a local `.aio` file (as written by `aio app use`) identifies an + * Org and Project on its own. Workspace is intentionally not required here, as + * an Org/Project can be locally selected before a Workspace is (e.g. via `aio app use --global` + * flows or partial imports) - callers should treat a missing local Workspace as + * "no workspace selected", not as "no local configuration at all". * - * @returns {boolean} true if a local `.aio` file defines the project configuration + * @returns {boolean} true if the local `.aio` file identifies an Org and Project */ function hasLocalConfiguration () { - return !!config.get('project', 'local') + const { org, project } = loadCurrentConfiguration('local') + return !!(org.id && org.name && project.id && project.name) } module.exports = { diff --git a/test/commands/app/console/open.test.js b/test/commands/app/console/open.test.js index 061341414..f3759d797 100644 --- a/test/commands/app/console/open.test.js +++ b/test/commands/app/console/open.test.js @@ -70,22 +70,20 @@ test('opens the project overview url when there is no workspace selected', async ) }) -test('errors when org is missing', async () => { +test('errors when org is missing locally', async () => { delete fakeCurrentConfig.org setConfigMock() await expect(TheCommand.run([])).rejects.toThrow( - 'Incomplete .aio configuration, cannot open the Developer Console.' + - ' Please import a valid Adobe Developer Console configuration file via `aio app use .json`.' + 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' ) expect(open).not.toHaveBeenCalled() }) -test('errors when project is missing', async () => { +test('errors when project is missing locally', async () => { fakeCurrentConfig = {} setConfigMock() await expect(TheCommand.run([])).rejects.toThrow( - 'Incomplete .aio configuration, cannot open the Developer Console.' + - ' Please import a valid Adobe Developer Console configuration file via `aio app use .json`.' + 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' ) expect(open).not.toHaveBeenCalled() }) @@ -97,3 +95,25 @@ test('errors when no local .aio configuration is found', async () => { ) expect(open).not.toHaveBeenCalled() }) + +test('global config is never used, even if it defines a complete org/project/workspace', async () => { + // local config only has org+project, no workspace - global has a full (different) config + delete fakeCurrentConfig.workspace + setConfigMock() + mockConfig.get.mockImplementation((k, source) => { + if (k === 'project') { + if (source === 'local') return fakeCurrentConfig + // global/merged fallback: a different, complete project - must never be used + return { + name: 'globalprojectname', + id: 'globalprojectid', + org: { name: 'global org name', id: 'global-org-id' }, + workspace: { name: 'globalworkspacename', id: 'globalworkspaceid' } + } + } + }) + await TheCommand.run([]) + expect(open).toHaveBeenCalledWith( + 'https://developer.adobe.com/console/projects/org-id/projectid/overview' + ) +}) diff --git a/test/commands/app/console/where.test.js b/test/commands/app/console/where.test.js index 583428df8..9709f1e42 100644 --- a/test/commands/app/console/where.test.js +++ b/test/commands/app/console/where.test.js @@ -72,14 +72,46 @@ describe('text output', () => { ].join(EOL)) }) - test('no config set', async () => { + test('errors when local .aio config is present but empty', async () => { fakeCurrentConfig = {} setConfigMock() + await expect(TheCommand.run([])).rejects.toThrow( + 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' + ) + }) + + test('shows the no-workspace-selected placeholder when local .aio has no workspace', async () => { + delete fakeCurrentConfig.workspace + setConfigMock() const logSpy = jest.spyOn(TheCommand.prototype, 'log').mockReturnValue() await TheCommand.run([]) expect(logSpy).toHaveBeenCalledWith(`This app is set to use:${EOL}` + [ - '1. Org: ', - '2. Project: ', + '1. Org: org name', + '2. Project: projectname', + '3. Workspace: ' + ].join(EOL)) + }) + + test('global config is never used, even if it defines a complete org/project/workspace', async () => { + delete fakeCurrentConfig.workspace + setConfigMock() + mockConfig.get.mockImplementation((k, source) => { + if (k === 'project') { + if (source === 'local') return fakeCurrentConfig + // global/merged fallback: a different, complete project - must never be used + return { + name: 'globalprojectname', + id: 'globalprojectid', + org: { name: 'global org name', id: 'global-org-id' }, + workspace: { name: 'globalworkspacename', id: 'globalworkspaceid' } + } + } + }) + const logSpy = jest.spyOn(TheCommand.prototype, 'log').mockReturnValue() + await TheCommand.run([]) + expect(logSpy).toHaveBeenCalledWith(`This app is set to use:${EOL}` + [ + '1. Org: org name', + '2. Project: projectname', '3. Workspace: ' ].join(EOL)) }) diff --git a/test/lib/console-helper.test.js b/test/lib/console-helper.test.js index d2a4900d3..749ddbf8d 100644 --- a/test/lib/console-helper.test.js +++ b/test/lib/console-helper.test.js @@ -109,10 +109,15 @@ describe('isCompleteConfig', () => { }) describe('hasLocalConfiguration', () => { - test('local .aio defines the project config', () => { + test('local .aio fully defines org, project, and workspace', () => { mockConfig.get.mockImplementation((k, source) => { if (k === 'project' && source === 'local') { - return { id: 'projectid', name: 'projectname' } + return { + id: 'projectid', + name: 'projectname', + org: { id: 'org-id', name: 'org name' }, + workspace: { id: 'workspaceid', name: 'workspacename' } + } } }) expect(hasLocalConfiguration()).toBe(true) @@ -126,4 +131,26 @@ describe('hasLocalConfiguration', () => { }) expect(hasLocalConfiguration()).toBe(false) }) + + test('local .aio defines org/project but is missing workspace (workspace is optional)', () => { + mockConfig.get.mockImplementation((k, source) => { + if (k === 'project' && source === 'local') { + return { + id: 'projectid', + name: 'projectname', + org: { id: 'org-id', name: 'org name' } + } + } + }) + expect(hasLocalConfiguration()).toBe(true) + }) + + test('local .aio defines a project but is missing org', () => { + mockConfig.get.mockImplementation((k, source) => { + if (k === 'project' && source === 'local') { + return { id: 'projectid', name: 'projectname' } + } + }) + expect(hasLocalConfiguration()).toBe(false) + }) }) From be3bee9836e9b7ff174901601f4918a23f0c1dd6 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 6 Aug 2026 16:59:20 +0200 Subject: [PATCH 4/4] fix: make console where/open error message consistent with output wording Both commands referred to "this app" being linked/configured with different phrasing than the success output's "This app is set to use:" - reword the error to match. --- src/commands/app/console/open.js | 2 +- src/commands/app/console/where.js | 2 +- test/commands/app/console/open.test.js | 6 +++--- test/commands/app/console/where.test.js | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/commands/app/console/open.js b/src/commands/app/console/open.js index 36e746413..18a8fb68b 100644 --- a/src/commands/app/console/open.js +++ b/src/commands/app/console/open.js @@ -20,7 +20,7 @@ class OpenCommand extends BaseCommand { await this.parse(OpenCommand) if (!hasLocalConfiguration()) { - this.error('No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.') + this.error('This app is not set to use any Org/Project/Workspace yet. Run `aio app use` to configure it.') } const { org, project, workspace } = loadCurrentConfiguration('local') diff --git a/src/commands/app/console/where.js b/src/commands/app/console/where.js index 6f5ff6e65..a3dc6e099 100644 --- a/src/commands/app/console/where.js +++ b/src/commands/app/console/where.js @@ -20,7 +20,7 @@ class WhereCommand extends BaseCommand { const { flags } = await this.parse(WhereCommand) if (!hasLocalConfiguration()) { - this.error('No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.') + this.error('This app is not set to use any Org/Project/Workspace yet. Run `aio app use` to configure it.') } const currentConfig = loadCurrentConfiguration('local') diff --git a/test/commands/app/console/open.test.js b/test/commands/app/console/open.test.js index f3759d797..6308d04ed 100644 --- a/test/commands/app/console/open.test.js +++ b/test/commands/app/console/open.test.js @@ -74,7 +74,7 @@ test('errors when org is missing locally', async () => { delete fakeCurrentConfig.org setConfigMock() await expect(TheCommand.run([])).rejects.toThrow( - 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' + 'This app is not set to use any Org/Project/Workspace yet. Run `aio app use` to configure it.' ) expect(open).not.toHaveBeenCalled() }) @@ -83,7 +83,7 @@ test('errors when project is missing locally', async () => { fakeCurrentConfig = {} setConfigMock() await expect(TheCommand.run([])).rejects.toThrow( - 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' + 'This app is not set to use any Org/Project/Workspace yet. Run `aio app use` to configure it.' ) expect(open).not.toHaveBeenCalled() }) @@ -91,7 +91,7 @@ test('errors when project is missing locally', async () => { test('errors when no local .aio configuration is found', async () => { setConfigMock(undefined) await expect(TheCommand.run([])).rejects.toThrow( - 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' + 'This app is not set to use any Org/Project/Workspace yet. Run `aio app use` to configure it.' ) expect(open).not.toHaveBeenCalled() }) diff --git a/test/commands/app/console/where.test.js b/test/commands/app/console/where.test.js index 9709f1e42..6df4e0348 100644 --- a/test/commands/app/console/where.test.js +++ b/test/commands/app/console/where.test.js @@ -57,7 +57,7 @@ test('--json and --yml cannot be used together', async () => { test('errors when no local .aio configuration is found', async () => { setConfigMock(undefined) await expect(TheCommand.run([])).rejects.toThrow( - 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' + 'This app is not set to use any Org/Project/Workspace yet. Run `aio app use` to configure it.' ) }) @@ -76,7 +76,7 @@ describe('text output', () => { fakeCurrentConfig = {} setConfigMock() await expect(TheCommand.run([])).rejects.toThrow( - 'No local .aio configuration found for this app. Run `aio app use` to link this app to an Org/Project/Workspace.' + 'This app is not set to use any Org/Project/Workspace yet. Run `aio app use` to configure it.' ) })