diff --git a/packages/b2c-cli/eslint.config.mjs b/packages/b2c-cli/eslint.config.mjs index 228e20a7..05a68b20 100644 --- a/packages/b2c-cli/eslint.config.mjs +++ b/packages/b2c-cli/eslint.config.mjs @@ -15,10 +15,13 @@ const gitignorePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), headerPlugin.rules.header.meta.schema = false; export default [ - includeIgnoreFile(gitignorePath), + // Global ignores must come first - these patterns apply to all subsequent configs + // node_modules must be explicitly ignored because the .gitignore pattern only covers + // packages/b2c-cli/node_modules, not the monorepo root node_modules { - ignores: ['test/functional/fixtures/**/*.js'], + ignores: ['**/node_modules/**', 'test/functional/fixtures/**/*.js'], }, + includeIgnoreFile(gitignorePath), ...oclif, prettierPlugin, { diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 6c33f842..6eafde8f 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -4,7 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {JobCommand, type B2COperationContext} from '@salesforce/b2c-tooling-sdk/cli'; import { executeJob, waitForJob, @@ -112,19 +112,7 @@ export default class JobRun extends JobCommand { waitForRunning: !noWaitRunning, }); } catch (error) { - // Run afterOperation hooks with failure - await this.runAfterHooks(context, { - success: false, - error: error instanceof Error ? error : new Error(String(error)), - duration: Date.now() - context.startTime, - }); - - if (error instanceof Error) { - this.error( - t('commands.job.run.executionFailed', 'Failed to execute job: {{message}}', {message: error.message}), - ); - } - throw error; + this.handleExecutionError(error, context); } this.log( @@ -136,59 +124,13 @@ export default class JobRun extends JobCommand { // Wait for completion if requested if (wait) { - this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); - - try { - execution = await waitForJob(this.instance, jobId, execution.id!, { - timeout: timeout ? timeout * 1000 : undefined, - onProgress: (exec, elapsed) => { - if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); - this.log( - t('commands.job.run.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), - }), - ); - } - }, - }); - - const durationSec = execution.duration ? (execution.duration / 1000).toFixed(1) : 'N/A'; - this.log( - t('commands.job.run.completed', 'Job completed: {{status}} (duration: {{duration}}s)', { - status: execution.exit_status?.code || execution.execution_status, - duration: durationSec, - }), - ); - - // Run afterOperation hooks with success - await this.runAfterHooks(context, { - success: true, - duration: Date.now() - context.startTime, - data: execution, - }); - } catch (error) { - // Run afterOperation hooks with failure - await this.runAfterHooks(context, { - success: false, - error: error instanceof Error ? error : new Error(String(error)), - duration: Date.now() - context.startTime, - data: error instanceof JobExecutionError ? error.execution : undefined, - }); - - if (error instanceof JobExecutionError) { - if (showLog) { - await this.showJobLog(error.execution); - } - this.error( - t('commands.job.run.jobFailed', 'Job failed: {{status}}', { - status: error.execution.exit_status?.code || 'ERROR', - }), - ); - } - throw error; - } + execution = await this.waitForJobCompletion({ + jobId, + executionId: execution.id!, + timeout, + showLog, + context, + }); } else { // Not waiting - run afterOperation hooks with current state await this.runAfterHooks(context, { @@ -198,12 +140,43 @@ export default class JobRun extends JobCommand { }); } - // JSON output handled by oclif - if (this.jsonEnabled()) { - return execution; + return execution; + } + + private handleExecutionError(error: unknown, context: B2COperationContext): never { + // Run afterOperation hooks with failure (fire-and-forget, errors ignored) + this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + }).catch(() => {}); + + if (error instanceof Error) { + this.error(t('commands.job.run.executionFailed', 'Failed to execute job: {{message}}', {message: error.message})); } + throw error; + } - return execution; + private async handleWaitError(error: unknown, showLog: boolean, context: B2COperationContext): Promise { + // Run afterOperation hooks with failure + await this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + data: error instanceof JobExecutionError ? error.execution : undefined, + }); + + if (error instanceof JobExecutionError) { + if (showLog) { + await this.showJobLog(error.execution); + } + this.error( + t('commands.job.run.jobFailed', 'Job failed: {{status}}', { + status: error.execution.exit_status?.code || 'ERROR', + }), + ); + } + throw error; } private parseBody(body: string): Record { @@ -228,4 +201,51 @@ export default class JobRun extends JobCommand { }; }); } + + private async waitForJobCompletion(options: { + jobId: string; + executionId: string; + timeout: number | undefined; + showLog: boolean; + context: B2COperationContext; + }): Promise { + const {jobId, executionId, timeout, showLog, context} = options; + this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); + + try { + const execution = await waitForJob(this.instance, jobId, executionId, { + timeout: timeout ? timeout * 1000 : undefined, + onProgress: (exec, elapsed) => { + if (!this.jsonEnabled()) { + const elapsedSec = Math.floor(elapsed / 1000); + this.log( + t('commands.job.run.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: exec.execution_status, + elapsed: elapsedSec.toString(), + }), + ); + } + }, + }); + + const durationSec = execution.duration ? (execution.duration / 1000).toFixed(1) : 'N/A'; + this.log( + t('commands.job.run.completed', 'Job completed: {{status}} (duration: {{duration}}s)', { + status: execution.exit_status?.code || execution.execution_status, + duration: durationSec, + }), + ); + + // Run afterOperation hooks with success + await this.runAfterHooks(context, { + success: true, + duration: Date.now() - context.startTime, + data: execution, + }); + + return execution; + } catch (error) { + return this.handleWaitError(error, showLog, context); + } + } } diff --git a/packages/b2c-cli/src/commands/ods/info.ts b/packages/b2c-cli/src/commands/ods/info.ts index 49ee7ce4..5bc91964 100644 --- a/packages/b2c-cli/src/commands/ods/info.ts +++ b/packages/b2c-cli/src/commands/ods/info.ts @@ -84,83 +84,65 @@ export default class OdsInfo extends OdsCommand { // User Info Section ui.div({text: 'User Information', padding: [1, 0, 0, 0]}); ui.div({text: '─'.repeat(40), padding: [0, 0, 0, 0]}); + this.renderUserInfo(ui, info.user); - if (info.user?.user) { - ui.div( - {text: 'Name:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.user.user.name || '-', padding: [0, 0, 0, 0]}, - ); - ui.div( - {text: 'Email:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.user.user.email || '-', padding: [0, 0, 0, 0]}, - ); - ui.div( - {text: 'User ID:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.user.user.id || '-', padding: [0, 0, 0, 0]}, - ); - } + // System Info Section + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'System Information', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(40), padding: [0, 0, 0, 0]}); + this.renderSystemInfo(ui, info.system); - if (info.user?.client) { - ui.div( - {text: 'Client ID:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.user.client.id || '-', padding: [0, 0, 0, 0]}, - ); - } + ux.stdout(ui.toString()); + } - if (info.user?.roles && info.user.roles.length > 0) { - ui.div( - {text: 'Roles:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.user.roles.join(', '), padding: [0, 0, 0, 0]}, - ); + private renderArrayField(ui: ReturnType, label: string, values: string[] | undefined): void { + if (values && values.length > 0) { + this.renderField(ui, label, values.join(', ')); } + } - if (info.user?.realms && info.user.realms.length > 0) { - ui.div( - {text: 'Realms:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.user.realms.join(', '), padding: [0, 0, 0, 0]}, - ); - } + private renderField(ui: ReturnType, label: string, value: string): void { + ui.div({text: label, width: 20, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } - if (info.user?.sandboxes && info.user.sandboxes.length > 0) { - ui.div( - {text: 'Sandboxes:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.user.sandboxes.length.toString(), padding: [0, 0, 0, 0]}, - ); + private renderSystemInfo(ui: ReturnType, system: SystemInfoSpec | undefined): void { + if (!system) return; + + if (system.region) { + this.renderField(ui, 'Region:', system.region); } - // System Info Section - ui.div({text: '', padding: [0, 0, 0, 0]}); - ui.div({text: 'System Information', padding: [1, 0, 0, 0]}); - ui.div({text: '─'.repeat(40), padding: [0, 0, 0, 0]}); + this.renderArrayField(ui, 'Inbound IPs:', system.inboundIps); + this.renderArrayField(ui, 'Outbound IPs:', system.outboundIps); - if (info.system?.region) { - ui.div({text: 'Region:', width: 20, padding: [0, 2, 0, 0]}, {text: info.system.region, padding: [0, 0, 0, 0]}); + // Sandbox IPs with truncation + if (system.sandboxIps && system.sandboxIps.length > 0) { + const truncated = system.sandboxIps.length > 5; + const displayValue = system.sandboxIps.slice(0, 5).join(', ') + (truncated ? '...' : ''); + this.renderField(ui, 'Sandbox IPs:', displayValue); } + } - if (info.system?.inboundIps && info.system.inboundIps.length > 0) { - ui.div( - {text: 'Inbound IPs:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.system.inboundIps.join(', '), padding: [0, 0, 0, 0]}, - ); - } + private renderUserInfo(ui: ReturnType, user: undefined | UserInfoSpec): void { + if (!user) return; - if (info.system?.outboundIps && info.system.outboundIps.length > 0) { - ui.div( - {text: 'Outbound IPs:', width: 20, padding: [0, 2, 0, 0]}, - {text: info.system.outboundIps.join(', '), padding: [0, 0, 0, 0]}, - ); + // User details + if (user.user) { + this.renderField(ui, 'Name:', user.user.name || '-'); + this.renderField(ui, 'Email:', user.user.email || '-'); + this.renderField(ui, 'User ID:', user.user.id || '-'); } - if (info.system?.sandboxIps && info.system.sandboxIps.length > 0) { - ui.div( - {text: 'Sandbox IPs:', width: 20, padding: [0, 2, 0, 0]}, - { - text: info.system.sandboxIps.slice(0, 5).join(', ') + (info.system.sandboxIps.length > 5 ? '...' : ''), - padding: [0, 0, 0, 0], - }, - ); + // Client info + if (user.client) { + this.renderField(ui, 'Client ID:', user.client.id || '-'); } - ux.stdout(ui.toString()); + // Arrays with length checks + this.renderArrayField(ui, 'Roles:', user.roles); + this.renderArrayField(ui, 'Realms:', user.realms); + if (user.sandboxes && user.sandboxes.length > 0) { + this.renderField(ui, 'Sandboxes:', user.sandboxes.length.toString()); + } } } diff --git a/packages/b2c-cli/src/commands/slas/client/update.ts b/packages/b2c-cli/src/commands/slas/client/update.ts index a07f4876..110aa093 100644 --- a/packages/b2c-cli/src/commands/slas/client/update.ts +++ b/packages/b2c-cli/src/commands/slas/client/update.ts @@ -111,59 +111,21 @@ export default class SlasClientUpdate extends SlasClientCommand (typeof uri === 'string' ? uri.split('|').map((s) => s.trim()) : [])) - : typeof existing.redirectUri === 'string' - ? existing.redirectUri.split('|').map((s) => s.trim()) - : []; - - // oclif handles comma-separation via delimiter option - const newChannels = channels ?? []; - const newScopes = scopes ?? []; - const newRedirectUri = redirectUri ?? []; - const newCallbackUri = callbackUri ?? []; - - // Merge or replace values - const mergedChannels = replace ? newChannels : [...new Set([...(existing.channels ?? []), ...newChannels])]; - const mergedScopes = replace ? newScopes : [...new Set([...existingScopes, ...newScopes])]; - const mergedRedirectUri = replace ? newRedirectUri : [...new Set([...existingRedirectUri, ...newRedirectUri])]; - // Handle callbackUri - existing value is comma-separated string from API - const existingCallbackUri = existing.callbackUri?.split(',').map((s) => s.trim()) ?? []; - const mergedCallbackUri = callbackUri - ? replace - ? newCallbackUri - : [...new Set([...existingCallbackUri, ...newCallbackUri])] - : existingCallbackUri.length > 0 - ? existingCallbackUri - : undefined; - if (!this.jsonEnabled()) { this.log(t('commands.slas.client.update.updating', 'Updating SLAS client {{clientId}}...', {clientId})); } - // Build request body - only include secret if provided (to rotate it) - const body: Partial = { + // Build request body with merged values + const body = this.buildUpdateRequest(existing, { clientId, - name: name ?? existing.name ?? '', - channels: channels ? mergedChannels : (existing.channels ?? []), - scopes: scopes ? mergedScopes : existingScopes, - redirectUri: redirectUri ? mergedRedirectUri : existingRedirectUri, - callbackUri: mergedCallbackUri, - isPrivateClient: existing.isPrivateClient ?? true, - }; - - if (secret) { - body.secret = secret; - } + name, + secret, + channels: channels ?? [], + scopes: scopes ?? [], + redirectUri: redirectUri ?? [], + callbackUri: callbackUri ?? [], + replace, + }); // Update the client const {data, error} = await slasClient.PUT('/tenants/{tenantId}/clients/{clientId}', { @@ -193,4 +155,91 @@ export default class SlasClientUpdate extends SlasClientCommand { + const existingScopes = this.normalizeScopes(existing.scopes); + const existingRedirectUri = this.normalizeUriArray(existing.redirectUri); + const existingCallbackUri = this.normalizeCallbackUri(existing.callbackUri); + + // Determine merged values + const mergedChannels = this.mergeArrayValues(existing.channels ?? [], updates.channels, updates.replace); + const mergedScopes = this.mergeArrayValues(existingScopes, updates.scopes, updates.replace); + const mergedRedirectUri = this.mergeArrayValues(existingRedirectUri, updates.redirectUri, updates.replace); + const mergedCallbackUri = this.computeCallbackUri(existingCallbackUri, updates.callbackUri, updates.replace); + + const body: Partial = { + clientId: updates.clientId, + name: updates.name ?? existing.name ?? '', + channels: updates.channels.length > 0 ? mergedChannels : (existing.channels ?? []), + scopes: updates.scopes.length > 0 ? mergedScopes : existingScopes, + redirectUri: updates.redirectUri.length > 0 ? mergedRedirectUri : existingRedirectUri, + callbackUri: mergedCallbackUri, + isPrivateClient: existing.isPrivateClient ?? true, + }; + + if (updates.secret) { + body.secret = updates.secret; + } + + return body; + } + + /** + * Compute callback URI value, handling the special case where no new values means keeping existing. + */ + private computeCallbackUri(existing: string[], updated: string[], replace: boolean): string[] | undefined { + if (updated.length > 0) { + return this.mergeArrayValues(existing, updated, replace); + } + return existing.length > 0 ? existing : undefined; + } + + /** + * Merge array values, optionally replacing or appending with deduplication. + */ + private mergeArrayValues(existing: string[], updated: string[], replace: boolean): string[] { + return replace ? updated : [...new Set([...existing, ...updated])]; + } + + /** + * Normalize callback URI from API response (comma-separated string). + */ + private normalizeCallbackUri(value: string | undefined): string[] { + return value ? value.split(',').map((s) => s.trim()) : []; + } + + /** + * Normalize scopes from API response (may be space-separated string or array). + */ + private normalizeScopes(scopes: string | string[] | undefined): string[] { + if (typeof scopes === 'string') { + return scopes.split(' '); + } + return Array.isArray(scopes) ? scopes : []; + } + + /** + * Normalize URI values from API response (may be pipe-delimited string or array). + */ + private normalizeUriArray(value: string | string[] | undefined): string[] { + if (Array.isArray(value)) { + return value.flatMap((uri) => (typeof uri === 'string' ? uri.split('|').map((s) => s.trim()) : [])); + } + return typeof value === 'string' ? value.split('|').map((s) => s.trim()) : []; + } } diff --git a/packages/b2c-cli/test/tsconfig.json b/packages/b2c-cli/test/tsconfig.json index 6c5a0d1e..8fed7680 100644 --- a/packages/b2c-cli/test/tsconfig.json +++ b/packages/b2c-cli/test/tsconfig.json @@ -2,8 +2,8 @@ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, - "rootDir": ".", + "rootDir": "..", "types": ["node", "mocha", "chai"] }, - "include": ["./**/*"] + "include": ["./**/*", "../src/**/*", "../../../types/**/*"] } diff --git a/packages/b2c-cli/tsconfig.json b/packages/b2c-cli/tsconfig.json index 8b5fb1d5..c2b83bb5 100644 --- a/packages/b2c-cli/tsconfig.json +++ b/packages/b2c-cli/tsconfig.json @@ -9,5 +9,5 @@ "verbatimModuleSyntax": true, "customConditions": ["development"] }, - "include": ["./src/**/*"] + "include": ["./src/**/*", "../../types/**/*"] } diff --git a/packages/b2c-dx-mcp/test/tsconfig.json b/packages/b2c-dx-mcp/test/tsconfig.json index 93dcd305..8fed7680 100644 --- a/packages/b2c-dx-mcp/test/tsconfig.json +++ b/packages/b2c-dx-mcp/test/tsconfig.json @@ -1,15 +1,9 @@ { + "extends": "../tsconfig.json", "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", "noEmit": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, + "rootDir": "..", "types": ["node", "mocha", "chai"] }, - "include": ["./**/*", "../src/**/*"] + "include": ["./**/*", "../src/**/*", "../../../types/**/*"] } diff --git a/packages/b2c-dx-mcp/tsconfig.json b/packages/b2c-dx-mcp/tsconfig.json index 8b5fb1d5..c2b83bb5 100644 --- a/packages/b2c-dx-mcp/tsconfig.json +++ b/packages/b2c-dx-mcp/tsconfig.json @@ -9,5 +9,5 @@ "verbatimModuleSyntax": true, "customConditions": ["development"] }, - "include": ["./src/**/*"] + "include": ["./src/**/*", "../../types/**/*"] } diff --git a/packages/b2c-tooling-sdk/src/types/cliui.d.ts b/packages/b2c-tooling-sdk/src/types/cliui.d.ts deleted file mode 100644 index 4699e4de..00000000 --- a/packages/b2c-tooling-sdk/src/types/cliui.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 - */ -declare module 'cliui' { - interface Column { - text: string; - width?: number; - align?: 'center' | 'left' | 'right'; - padding?: [number, number, number, number]; - border?: boolean; - } - - interface UIOptions { - width?: number; - wrap?: boolean; - } - - interface UI { - div(...columns: (Column | string)[]): void; - span(...columns: (Column | string)[]): void; - resetOutput(): void; - toString(): string; - } - - function cliui(options?: UIOptions): UI; - export default cliui; -} diff --git a/packages/b2c-tooling-sdk/test/tsconfig.json b/packages/b2c-tooling-sdk/test/tsconfig.json index 783bb46c..8c7682ef 100644 --- a/packages/b2c-tooling-sdk/test/tsconfig.json +++ b/packages/b2c-tooling-sdk/test/tsconfig.json @@ -8,5 +8,5 @@ "@salesforce/b2c-tooling-sdk/*": ["../src/*/index.ts", "../src/*"] } }, - "include": ["./**/*", "../src/**/*"] + "include": ["./**/*", "../src/**/*", "../../../types/**/*"] } diff --git a/packages/b2c-tooling-sdk/tsconfig.cjs.json b/packages/b2c-tooling-sdk/tsconfig.cjs.json index 1cd244f3..e7826e9a 100644 --- a/packages/b2c-tooling-sdk/tsconfig.cjs.json +++ b/packages/b2c-tooling-sdk/tsconfig.cjs.json @@ -7,6 +7,6 @@ "rootDir": "src", "verbatimModuleSyntax": false }, - "include": ["src/**/*"], + "include": ["src/**/*", "../../types/**/*"], "exclude": ["scripts/**/*"] } diff --git a/packages/b2c-tooling-sdk/tsconfig.esm.json b/packages/b2c-tooling-sdk/tsconfig.esm.json index 15341d0f..49066cf2 100644 --- a/packages/b2c-tooling-sdk/tsconfig.esm.json +++ b/packages/b2c-tooling-sdk/tsconfig.esm.json @@ -4,6 +4,6 @@ "outDir": "dist/esm", "rootDir": "src" }, - "include": ["src/**/*"], + "include": ["src/**/*", "../../types/**/*"], "exclude": ["scripts/**/*"] } diff --git a/packages/b2c-tooling-sdk/tsconfig.json b/packages/b2c-tooling-sdk/tsconfig.json index 7f84df40..377b553a 100644 --- a/packages/b2c-tooling-sdk/tsconfig.json +++ b/packages/b2c-tooling-sdk/tsconfig.json @@ -6,5 +6,5 @@ "forceConsistentCasingInFileNames": true, "verbatimModuleSyntax": true }, - "include": ["src/**/*", "scripts/**/*"] + "include": ["src/**/*", "scripts/**/*", "../../types/**/*"] } diff --git a/packages/b2c-cli/src/types/cliui.d.ts b/types/cliui.d.ts similarity index 100% rename from packages/b2c-cli/src/types/cliui.d.ts rename to types/cliui.d.ts