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 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/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 new file mode 100644 index 00000000..2a38054f --- /dev/null +++ b/packages/b2c-cli/src/commands/mrt/save-credentials.ts @@ -0,0 +1,117 @@ +/* + * 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 {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; + 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}}. Overwrite?', { + 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; + } + + /** Wraps shared confirm for testability. */ + protected async confirm(message: string): Promise { + return confirm(message); + } + + 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'); + } +} diff --git a/packages/b2c-cli/src/commands/mrt/user/api-key.ts b/packages/b2c-cli/src/commands/mrt/user/api-key.ts index 80abbfd6..6469655b 100644 --- a/packages/b2c-cli/src/commands/mrt/user/api-key.ts +++ b/packages/b2c-cli/src/commands/mrt/user/api-key.ts @@ -4,10 +4,10 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags} from '@oclif/core'; -import * as readline from 'node:readline'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {resetApiKey, type ApiKeyResult} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../i18n/index.js'; +import {confirm} from '../../../prompts.js'; /** * Reset the current user's API key. @@ -41,10 +41,10 @@ export default class MrtUserApiKey extends MrtCommand { 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 new file mode 100644 index 00000000..90aa4239 --- /dev/null +++ b/packages/b2c-cli/src/prompts.ts @@ -0,0 +1,40 @@ +/* + * 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'; + +export interface ConfirmOptions { + /** Default to yes when the user presses Enter without typing. Defaults to false (no). */ + defaultYes?: boolean; +} + +/** + * Simple yes/no confirmation prompt. + * + * @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, 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} ${hint} `, (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + if (normalized === '') { + resolve(defaultYes); + } else { + resolve(normalized === 'y' || normalized === 'yes'); + } + }); + }); +} 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); + }); +}); 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();