From 64f1b7bbfe48cac74280aaa6c0547d700445c7b0 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 24 Mar 2026 18:46:52 -0400 Subject: [PATCH 1/4] Add `mrt save-credentials` command Adds a new command to save MRT credentials (username and API key) to the ~/.mobify file, replacing the manual echo/JSON workflow. Supports --credentials-file override, --cloud-origin for alternate file paths, and --yes to skip overwrite confirmation. File is written with 0o600 permissions. --- docs/cli/mrt.md | 44 +++- docs/guide/authentication.md | 8 +- .../src/commands/mrt/save-credentials.ts | 128 ++++++++++ .../commands/mrt/save-credentials.test.ts | 234 ++++++++++++++++++ 4 files changed, 401 insertions(+), 13 deletions(-) create mode 100644 packages/b2c-cli/src/commands/mrt/save-credentials.ts create mode 100644 packages/b2c-cli/test/commands/mrt/save-credentials.test.ts diff --git a/docs/cli/mrt.md b/docs/cli/mrt.md index c4a285ec..b4c72f0c 100644 --- a/docs/cli/mrt.md +++ b/docs/cli/mrt.md @@ -20,6 +20,7 @@ Commands for managing Managed Runtime (MRT) projects, environments, and bundles | `mrt env access-control` | `list` | Manage access control headers | | `mrt bundle` | `deploy`, `list`, `history`, `download` | Manage bundles and deployments | | `mrt tail-logs` | | Tail real-time application logs | +| `mrt save-credentials` | | Save MRT credentials to ~/.mobify | | `mrt user` | `profile`, `api-key`, `email-prefs` | Manage user settings | ## Global MRT Flags @@ -55,15 +56,9 @@ MRT commands use API key authentication. The API key is configured in the Manage Provide the API key via one of these methods: -1. **Command-line flag**: `--api-key your-api-key` -2. **Environment variable**: `export MRT_API_KEY=your-api-key` -3. **Mobify config file**: `~/.mobify` with `api_key` field - -```json -{ - "api_key": "your-mrt-api-key" -} -``` +1. **Save credentials** (recommended): `b2c mrt save-credentials --user you@example.com --api-key your-api-key` +2. **Command-line flag**: `--api-key your-api-key` +3. **Environment variable**: `export MRT_API_KEY=your-api-key` For complete setup instructions, see the [Authentication Guide](/guide/authentication#managed-runtime-api-key). @@ -539,6 +534,37 @@ b2c mrt tail-logs -p my-storefront -e staging --json --- +## Save Credentials + +### b2c mrt save-credentials + +Save MRT credentials (username and API key) to the `~/.mobify` file. Prompts for confirmation before overwriting an existing file. + +```bash +# Save credentials +b2c mrt save-credentials --user user@example.com --api-key abc123 + +# Overwrite without confirmation +b2c mrt save-credentials --user user@example.com --api-key abc123 --yes + +# Save to a custom credentials file +b2c mrt save-credentials --user user@example.com --api-key abc123 --credentials-file ./my-creds + +# Save for a specific cloud origin (writes to ~/.mobify--) +b2c mrt save-credentials --user user@example.com --api-key abc123 --cloud-origin https://cloud-staging.example.com +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--user` | MRT username (email). Required. | +| `--api-key` | MRT API key. Required. | +| `--cloud-origin` | MRT cloud origin URL. Determines the credentials file path (e.g., `~/.mobify--`). | +| `--credentials-file` | Explicit path to credentials file (overrides default `~/.mobify`). | +| `--yes`, `-y` | Overwrite existing credentials without confirmation. | + +--- + ## User Commands ### b2c mrt user profile diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 6907348f..2a2645fa 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -374,11 +374,11 @@ MRT commands use a separate API key system. ### Configuring the API Key ```bash -# Environment variable -export MRT_API_KEY=your-mrt-api-key +# Save credentials to ~/.mobify +b2c mrt save-credentials --user your-email@example.com --api-key your-mrt-api-key -# Or in ~/.mobify config file -echo '{"api_key": "your-mrt-api-key"}' > ~/.mobify +# Or use an environment variable +export MRT_API_KEY=your-mrt-api-key ``` ## Quick Start Example diff --git a/packages/b2c-cli/src/commands/mrt/save-credentials.ts b/packages/b2c-cli/src/commands/mrt/save-credentials.ts new file mode 100644 index 00000000..338439e7 --- /dev/null +++ b/packages/b2c-cli/src/commands/mrt/save-credentials.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import * as fsp from 'node:fs/promises'; +import * as os from 'node:os'; +import path from 'node:path'; +import * as readline from 'node:readline'; +import {Flags} from '@oclif/core'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {DEFAULT_MRT_ORIGIN} from '@salesforce/b2c-tooling-sdk/clients'; +import {t, withDocs} from '../../i18n/index.js'; + +interface MobifyConfigFile { + username?: string; + api_key?: string; +} + +/** + * Save MRT credentials to the ~/.mobify file. + */ +export default class MrtSaveCredentials extends BaseCommand { + static description = withDocs( + t('commands.mrt.save-credentials.description', 'Save MRT credentials to the ~/.mobify file'), + '/cli/mrt.html#b2c-mrt-save-credentials', + ); + + static examples = [ + '<%= config.bin %> <%= command.id %> --user user@example.com --api-key abc123', + '<%= config.bin %> <%= command.id %> --user user@example.com --api-key abc123 --yes', + '<%= config.bin %> <%= command.id %> --user user@example.com --api-key abc123 --credentials-file ./my-creds', + '<%= config.bin %> <%= command.id %> --user user@example.com --api-key abc123 --cloud-origin https://custom.example.com', + ]; + + static flags = { + ...BaseCommand.baseFlags, + user: Flags.string({ + description: 'MRT username (email)', + required: true, + }), + 'api-key': Flags.string({ + description: 'MRT API key', + required: true, + }), + 'cloud-origin': Flags.string({ + description: `MRT cloud origin URL (determines credentials file path; default: ${DEFAULT_MRT_ORIGIN})`, + env: 'MRT_CLOUD_ORIGIN', + default: async () => process.env.SFCC_MRT_CLOUD_ORIGIN || undefined, + }), + 'credentials-file': Flags.string({ + description: 'Path to MRT credentials file (overrides default ~/.mobify)', + env: 'MRT_CREDENTIALS_FILE', + }), + yes: Flags.boolean({ + char: 'y', + description: 'Overwrite existing credentials without confirmation', + default: false, + }), + }; + + async run(): Promise { + const {user, 'api-key': apiKey, 'cloud-origin': cloudOrigin, 'credentials-file': credentialsFile, yes} = this.flags; + + const mobifyPath = credentialsFile ?? this.getMobifyPath(cloudOrigin); + + const credentials: MobifyConfigFile = { + username: user, + api_key: apiKey, + }; + + // Check if file already exists + if (!yes) { + let fileExists = false; + try { + await fsp.access(mobifyPath); + fileExists = true; + } catch { + // File does not exist, proceed + } + + if (fileExists) { + const confirmed = await this.confirm( + t( + 'commands.mrt.save-credentials.confirm', + 'Credentials file already exists at {{path}}.\nOverwrite? (yes/no): ', + {path: mobifyPath}, + ), + ); + if (!confirmed) { + this.error('Save cancelled.'); + } + } + } + + await fsp.writeFile(mobifyPath, JSON.stringify(credentials, null, 2) + '\n', {encoding: 'utf8', mode: 0o600}); + + this.log(t('commands.mrt.save-credentials.success', 'Credentials saved to {{path}}', {path: mobifyPath})); + + return credentials; + } + + private getMobifyPath(cloudOrigin?: string): string { + if (cloudOrigin) { + try { + const url = new URL(cloudOrigin); + return path.join(os.homedir(), `.mobify--${url.hostname}`); + } catch { + return path.join(os.homedir(), `.mobify--${cloudOrigin}`); + } + } + return path.join(os.homedir(), '.mobify'); + } + + private async confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + + return new Promise((resolve) => { + rl.question(message, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); + }); + }); + } +} diff --git a/packages/b2c-cli/test/commands/mrt/save-credentials.test.ts b/packages/b2c-cli/test/commands/mrt/save-credentials.test.ts new file mode 100644 index 00000000..40c91f29 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/save-credentials.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import path from 'node:path'; +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import MrtSaveCredentials from '../../../src/commands/mrt/save-credentials.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../helpers/stub-parse.js'; +import {runSilent} from '../../helpers/test-setup.js'; + +describe('mrt save-credentials', () => { + let config: Config; + let tempDir: string; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'b2c-save-creds-')); + }); + + afterEach(async () => { + sinon.restore(); + restoreConfig(); + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + function createCommand(flags: Record = {}): any { + const command = new MrtSaveCredentials([], config); + stubParse(command, flags, {}); + return command; + } + + it('writes credentials to the specified file', async () => { + const credFile = path.join(tempDir, '.mobify'); + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'abc123', + 'credentials-file': credFile, + }); + await command.init(); + sinon.stub(command, 'log'); + + const result = (await runSilent(() => command.run())) as {username: string; api_key: string}; + + expect(result).to.deep.equal({username: 'user@example.com', api_key: 'abc123'}); + + const content = JSON.parse(await fs.readFile(credFile, 'utf8')); + expect(content).to.deep.equal({username: 'user@example.com', api_key: 'abc123'}); + }); + + it('sets file permissions to 0o600', async () => { + const credFile = path.join(tempDir, '.mobify'); + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'key123', + 'credentials-file': credFile, + }); + await command.init(); + sinon.stub(command, 'log'); + + await runSilent(() => command.run()); + + const stat = await fs.stat(credFile); + // eslint-disable-next-line no-bitwise -- checking file permission bits + expect(stat.mode & 0o777).to.equal(0o600); + }); + + it('writes to default ~/.mobify when no --credentials-file or --cloud-origin', async () => { + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'key123', + }); + await command.init(); + sinon.stub(command, 'log'); + + // Stub getMobifyPath to use temp dir instead of real home + sinon.stub(command as any, 'getMobifyPath').returns(path.join(tempDir, '.mobify')); + + await runSilent(() => command.run()); + + const content = JSON.parse(await fs.readFile(path.join(tempDir, '.mobify'), 'utf8')); + expect(content.api_key).to.equal('key123'); + }); + + it('uses --cloud-origin hostname for file suffix', async () => { + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'key123', + 'cloud-origin': 'https://cloud-staging.example.com', + }); + await command.init(); + sinon.stub(command, 'log'); + + // Redirect getMobifyPath to use temp dir + const expectedFile = path.join(tempDir, '.mobify--cloud-staging.example.com'); + sinon.stub(command as any, 'getMobifyPath').returns(expectedFile); + + await runSilent(() => command.run()); + + const content = JSON.parse(await fs.readFile(expectedFile, 'utf8')); + expect(content.api_key).to.equal('key123'); + }); + + it('--credentials-file takes precedence over --cloud-origin', async () => { + const credFile = path.join(tempDir, 'my-creds'); + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'key123', + 'cloud-origin': 'https://cloud-staging.example.com', + 'credentials-file': credFile, + }); + await command.init(); + sinon.stub(command, 'log'); + + await runSilent(() => command.run()); + + const content = JSON.parse(await fs.readFile(credFile, 'utf8')); + expect(content.api_key).to.equal('key123'); + }); + + it('creates new file without confirmation when file does not exist', async () => { + const credFile = path.join(tempDir, '.mobify'); + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'key123', + 'credentials-file': credFile, + }); + await command.init(); + sinon.stub(command, 'log'); + + // confirm should not be called + const confirmStub = sinon.stub(command as any, 'confirm'); + + await runSilent(() => command.run()); + + expect(confirmStub.called).to.equal(false); + }); + + it('prompts for confirmation when file already exists', async () => { + const credFile = path.join(tempDir, '.mobify'); + await fs.writeFile(credFile, '{"api_key": "old"}', 'utf8'); + + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'newkey', + 'credentials-file': credFile, + }); + await command.init(); + sinon.stub(command, 'log'); + + sinon.stub(command as any, 'confirm').resolves(true); + + await runSilent(() => command.run()); + + const content = JSON.parse(await fs.readFile(credFile, 'utf8')); + expect(content.api_key).to.equal('newkey'); + }); + + it('aborts when user declines overwrite confirmation', async () => { + const credFile = path.join(tempDir, '.mobify'); + await fs.writeFile(credFile, '{"api_key": "old"}', 'utf8'); + + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'newkey', + 'credentials-file': credFile, + }); + await command.init(); + sinon.stub(command, 'log'); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Save cancelled.')); + sinon.stub(command as any, 'confirm').resolves(false); + + try { + await command.run(); + expect.fail('Expected command to throw'); + } catch { + // Expected + } + + expect(errorStub.calledOnce).to.equal(true); + + // File should not be modified + const content = JSON.parse(await fs.readFile(credFile, 'utf8')); + expect(content.api_key).to.equal('old'); + }); + + it('--yes skips confirmation even when file exists', async () => { + const credFile = path.join(tempDir, '.mobify'); + await fs.writeFile(credFile, '{"api_key": "old"}', 'utf8'); + + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'newkey', + 'credentials-file': credFile, + yes: true, + }); + await command.init(); + sinon.stub(command, 'log'); + + const confirmStub = sinon.stub(command as any, 'confirm'); + + await runSilent(() => command.run()); + + expect(confirmStub.called).to.equal(false); + + const content = JSON.parse(await fs.readFile(credFile, 'utf8')); + expect(content.api_key).to.equal('newkey'); + }); + + it('writes pretty-printed JSON with trailing newline', async () => { + const credFile = path.join(tempDir, '.mobify'); + const command = createCommand({ + user: 'user@example.com', + 'api-key': 'key123', + 'credentials-file': credFile, + }); + await command.init(); + sinon.stub(command, 'log'); + + await runSilent(() => command.run()); + + const raw = await fs.readFile(credFile, 'utf8'); + const expected = JSON.stringify({username: 'user@example.com', api_key: 'key123'}, null, 2) + '\n'; + expect(raw).to.equal(expected); + }); +}); From 2a41405578dd5ab34afa4f86b800f79731e9165f Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 24 Mar 2026 18:46:59 -0400 Subject: [PATCH 2/4] chore: add changeset for mrt save-credentials --- .changeset/mrt-save-credentials.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/mrt-save-credentials.md diff --git a/.changeset/mrt-save-credentials.md b/.changeset/mrt-save-credentials.md new file mode 100644 index 00000000..13bc04d4 --- /dev/null +++ b/.changeset/mrt-save-credentials.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-dx-docs': patch +--- + +Add `mrt save-credentials` command to save MRT API credentials to the ~/.mobify file From 3ca9cc7975409ed03c3d8f9301626f855997781a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 24 Mar 2026 18:51:11 -0400 Subject: [PATCH 3/4] Extract shared confirm prompt utility Move the duplicated confirm() pattern to a shared prompts.ts module. Use (y/N) format matching existing CLI conventions with default-no. --- .../src/commands/mrt/save-credentials.ts | 29 ++++++------------- packages/b2c-cli/src/prompts.ts | 26 +++++++++++++++++ 2 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 packages/b2c-cli/src/prompts.ts diff --git a/packages/b2c-cli/src/commands/mrt/save-credentials.ts b/packages/b2c-cli/src/commands/mrt/save-credentials.ts index 338439e7..fc7af6db 100644 --- a/packages/b2c-cli/src/commands/mrt/save-credentials.ts +++ b/packages/b2c-cli/src/commands/mrt/save-credentials.ts @@ -6,11 +6,11 @@ import * as fsp from 'node:fs/promises'; import * as os from 'node:os'; import path from 'node:path'; -import * as readline from 'node:readline'; import {Flags} from '@oclif/core'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {DEFAULT_MRT_ORIGIN} from '@salesforce/b2c-tooling-sdk/clients'; import {t, withDocs} from '../../i18n/index.js'; +import {confirm} from '../../prompts.js'; interface MobifyConfigFile { username?: string; @@ -81,11 +81,9 @@ export default class MrtSaveCredentials extends BaseCommand { + return confirm(message); + } + private getMobifyPath(cloudOrigin?: string): string { if (cloudOrigin) { try { @@ -111,18 +114,4 @@ export default class MrtSaveCredentials extends BaseCommand { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - - return new Promise((resolve) => { - rl.question(message, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); - }); - }); - } } diff --git a/packages/b2c-cli/src/prompts.ts b/packages/b2c-cli/src/prompts.ts new file mode 100644 index 00000000..ea95c0d5 --- /dev/null +++ b/packages/b2c-cli/src/prompts.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import * as readline from 'node:readline'; + +/** + * Simple yes/no confirmation prompt. Defaults to no. + * + * @param message - Prompt message (e.g., "Delete project? (y/N)") + * @returns true if user answered y/yes, false otherwise + */ +export async function confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + + return new Promise((resolve) => { + rl.question(`${message} `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} From 60c974af4c3fcec0799ddd065f0d2596cf9f1464 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 24 Mar 2026 20:01:55 -0400 Subject: [PATCH 4/4] Migrate all commands to shared confirm prompt utility Replace duplicated readline-based confirm functions across 10 commands with the shared confirm() from prompts.ts. The shared utility auto-appends (y/N) or (Y/n) hints and supports configurable defaults. --- packages/b2c-cli/src/commands/code/delete.ts | 21 ++------------- .../b2c-cli/src/commands/mrt/env/delete.ts | 21 ++------------- .../src/commands/mrt/env/redirect/clone.ts | 19 +------------- .../src/commands/mrt/env/redirect/delete.ts | 19 +------------- .../src/commands/mrt/project/delete.ts | 21 ++------------- .../src/commands/mrt/project/member/remove.ts | 19 +------------- .../mrt/project/notification/delete.ts | 19 +------------- .../src/commands/mrt/save-credentials.ts | 2 +- .../b2c-cli/src/commands/mrt/user/api-key.ts | 20 +++----------- .../b2c-cli/src/commands/sandbox/delete.ts | 21 ++------------- packages/b2c-cli/src/commands/webdav/rm.ts | 24 +++-------------- packages/b2c-cli/src/i18n/locales/en.ts | 2 +- packages/b2c-cli/src/prompts.ts | 26 ++++++++++++++----- .../b2c-cli/test/commands/webdav/rm.test.ts | 12 +++------ 14 files changed, 45 insertions(+), 201 deletions(-) diff --git a/packages/b2c-cli/src/commands/code/delete.ts b/packages/b2c-cli/src/commands/code/delete.ts index 302226d9..13be2c90 100644 --- a/packages/b2c-cli/src/commands/code/delete.ts +++ b/packages/b2c-cli/src/commands/code/delete.ts @@ -3,28 +3,11 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import * as readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {deleteCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; import {t, withDocs} from '../../i18n/index.js'; - -/** - * Simple confirmation prompt. - */ -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - - return new Promise((resolve) => { - rl.question(`${message} `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../prompts.js'; export default class CodeDelete extends InstanceCommand { static hiddenAliases = ['code:delete']; @@ -75,7 +58,7 @@ export default class CodeDelete extends InstanceCommand { const confirmed = await this.operations.confirm( t( 'commands.code.delete.confirm', - 'Are you sure you want to delete code version "{{codeVersion}}" on {{hostname}}? (y/n)', + 'Are you sure you want to delete code version "{{codeVersion}}" on {{hostname}}?', {codeVersion, hostname}, ), ); diff --git a/packages/b2c-cli/src/commands/mrt/env/delete.ts b/packages/b2c-cli/src/commands/mrt/env/delete.ts index ad549057..6cabd8f1 100644 --- a/packages/b2c-cli/src/commands/mrt/env/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/delete.ts @@ -3,28 +3,11 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import * as readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {deleteEnv} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../i18n/index.js'; - -/** - * Simple confirmation prompt. - */ -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - - return new Promise((resolve) => { - rl.question(`${message} `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../../prompts.js'; /** * Delete an environment (target) from a Managed Runtime project. @@ -83,7 +66,7 @@ export default class MrtEnvDelete extends MrtCommand { const confirmed = await this.operations.confirm( t( 'commands.mrt.env.delete.confirm', - 'Are you sure you want to delete environment "{{slug}}" from {{project}}? (y/n)', + 'Are you sure you want to delete environment "{{slug}}" from {{project}}?', { slug, project, diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/clone.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/clone.ts index 6436d3a6..00f10609 100644 --- a/packages/b2c-cli/src/commands/mrt/env/redirect/clone.ts +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/clone.ts @@ -3,28 +3,11 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import * as readline from 'node:readline'; import {Flags} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {cloneRedirects, type CloneRedirectsResult} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; - -/** - * Prompt for confirmation. - */ -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(`${message} (y/N): `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../../../prompts.js'; /** * Clone redirects from one environment to another. diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts index 256d7601..af6d783b 100644 --- a/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts @@ -3,28 +3,11 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import * as readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {deleteRedirect} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; - -/** - * Prompt for confirmation. - */ -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(`${message} (y/N): `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../../../prompts.js'; /** * Delete a redirect from an MRT environment. diff --git a/packages/b2c-cli/src/commands/mrt/project/delete.ts b/packages/b2c-cli/src/commands/mrt/project/delete.ts index 81b20c49..cb787124 100644 --- a/packages/b2c-cli/src/commands/mrt/project/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/project/delete.ts @@ -3,28 +3,11 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import * as readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {deleteProject} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../i18n/index.js'; - -/** - * Simple confirmation prompt. - */ -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - - return new Promise((resolve) => { - rl.question(`${message} `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../../prompts.js'; /** * Delete result for JSON output. @@ -78,7 +61,7 @@ export default class MrtProjectDelete extends MrtCommand { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(`${message} (y/N): `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../../../prompts.js'; /** * Remove a member from an MRT project. diff --git a/packages/b2c-cli/src/commands/mrt/project/notification/delete.ts b/packages/b2c-cli/src/commands/mrt/project/notification/delete.ts index 6e427d8b..51c65a09 100644 --- a/packages/b2c-cli/src/commands/mrt/project/notification/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/project/notification/delete.ts @@ -3,28 +3,11 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import * as readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {deleteNotification} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; - -/** - * Prompt for confirmation. - */ -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(`${message} (y/N): `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../../../prompts.js'; /** * Delete a notification from an MRT project. diff --git a/packages/b2c-cli/src/commands/mrt/save-credentials.ts b/packages/b2c-cli/src/commands/mrt/save-credentials.ts index fc7af6db..2a38054f 100644 --- a/packages/b2c-cli/src/commands/mrt/save-credentials.ts +++ b/packages/b2c-cli/src/commands/mrt/save-credentials.ts @@ -81,7 +81,7 @@ export default class MrtSaveCredentials extends BaseCommand { const {yes} = this.flags; if (!yes && !this.jsonEnabled()) { - const confirmed = await this.confirm( + const confirmed = await confirm( t( 'commands.mrt.user.api-key.confirm', - 'Warning: This will invalidate your current API key.\nAre you sure you want to reset your API key? (yes/no): ', + 'Warning: This will invalidate your current API key.\nAre you sure you want to reset your API key?', ), ); if (!confirmed) { @@ -74,18 +74,4 @@ export default class MrtUserApiKey extends MrtCommand { return result; } - - private async confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(message, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); - }); - }); - } } diff --git a/packages/b2c-cli/src/commands/sandbox/delete.ts b/packages/b2c-cli/src/commands/sandbox/delete.ts index 500958d0..e029e335 100644 --- a/packages/b2c-cli/src/commands/sandbox/delete.ts +++ b/packages/b2c-cli/src/commands/sandbox/delete.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import * as readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; import { @@ -15,23 +14,7 @@ import { type SandboxState, } from '@salesforce/b2c-tooling-sdk'; import {t, withDocs} from '../../i18n/index.js'; - -/** - * Simple confirmation prompt. - */ -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - - return new Promise((resolve) => { - rl.question(`${message} `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../prompts.js'; /** * Command to delete an on-demand sandbox. @@ -106,7 +89,7 @@ export default class SandboxDelete extends OdsCommand { // Confirm deletion unless --force is used if (!this.flags.force) { const confirmed = await confirm( - t('commands.sandbox.delete.confirm', 'Are you sure you want to delete sandbox "{{sandboxInfo}}"? (y/n)', { + t('commands.sandbox.delete.confirm', 'Are you sure you want to delete sandbox "{{sandboxInfo}}"?', { sandboxInfo, }), ); diff --git a/packages/b2c-cli/src/commands/webdav/rm.ts b/packages/b2c-cli/src/commands/webdav/rm.ts index 23a105c7..8eb7b4ec 100644 --- a/packages/b2c-cli/src/commands/webdav/rm.ts +++ b/packages/b2c-cli/src/commands/webdav/rm.ts @@ -3,27 +3,10 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t, withDocs} from '../../i18n/index.js'; - -/** - * Simple confirmation prompt. - */ -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - - return new Promise((resolve) => { - rl.question(`${message} `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} +import {confirm} from '../../prompts.js'; interface RmResult { path: string; @@ -31,6 +14,7 @@ interface RmResult { } export default class WebDavRm extends WebDavCommand { + protected operations = {confirm}; static args = { path: Args.string({ description: 'Path to delete relative to root', @@ -67,8 +51,8 @@ export default class WebDavRm extends WebDavCommand { // Confirm deletion unless --force is used if (!this.flags.force) { - const confirmed = await confirm( - t('commands.webdav.rm.confirm', 'Are you sure you want to delete "{{path}}"? (y/n)', {path: fullPath}), + const confirmed = await this.operations.confirm( + t('commands.webdav.rm.confirm', 'Are you sure you want to delete "{{path}}"?', {path: fullPath}), ); if (!confirmed) { diff --git a/packages/b2c-cli/src/i18n/locales/en.ts b/packages/b2c-cli/src/i18n/locales/en.ts index 41e2a579..828a41e8 100644 --- a/packages/b2c-cli/src/i18n/locales/en.ts +++ b/packages/b2c-cli/src/i18n/locales/en.ts @@ -97,7 +97,7 @@ export const en = { deleting: 'Deleting code version {{codeVersion}} from {{hostname}}...', deleted: 'Code version {{codeVersion}} deleted successfully', failed: 'Failed to delete code version: {{message}}', - confirm: 'Are you sure you want to delete code version "{{codeVersion}}" on {{hostname}}? (y/n)', + confirm: 'Are you sure you want to delete code version "{{codeVersion}}" on {{hostname}}?', cancelled: 'Deletion cancelled', }, deploy: { diff --git a/packages/b2c-cli/src/prompts.ts b/packages/b2c-cli/src/prompts.ts index ea95c0d5..90aa4239 100644 --- a/packages/b2c-cli/src/prompts.ts +++ b/packages/b2c-cli/src/prompts.ts @@ -5,22 +5,36 @@ */ import * as readline from 'node:readline'; +export interface ConfirmOptions { + /** Default to yes when the user presses Enter without typing. Defaults to false (no). */ + defaultYes?: boolean; +} + /** - * Simple yes/no confirmation prompt. Defaults to no. + * Simple yes/no confirmation prompt. * - * @param message - Prompt message (e.g., "Delete project? (y/N)") - * @returns true if user answered y/yes, false otherwise + * @param message - Prompt message (the hint is appended automatically, e.g., "(y/N)" or "(Y/n)") + * @param options - Options to control default behavior + * @returns true if user confirmed, false otherwise */ -export async function confirm(message: string): Promise { +export async function confirm(message: string, options?: ConfirmOptions): Promise { + const defaultYes = options?.defaultYes ?? false; + const hint = defaultYes ? '(Y/n)' : '(y/N)'; + const rl = readline.createInterface({ input: process.stdin, output: process.stderr, }); return new Promise((resolve) => { - rl.question(`${message} `, (answer) => { + rl.question(`${message} ${hint} `, (answer) => { rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + const normalized = answer.trim().toLowerCase(); + if (normalized === '') { + resolve(defaultYes); + } else { + resolve(normalized === 'y' || normalized === 'yes'); + } }); }); } diff --git a/packages/b2c-cli/test/commands/webdav/rm.test.ts b/packages/b2c-cli/test/commands/webdav/rm.test.ts index fe717159..5a0da69e 100644 --- a/packages/b2c-cli/test/commands/webdav/rm.test.ts +++ b/packages/b2c-cli/test/commands/webdav/rm.test.ts @@ -4,7 +4,6 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import readline from 'node:readline'; import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; @@ -56,13 +55,10 @@ describe('webdav rm', () => { }, })); - const rl = { - question(_prompt: string, cb: (answer: string) => void) { - cb('n'); - }, - close() {}, - }; - sinon.stub(readline, 'createInterface').returns(rl as any); + const confirmStub = sinon.stub().resolves(false); + command.operations = {...command.operations, confirm: confirmStub}; + + sinon.stub(command, 'log').returns(void 0); const result = await command.run();