diff --git a/.changeset/wave-review-cleanup.md b/.changeset/wave-review-cleanup.md new file mode 100644 index 000000000..d391e282d --- /dev/null +++ b/.changeset/wave-review-cleanup.md @@ -0,0 +1,18 @@ +--- +'@salesforce/b2c-tooling-sdk': patch +'@salesforce/b2c-cli': patch +'@salesforce/mrt-utilities': patch +--- + +Hardens long-running operations and atomic config writes: + +- dw.json mutations (`addInstance`, `removeInstance`, `setActiveInstance`) now go through a per-path async serializer and write via temp-file + rename, so concurrent CLI invocations within the same process can no longer interleave reads and writes. +- The session file written by stateful auth removes an exists-then-mkdir TOCTOU and cleans up orphan tmp files on rename failure. +- Cartridge deploy/download progress intervals are wrapped in a `withProgress` helper that always tears down on exception. +- `b2c jobs run` no longer silently treats a body-read failure as "not the JobAlreadyRunning case" during 400 detection. +- MRT proxy `onError` no longer crashes when upstream begins streaming before erroring (`headersSent` guard). +- Sandbox CLI commands now route output through `this.log` so `--json` mode and test output silencing work as documented; a lint rule prevents regression. +- Shared ANSI palette consolidated in `@salesforce/b2c-tooling-sdk/cli` (now also exporting standalone `RED`/`GREEN`/`YELLOW`/`CYAN`/`MAGENTA`/`GRAY`); the script-debugger REPL and the `cap pull`/`cap tasks` commands consume it instead of redefining literal-ESC palettes. +- HTTP error paths in `code:deploy`, `code:download`, and OAuth client_credentials no longer lose the underlying status when `response.text()` itself rejects mid-body. +- MRT bundle `loadServerConfig` now surfaces real errors from `config.server.js` instead of silently falling back to defaults; scaffold registry surfaces a warning for non-ENOENT manifest read errors instead of dropping them silently. +- Six newly-added ecdn detail commands (`firewall:get/create/update`, `rate-limit:get/create/update`) use the SDK's `printFieldsBlock` helper, matching the `bm`/`am` detail commands. diff --git a/packages/b2c-cli/eslint.config.mjs b/packages/b2c-cli/eslint.config.mjs index 1e4862a90..00e0db7b5 100644 --- a/packages/b2c-cli/eslint.config.mjs +++ b/packages/b2c-cli/eslint.config.mjs @@ -82,4 +82,15 @@ export default [ 'import/no-unresolved': 'off', }, }, + { + // Commands must route output through oclif (this.log/this.error/ux.stdout) so that + // --json mode, log redirection, and test stdout silencing work. Bare console.* breaks + // these contracts. The prophet IDE script is exempt because it serializes JS source + // that runs outside the CLI process. + files: ['src/commands/**/*.ts'], + ignores: ['src/commands/setup/ide/prophet.ts'], + rules: { + 'no-console': 'error', + }, + }, ]; diff --git a/packages/b2c-cli/src/commands/cap/pull.ts b/packages/b2c-cli/src/commands/cap/pull.ts index 41196498f..8f9259758 100644 --- a/packages/b2c-cli/src/commands/cap/pull.ts +++ b/packages/b2c-cli/src/commands/cap/pull.ts @@ -5,7 +5,7 @@ */ import path from 'node:path'; import {Args, Flags} from '@oclif/core'; -import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {JobCommand, ANSI} from '@salesforce/b2c-tooling-sdk/cli'; import { listInstalledApps, pullCommerceApps, @@ -125,12 +125,7 @@ export default class CapPull extends JobCommand { }); if (!this.jsonEnabled()) { - const bold = ''; - const dim = ''; - const cyan = ''; - const yellow = ''; - const red = ''; - const reset = ''; + const {BOLD: bold, DIM: dim, CYAN: cyan, YELLOW: yellow, RED: red, RESET: reset} = ANSI; for (const app of result.pulled) { const relativePath = path.relative(process.cwd(), app.extractedPath); diff --git a/packages/b2c-cli/src/commands/cap/tasks.ts b/packages/b2c-cli/src/commands/cap/tasks.ts index af3493aa1..9129f99c8 100644 --- a/packages/b2c-cli/src/commands/cap/tasks.ts +++ b/packages/b2c-cli/src/commands/cap/tasks.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, ANSI} from '@salesforce/b2c-tooling-sdk/cli'; import {listInstalledApps, type CommerceFeatureState} from '@salesforce/b2c-tooling-sdk/operations/cap'; import {t, withDocs} from '../../i18n/index.js'; @@ -107,10 +107,7 @@ export default class CapTasks extends JobCommand { ); if (!this.jsonEnabled()) { - const bold = ''; - const dim = ''; - const cyan = ''; - const reset = ''; + const {BOLD: bold, DIM: dim, CYAN: cyan, RESET: reset} = ANSI; for (const task of tasks) { process.stdout.write(`\n ${bold}${task.taskNumber}. ${task.name}${reset}\n`); diff --git a/packages/b2c-cli/src/commands/ecdn/firewall/create.ts b/packages/b2c-cli/src/commands/ecdn/firewall/create.ts index f27a691ae..79153e43a 100644 --- a/packages/b2c-cli/src/commands/ecdn/firewall/create.ts +++ b/packages/b2c-cli/src/commands/ecdn/firewall/create.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 {Flags} from '@oclif/core'; -import cliui from 'cliui'; +import {printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../i18n/index.js'; @@ -125,18 +125,16 @@ export default class EcdnFirewallCreate extends EcdnZoneCommand { private printIps(system: SystemInfoSpec, realm?: string): void { const context = realm ? t('commands.sandbox.ips.realmLabel', ' (for realm {{realm}})', {realm}) : ''; - console.log(t('commands.sandbox.ips.inboundHeader', 'Inbound IP addresses{{context}}:', {context})); + this.log(t('commands.sandbox.ips.inboundHeader', 'Inbound IP addresses{{context}}:', {context})); for (const ip of system.inboundIps ?? []) { - console.log(` - ${ip}`); + this.log(` - ${ip}`); } - console.log(); + this.log(''); - console.log(t('commands.sandbox.ips.outboundHeader', 'Outbound IP addresses{{context}}:', {context})); + this.log(t('commands.sandbox.ips.outboundHeader', 'Outbound IP addresses{{context}}:', {context})); for (const ip of system.outboundIps ?? []) { - console.log(` - ${ip}`); + this.log(` - ${ip}`); } if (system.sandboxIps && system.sandboxIps.length > 0) { - console.log(); + this.log(''); - console.log(t('commands.sandbox.ips.sandboxHeader', 'Sandbox IP addresses{{context}}:', {context})); + this.log(t('commands.sandbox.ips.sandboxHeader', 'Sandbox IP addresses{{context}}:', {context})); for (const ip of system.sandboxIps) { - console.log(` - ${ip}`); + this.log(` - ${ip}`); } } } diff --git a/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts b/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts index 0476338ea..db8cc1227 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts @@ -71,8 +71,8 @@ export default class SandboxRealmConfiguration extends OdsCommand= 2_147_483_647 ? '0' : String(maxTtlRaw); @@ -98,16 +98,16 @@ export default class SandboxRealmConfiguration extends OdsCommand 0) { - console.log(); + this.log(''); - console.log('Minutes up by profile:'); + this.log('Minutes up by profile:'); for (const item of anyUsage.minutesUpByProfile) { if (item.profile && item.minutes !== undefined) { - console.log(` ${item.profile}: ${item.minutes} minutes`); + this.log(` ${item.profile}: ${item.minutes} minutes`); } } } @@ -150,13 +150,13 @@ export default class SandboxRealmUsage extends OdsCommand 0) ) { - console.log( + this.log( t('commands.realm.usage.emptyPeriod', 'No usage data was returned for this realm in the requested period.'), ); } else if (hasDetailedData) { - console.log(); + this.log(''); - console.log( + this.log( t( 'commands.realm.usage.detailedHint', 'Detailed usage data is available; re-run with --json to see full details.', diff --git a/packages/b2c-cli/src/commands/sandbox/realm/usages.ts b/packages/b2c-cli/src/commands/sandbox/realm/usages.ts index 61e8b7df0..0a13d15a8 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/usages.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/usages.ts @@ -92,8 +92,8 @@ export default class SandboxRealmUsages extends OdsCommand 0) { - console.log(); - console.log('OCAPI'); + this.log(''); + this.log('OCAPI'); for (const entry of ocapi) { const resources = entry.resources?.length ?? 0; - console.log(` - ${entry.client_id ?? 'unknown-client'} (${resources} resource rules)`); + this.log(` - ${entry.client_id ?? 'unknown-client'} (${resources} resource rules)`); } } if (webdav.length > 0) { - console.log(); - console.log('WebDAV'); + this.log(''); + this.log('WebDAV'); for (const entry of webdav) { const permissions = entry.permissions?.length ?? 0; - console.log(` - ${entry.client_id ?? 'unknown-client'} (${permissions} permission rules)`); + this.log(` - ${entry.client_id ?? 'unknown-client'} (${permissions} permission rules)`); } } } diff --git a/packages/b2c-cli/src/commands/sandbox/storage.ts b/packages/b2c-cli/src/commands/sandbox/storage.ts index 17fa077f3..5fe1ea17c 100644 --- a/packages/b2c-cli/src/commands/sandbox/storage.ts +++ b/packages/b2c-cli/src/commands/sandbox/storage.ts @@ -70,17 +70,17 @@ export default class SandboxStorage extends OdsCommand { } private printStorage(storage: SandboxStorageModel): void { - console.log('Sandbox Storage'); - console.log('───────────────'); - console.log('Filesystem Total (MB) Used (MB) Used (%)'); - console.log('──────────────────────── ────────── ───────── ────────'); + this.log('Sandbox Storage'); + this.log('───────────────'); + this.log('Filesystem Total (MB) Used (MB) Used (%)'); + this.log('──────────────────────── ────────── ───────── ────────'); for (const [name, usage] of Object.entries(storage)) { const total = usage?.spaceTotal ?? '-'; const used = usage?.spaceUsed ?? '-'; const percentage = usage?.percentageUsed ?? '-'; - console.log( + this.log( `${name.padEnd(24)} ${String(total).padStart(10)} ${String(used).padStart(9)} ${String(percentage).padStart(8)}`, ); } diff --git a/packages/b2c-cli/src/commands/sandbox/usage.ts b/packages/b2c-cli/src/commands/sandbox/usage.ts index 9edac35d4..a06bdbe59 100644 --- a/packages/b2c-cli/src/commands/sandbox/usage.ts +++ b/packages/b2c-cli/src/commands/sandbox/usage.ts @@ -94,9 +94,9 @@ export default class SandboxUsage extends OdsCommand { } private printSandboxUsageSummary(usage: SandboxUsageModel): void { - console.log('Sandbox Usage Summary'); + this.log('Sandbox Usage Summary'); - console.log('─────────────────────'); + this.log('─────────────────────'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const anyUsage = usage as any; @@ -113,17 +113,17 @@ export default class SandboxUsage extends OdsCommand { if (value !== undefined) { hasSummaryMetric = true; - console.log(`${label}: ${value}`); + this.log(`${label}: ${value}`); } } if (anyUsage.minutesUpByProfile && anyUsage.minutesUpByProfile.length > 0) { - console.log(); + this.log(''); - console.log('Minutes up by profile:'); + this.log('Minutes up by profile:'); for (const item of anyUsage.minutesUpByProfile) { if (item.profile && item.minutes !== undefined) { - console.log(` ${item.profile}: ${item.minutes} minutes`); + this.log(` ${item.profile}: ${item.minutes} minutes`); } } } @@ -137,13 +137,13 @@ export default class SandboxUsage extends OdsCommand { !hasDetailedData && !(anyUsage.minutesUpByProfile && anyUsage.minutesUpByProfile.length > 0) ) { - console.log( + this.log( t('commands.sandbox.usage.emptyPeriod', 'No usage data was returned for this sandbox in the requested period.'), ); } else if (hasDetailedData) { - console.log(); + this.log(''); - console.log( + this.log( t( 'commands.sandbox.usage.detailedHint', 'Detailed usage data is available; re-run with --json to see full details.', diff --git a/packages/b2c-cli/src/commands/scapi/schemas/get.ts b/packages/b2c-cli/src/commands/scapi/schemas/get.ts index 97bd3906a..405be5108 100644 --- a/packages/b2c-cli/src/commands/scapi/schemas/get.ts +++ b/packages/b2c-cli/src/commands/scapi/schemas/get.ts @@ -11,7 +11,8 @@ import { getExampleNames, type OpenApiSchemaInput, } from '@salesforce/b2c-tooling-sdk/schemas'; -import {ScapiSchemasCommand, formatApiError} from '../../../utils/scapi/schemas.js'; +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; +import {ScapiSchemasCommand} from '../../../utils/scapi/schemas.js'; import {t, withDocs} from '../../../i18n/index.js'; /** @@ -186,7 +187,7 @@ export default class ScapiSchemasGet extends ScapiSchemasCommand = { - ERROR: '\u001B[31m', // Red - FATAL: '\u001B[35m', // Magenta - WARN: '\u001B[33m', // Yellow - INFO: '\u001B[36m', // Cyan - DEBUG: '\u001B[90m', // Gray - TRACE: '\u001B[90m', // Gray -}; - -const RESET = '\u001B[0m'; -const DIM = '\u001B[2m'; -const BOLD = '\u001B[1m'; +const {RESET, DIM, BOLD} = ANSI; /** * Formats a log entry for human-readable output. diff --git a/packages/b2c-cli/src/utils/mrt-logs/format.ts b/packages/b2c-cli/src/utils/mrt-logs/format.ts index 1da130635..96ab38de2 100644 --- a/packages/b2c-cli/src/utils/mrt-logs/format.ts +++ b/packages/b2c-cli/src/utils/mrt-logs/format.ts @@ -5,25 +5,9 @@ */ import type {MrtLogEntry} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {ANSI, LEVEL_COLORS} from '@salesforce/b2c-tooling-sdk/cli'; -/** - * ANSI color codes for log levels. - * Matches the color scheme from logs/format.ts for consistency. - */ -const LEVEL_COLORS: Record = { - ERROR: '\u001B[31m', // Red - FATAL: '\u001B[35m', // Magenta - WARN: '\u001B[33m', // Yellow - WARNING: '\u001B[33m', // Yellow - INFO: '\u001B[36m', // Cyan - DEBUG: '\u001B[90m', // Gray - TRACE: '\u001B[90m', // Gray -}; - -const RESET = '\u001B[0m'; -const DIM = '\u001B[2m'; -const BOLD = '\u001B[1m'; -const HIGHLIGHT = '\u001B[1;33m'; // Bold yellow for search matches +const {RESET, DIM, BOLD, HIGHLIGHT} = ANSI; /** * Options for formatting an MRT log entry. diff --git a/packages/b2c-cli/src/utils/scapi/schemas.ts b/packages/b2c-cli/src/utils/scapi/schemas.ts index 48ad65c33..5f4c5d037 100644 --- a/packages/b2c-cli/src/utils/scapi/schemas.ts +++ b/packages/b2c-cli/src/utils/scapi/schemas.ts @@ -8,10 +8,6 @@ import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {createScapiSchemasClient, type ScapiSchemasClient} from '@salesforce/b2c-tooling-sdk/clients'; import {t} from '../../i18n/index.js'; -// Backwards-compatible alias for SDK's getApiErrorMessage; existing call sites -// use this name. New code should import getApiErrorMessage from the SDK directly. -export {getApiErrorMessage as formatApiError} from '@salesforce/b2c-tooling-sdk/clients'; - /** * Base command for SCAPI Schemas operations. * Provides common flags and helper methods for interacting with the SCAPI Schemas API. diff --git a/packages/b2c-cli/src/utils/slas/client.ts b/packages/b2c-cli/src/utils/slas/client.ts index 395e9fdd3..8ccd9dd5b 100644 --- a/packages/b2c-cli/src/utils/slas/client.ts +++ b/packages/b2c-cli/src/utils/slas/client.ts @@ -122,10 +122,6 @@ export function parseRedirectUris(redirectUri: string): string[] { .filter(Boolean); } -// Backwards-compatible alias for SDK's getApiErrorMessage; existing call sites -// use this name. New code should import getApiErrorMessage from the SDK directly. -export {getApiErrorMessage as formatApiError} from '@salesforce/b2c-tooling-sdk/clients'; - /** * Base command for SLAS client operations. * Provides common flags and helper methods. diff --git a/packages/b2c-cli/test/utils/scapi/schemas.test.ts b/packages/b2c-cli/test/utils/scapi/schemas.test.ts deleted file mode 100644 index c53f459a5..000000000 --- a/packages/b2c-cli/test/utils/scapi/schemas.test.ts +++ /dev/null @@ -1,51 +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 - */ -import {expect} from 'chai'; -import {formatApiError} from '../../../src/utils/scapi/schemas.js'; - -describe('utils/scapi/schemas', () => { - describe('formatApiError', () => { - it('extracts the standard `message` field from an error object', () => { - const error = {message: 'Not Found'}; - const response = new Response(null, {status: 404, statusText: 'Not Found'}); - const result = formatApiError(error, response); - expect(result).to.equal('Not Found'); - }); - - it('extracts SCAPI/Problem+JSON `detail` over `title`', () => { - const error = {detail: 'The widget is missing.', title: 'Widget Error'}; - const response = new Response(null, {status: 422, statusText: 'Unprocessable Entity'}); - const result = formatApiError(error, response); - expect(result).to.equal('The widget is missing.'); - }); - - it('extracts ODS-style nested `error.message`', () => { - const error = {error: {message: 'Sandbox not found'}}; - const response = new Response(null, {status: 404, statusText: 'Not Found'}); - const result = formatApiError(error, response); - expect(result).to.equal('Sandbox not found'); - }); - - it('extracts OCAPI fault.message', () => { - const error = {fault: {message: 'Invalid filter expression'}}; - const response = new Response(null, {status: 400, statusText: 'Bad Request'}); - const result = formatApiError(error, response); - expect(result).to.equal('Invalid filter expression'); - }); - - it('falls back to the HTTP status line for non-object errors', () => { - const response = new Response(null, {status: 500, statusText: 'Internal Server Error'}); - const result = formatApiError('Server error', response); - expect(result).to.equal('HTTP 500 Internal Server Error'); - }); - - it('falls back to the HTTP status line for null errors', () => { - const response = new Response(null, {status: 400, statusText: 'Bad Request'}); - const result = formatApiError(null, response); - expect(result).to.equal('HTTP 400 Bad Request'); - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index bf22555e5..549eadebe 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -338,9 +338,16 @@ export class OAuthStrategy implements AuthStrategy { }); if (!response.ok) { - const errorText = await response.text(); + let errorText = ''; + try { + errorText = await response.text(); + } catch (err) { + logger.debug({err, method, url}, 'Failed to read response body for OAuth error'); + } logger.trace({method, url, headers: responseHeaders, body: errorText}, `[Auth RESP BODY] ${method} ${url}`); - throw new Error(`Failed to get access token: ${response.status} ${response.statusText} - ${errorText}`); + throw new Error( + `Failed to get access token: ${response.status} ${response.statusText}${errorText ? ' - ' + errorText : ''}`, + ); } const data = (await response.json()) as { diff --git a/packages/b2c-tooling-sdk/src/auth/stateful-store.ts b/packages/b2c-tooling-sdk/src/auth/stateful-store.ts index 40f5ba892..d4df48eb0 100644 --- a/packages/b2c-tooling-sdk/src/auth/stateful-store.ts +++ b/packages/b2c-tooling-sdk/src/auth/stateful-store.ts @@ -100,10 +100,9 @@ export function getStoredSession(): StatefulSession | null { */ export function setStoredSession(session: StatefulSession): void { const filePath = getSessionFilePath(); - const dir = join(filePath, '..'); - if (!existsSync(dir)) { - mkdirSync(dir, {recursive: true}); - } + // mkdirSync({recursive:true}) is idempotent and atomic — no exists-then-create + // TOCTOU window even under concurrent writers. + mkdirSync(join(filePath, '..'), {recursive: true}); const data: StatefulSession = { clientId: session.clientId, accessToken: session.accessToken, @@ -115,7 +114,17 @@ export function setStoredSession(session: StatefulSession): void { // crashed writer) never observes a partially written JSON document. const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8'); - renameSync(tmpPath, filePath); + try { + renameSync(tmpPath, filePath); + } catch (err) { + // Clean up orphan tmp on rename failure so we don't leak files in the data dir. + try { + unlinkSync(tmpPath); + } catch { + /* swallow — the rename error is the one that matters */ + } + throw err; + } } /** diff --git a/packages/b2c-tooling-sdk/src/cli/ansi.ts b/packages/b2c-tooling-sdk/src/cli/ansi.ts new file mode 100644 index 000000000..30ed2b206 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/ansi.ts @@ -0,0 +1,58 @@ +/* + * 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 + */ + +/** + * Shared ANSI control codes and a single source of truth for the level -> color + * palette used by every CLI log/table renderer. Concentrating the palette here + * keeps the CLI's terminal output visually consistent across `b2c logs`, + * `b2c mrt logs`, job log highlighting, and any future stream renderer. + */ + +const ESC = String.fromCharCode(27); + +export const ANSI = { + RESET: `${ESC}[0m`, + DIM: `${ESC}[2m`, + BOLD: `${ESC}[1m`, + HIGHLIGHT: `${ESC}[1;33m`, + // Standalone hues for ad-hoc CLI styling (REPL prompts, status output, cap commands) + RED: `${ESC}[31m`, + GREEN: `${ESC}[32m`, + YELLOW: `${ESC}[33m`, + MAGENTA: `${ESC}[35m`, + CYAN: `${ESC}[36m`, + GRAY: `${ESC}[90m`, +} as const; + +/** + * ANSI color codes by log level. The keys cover the levels emitted by both + * B2C server logs and MRT logs (which uses both `WARN` and `WARNING`). + */ +export const LEVEL_COLORS: Readonly> = { + ERROR: `${ESC}[31m`, // Red + FATAL: `${ESC}[35m`, // Magenta + WARN: `${ESC}[33m`, // Yellow + WARNING: `${ESC}[33m`, // Yellow + INFO: `${ESC}[36m`, // Cyan + DEBUG: `${ESC}[90m`, // Gray + TRACE: `${ESC}[90m`, // Gray +}; + +/** Wrap text in the bold-yellow highlight style. */ +export function colorHighlight(text: string): string { + return `${ANSI.HIGHLIGHT}${text}${ANSI.RESET}`; +} + +/** Wrap a level name in its level color + bold. */ +export function colorLevel(level: string): string { + const color = LEVEL_COLORS[level] || ''; + return `${color}${ANSI.BOLD}${level}${ANSI.RESET}`; +} + +/** Wrap text in the dim style (used for timestamps and metadata brackets). */ +export function colorDim(text: string): string { + return `${ANSI.DIM}${text}${ANSI.RESET}`; +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 6d2727ff4..0381e50e4 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -151,3 +151,6 @@ export {columnFlagsFor, selectColumns} from './columns.js'; export type {ColumnFlags, ColumnFlagsOptions, WarnFn} from './columns.js'; export {printFieldsBlock} from './details.js'; export type {DetailField, DetailFieldObject, DetailSection, DetailValue, PrintFieldsBlockOptions} from './details.js'; + +// ANSI color palette and helpers — single source of truth for log/level colors +export {ANSI, LEVEL_COLORS, colorHighlight, colorLevel, colorDim} from './ansi.js'; diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 536666617..473e02e59 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -296,14 +296,48 @@ export async function loadFullDwJson( } /** - * Save a dw.json configuration to disk. + * Per-path serializer for dw.json mutations. Ensures that concurrent calls to + * addInstance/removeInstance/setActiveInstance from within the same Node process + * read-modify-write atomically against each other (e.g. when a CLI command spawns + * parallel work that all touches dw.json). + * + * Multi-process coordination is out of scope; the writes themselves are atomic + * via tmp-file + rename so a crash mid-write won't corrupt the existing file. + */ +const DW_JSON_LOCKS = new Map>(); + +async function withDwJsonLock(filePath: string, fn: () => Promise): Promise { + const key = path.resolve(filePath); + const previous = DW_JSON_LOCKS.get(key) ?? Promise.resolve(); + const next = previous.then(fn, fn); + DW_JSON_LOCKS.set( + key, + next.finally(() => { + // Only clear if no newer task chained on; otherwise keep the chain alive. + if (DW_JSON_LOCKS.get(key) === next) DW_JSON_LOCKS.delete(key); + }), + ); + return next; +} + +/** + * Save a dw.json configuration to disk atomically (tmp file + rename). * * @param config - The configuration to save * @param filePath - Path to save to */ export async function saveDwJson(config: DwJsonMultiConfig, filePath: string): Promise { const content = JSON.stringify(config, null, 2) + '\n'; - await fsp.writeFile(filePath, content, 'utf8'); + const dir = path.dirname(filePath); + const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`); + await fsp.writeFile(tmpPath, content, 'utf8'); + try { + await fsp.rename(tmpPath, filePath); + } catch (err) { + // Rename failed — clean up the orphan tmp file before bubbling the error. + await fsp.unlink(tmpPath).catch(() => {}); + throw err; + } } /** @@ -334,59 +368,61 @@ export async function addInstance(instance: DwJsonConfig, options: AddInstanceOp const dwJsonPath = options.path ?? path.join(options.projectDirectory || options.workingDirectory || process.cwd(), 'dw.json'); - let existing: DwJsonMultiConfig = {}; - try { - const content = await fsp.readFile(dwJsonPath, 'utf8'); - existing = JSON.parse(content) as DwJsonMultiConfig; - } catch (error) { - // File doesn't exist - start with empty config - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; + await withDwJsonLock(dwJsonPath, async () => { + let existing: DwJsonMultiConfig = {}; + try { + const content = await fsp.readFile(dwJsonPath, 'utf8'); + existing = JSON.parse(content) as DwJsonMultiConfig; + } catch (error) { + // File doesn't exist - start with empty config + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } } - } - // Check if instance name already exists - const instanceName = instance.name; - if (!instanceName) { - throw new Error('Instance must have a name'); - } - - // Check root config - if (existing.name === instanceName) { - throw new Error(`Instance "${instanceName}" already exists`); - } + // Check if instance name already exists + const instanceName = instance.name; + if (!instanceName) { + throw new Error('Instance must have a name'); + } - // Check configs array - if (existing.configs?.some((c) => c.name === instanceName)) { - throw new Error(`Instance "${instanceName}" already exists`); - } + // Check root config + if (existing.name === instanceName) { + throw new Error(`Instance "${instanceName}" already exists`); + } - // Handle setActive - clear other active flags - if (options.setActive) { - instance.active = true; - // Clear active on root if it has it - if (existing.active !== undefined) { - existing.active = false; + // Check configs array + if (existing.configs?.some((c) => c.name === instanceName)) { + throw new Error(`Instance "${instanceName}" already exists`); } - // Clear active on all other configs - if (existing.configs) { - for (const c of existing.configs) { - if (c.active !== undefined) { - c.active = false; + + // Handle setActive - clear other active flags + if (options.setActive) { + instance.active = true; + // Clear active on root if it has it + if (existing.active !== undefined) { + existing.active = false; + } + // Clear active on all other configs + if (existing.configs) { + for (const c of existing.configs) { + if (c.active !== undefined) { + c.active = false; + } } } } - } - // Initialize configs array if needed - if (!existing.configs) { - existing.configs = []; - } + // Initialize configs array if needed + if (!existing.configs) { + existing.configs = []; + } - // Add the new instance - existing.configs.push(instance); + // Add the new instance + existing.configs.push(instance); - await saveDwJson(existing, dwJsonPath); + await saveDwJson(existing, dwJsonPath); + }); } /** @@ -412,31 +448,33 @@ export async function removeInstance(name: string, options: RemoveInstanceOption const dwJsonPath = options.path ?? path.join(options.projectDirectory || options.workingDirectory || process.cwd(), 'dw.json'); - let content: string; - try { - content = await fsp.readFile(dwJsonPath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - throw new Error('No dw.json file found'); + await withDwJsonLock(dwJsonPath, async () => { + let content: string; + try { + content = await fsp.readFile(dwJsonPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('No dw.json file found'); + } + throw error; } - throw error; - } - const existing = JSON.parse(content) as DwJsonMultiConfig; + const existing = JSON.parse(content) as DwJsonMultiConfig; - // Check if trying to remove root config - if (existing.name === name) { - throw new Error(`Cannot remove root instance "${name}". Edit dw.json manually to remove root config.`); - } + // Check if trying to remove root config + if (existing.name === name) { + throw new Error(`Cannot remove root instance "${name}". Edit dw.json manually to remove root config.`); + } - // Find and remove from configs array - if (!existing.configs || !existing.configs.some((c) => c.name === name)) { - throw new Error(`Instance "${name}" not found`); - } + // Find and remove from configs array + if (!existing.configs || !existing.configs.some((c) => c.name === name)) { + throw new Error(`Instance "${name}" not found`); + } - existing.configs = existing.configs.filter((c) => c.name !== name); + existing.configs = existing.configs.filter((c) => c.name !== name); - await saveDwJson(existing, dwJsonPath); + await saveDwJson(existing, dwJsonPath); + }); } /** @@ -462,46 +500,48 @@ export async function setActiveInstance(name: string, options: SetActiveInstance const dwJsonPath = options.path ?? path.join(options.projectDirectory || options.workingDirectory || process.cwd(), 'dw.json'); - let content: string; - try { - content = await fsp.readFile(dwJsonPath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - throw new Error('No dw.json file found'); + await withDwJsonLock(dwJsonPath, async () => { + let content: string; + try { + content = await fsp.readFile(dwJsonPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('No dw.json file found'); + } + throw error; } - throw error; - } - const existing = JSON.parse(content) as DwJsonMultiConfig; + const existing = JSON.parse(content) as DwJsonMultiConfig; - // Find the target instance - let found = false; + // Find the target instance + let found = false; - // Check root config - if (existing.name === name) { - found = true; - existing.active = true; - } else if (existing.active !== undefined) { - existing.active = false; - } + // Check root config + if (existing.name === name) { + found = true; + existing.active = true; + } else if (existing.active !== undefined) { + existing.active = false; + } - // Check and update configs array - if (existing.configs) { - for (const c of existing.configs) { - if (c.name === name) { - found = true; - c.active = true; - } else if (c.active !== undefined) { - c.active = false; + // Check and update configs array + if (existing.configs) { + for (const c of existing.configs) { + if (c.name === name) { + found = true; + c.active = true; + } else if (c.active !== undefined) { + c.active = false; + } } } - } - if (!found) { - throw new Error(`Instance "${name}" not found`); - } + if (!found) { + throw new Error(`Instance "${name}" not found`); + } - await saveDwJson(existing, dwJsonPath); + await saveDwJson(existing, dwJsonPath); + }); } /** diff --git a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts index b0bed4ad5..f6da2c3e8 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts @@ -164,27 +164,30 @@ export async function uploadCartridges( const uploadPath = `Cartridges/_sync-${now}.zip`; const onProgress = options?.onProgress; - // Progress helper: fires immediately (0s) then every 5s until stopped + // Progress helper: fires immediately (0s) then every 5s for the duration of `body`. + // Always tears down the interval, even if `body` throws. const PROGRESS_INTERVAL_MS = 5_000; - function startProgress(phase: UploadProgressInfo['phase']): () => void { + async function withProgress(phase: UploadProgressInfo['phase'], body: () => Promise): Promise { const start = Date.now(); onProgress?.({phase, elapsedSeconds: 0}); - if (!onProgress) return () => {}; + if (!onProgress) return body(); const interval = setInterval(() => { onProgress({phase, elapsedSeconds: Math.round((Date.now() - start) / 1000)}); }, PROGRESS_INTERVAL_MS); - return () => clearInterval(interval); + try { + return await body(); + } finally { + clearInterval(interval); + } } - // Create zip archive + // Create zip archive (one-shot phase signal; no polling — preserves original behavior) onProgress?.({phase: 'archiving', elapsedSeconds: 0}); logger.debug('Creating cartridge archive...'); const zip = new JSZip(); - for (const c of cartridges) { await addDirectoryToZip(zip, c.src, path.join(codeVersion, c.dest)); } - const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', @@ -193,34 +196,32 @@ export async function uploadCartridges( logger.debug({size: buffer.length}, `Archive created: ${buffer.length} bytes`); // Upload archive - let stopProgress = startProgress('uploading'); - logger.debug({uploadPath}, 'Uploading archive...'); - try { + await withProgress('uploading', async () => { + logger.debug({uploadPath}, 'Uploading archive...'); await webdav.put(uploadPath, buffer, 'application/zip'); - } finally { - stopProgress(); - } - logger.debug('Archive uploaded'); + logger.debug('Archive uploaded'); + }); // Unzip on server - stopProgress = startProgress('unzipping'); - logger.debug('Unzipping archive on server...'); - let response: Response; - try { - response = await webdav.request(uploadPath, { + const response = await withProgress('unzipping', async () => { + logger.debug('Unzipping archive on server...'); + return webdav.request(uploadPath, { method: 'POST', body: UNZIP_BODY, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); - } finally { - stopProgress(); - } + }); if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to unzip archive: ${response.status} ${response.statusText} - ${text}`); + let text = ''; + try { + text = await response.text(); + } catch (err) { + logger.debug({err}, 'Failed to read response body for unzip error'); + } + throw new Error(`Failed to unzip archive: ${response.status} ${response.statusText}${text ? ' - ' + text : ''}`); } logger.debug('Archive unzipped'); diff --git a/packages/b2c-tooling-sdk/src/operations/code/download.ts b/packages/b2c-tooling-sdk/src/operations/code/download.ts index 958cb1531..c182a5160 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/download.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/download.ts @@ -49,19 +49,25 @@ export interface DownloadResult { outputDirectory: string; } -// Progress helper: fires immediately (0s) then every 5s until stopped +// Progress helper: fires immediately (0s) then every 5s for the duration of `body`. +// Always tears down the interval, even if `body` throws. const PROGRESS_INTERVAL_MS = 5_000; -function startProgress( +async function withProgress( phase: DownloadProgressInfo['phase'], - onProgress?: (info: DownloadProgressInfo) => void, -): () => void { + onProgress: ((info: DownloadProgressInfo) => void) | undefined, + body: () => Promise, +): Promise { const start = Date.now(); onProgress?.({phase, elapsedSeconds: 0}); - if (!onProgress) return () => {}; + if (!onProgress) return body(); const interval = setInterval(() => { onProgress({phase, elapsedSeconds: Math.round((Date.now() - start) / 1000)}); }, PROGRESS_INTERVAL_MS); - return () => clearInterval(interval); + try { + return await body(); + } finally { + clearInterval(interval); + } } /** @@ -142,8 +148,9 @@ async function extractZip( } let targetPath: string; - if (options.mirror?.has(cartridgeName)) { - targetPath = path.join(options.mirror.get(cartridgeName)!, relativePath); + const mirrorPath = options.mirror?.get(cartridgeName); + if (mirrorPath !== undefined) { + targetPath = path.join(mirrorPath, relativePath); } else { targetPath = path.join(options.outputDirectory, cartridgeName, relativePath); } @@ -196,28 +203,29 @@ export async function downloadSingleCartridge( const cartridgePath = `Cartridges/${codeVersion}/${cartridgeName}`; const zipPath = `${cartridgePath}.zip`; - let stopProgress = startProgress('zipping', onProgress); logger.debug({cartridgeName, codeVersion}, 'Requesting server-side zip for single cartridge...'); - let zipResponse: Response; - try { - zipResponse = await webdav.request(cartridgePath, { + const zipResponse = await withProgress('zipping', onProgress, () => + webdav.request(cartridgePath, { method: 'POST', body: ZIP_BODY, headers: {'Content-Type': 'application/x-www-form-urlencoded'}, signal: AbortSignal.timeout(LONG_OPERATION_TIMEOUT_MS), - }); - } finally { - stopProgress(); - } + }), + ); if (!zipResponse.ok) { - const text = await zipResponse.text(); - throw new Error(`Failed to create server-side zip: ${zipResponse.status} ${zipResponse.statusText} - ${text}`); + let text = ''; + try { + text = await zipResponse.text(); + } catch (err) { + logger.debug({err}, 'Failed to read response body for server-zip error'); + } + throw new Error( + `Failed to create server-side zip: ${zipResponse.status} ${zipResponse.statusText}${text ? ' - ' + text : ''}`, + ); } - stopProgress = startProgress('downloading', onProgress); - let buffer: ArrayBuffer; - try { + const buffer = await withProgress('downloading', onProgress, async () => { const dlResponse = await webdav.request(zipPath, { method: 'GET', signal: AbortSignal.timeout(LONG_OPERATION_TIMEOUT_MS), @@ -225,10 +233,8 @@ export async function downloadSingleCartridge( if (!dlResponse.ok) { throw new Error(`Failed to download zip: ${dlResponse.status} ${dlResponse.statusText}`); } - buffer = await dlResponse.arrayBuffer(); - } finally { - stopProgress(); - } + return dlResponse.arrayBuffer(); + }); logger.debug({size: buffer.byteLength}, `Archive downloaded: ${buffer.byteLength} bytes`); // Cleanup server-side zip (best effort) @@ -300,9 +306,8 @@ export async function downloadCartridges( for (const cartridgeName of include) { if (exclude?.length && exclude.includes(cartridgeName)) continue; - const outputPath = mirror?.has(cartridgeName) - ? mirror.get(cartridgeName)! - : path.join(resolvedOutput, cartridgeName); + const mirrorPath = mirror?.get(cartridgeName); + const outputPath = mirrorPath ?? path.join(resolvedOutput, cartridgeName); await downloadSingleCartridge(instance, codeVersion, cartridgeName, outputPath, onProgress); allExtracted.add(cartridgeName); @@ -316,32 +321,33 @@ export async function downloadCartridges( const webdav = instance.webdav; const zipPath = `Cartridges/${codeVersion}.zip`; - let stopProgress = startProgress('zipping', onProgress); logger.debug({codeVersion}, 'Requesting server-side zip...'); - let zipResponse: Response; - try { - zipResponse = await webdav.request(`Cartridges/${codeVersion}`, { + const zipResponse = await withProgress('zipping', onProgress, () => + webdav.request(`Cartridges/${codeVersion}`, { method: 'POST', body: ZIP_BODY, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, signal: AbortSignal.timeout(LONG_OPERATION_TIMEOUT_MS), - }); - } finally { - stopProgress(); - } + }), + ); if (!zipResponse.ok) { - const text = await zipResponse.text(); - throw new Error(`Failed to create server-side zip: ${zipResponse.status} ${zipResponse.statusText} - ${text}`); + let text = ''; + try { + text = await zipResponse.text(); + } catch (err) { + logger.debug({err}, 'Failed to read response body for server-zip error'); + } + throw new Error( + `Failed to create server-side zip: ${zipResponse.status} ${zipResponse.statusText}${text ? ' - ' + text : ''}`, + ); } logger.debug('Server-side zip created'); - stopProgress = startProgress('downloading', onProgress); logger.debug({zipPath}, 'Downloading zip archive...'); - let buffer: ArrayBuffer; - try { + const buffer = await withProgress('downloading', onProgress, async () => { const downloadResponse = await webdav.request(zipPath, { method: 'GET', signal: AbortSignal.timeout(LONG_OPERATION_TIMEOUT_MS), @@ -349,10 +355,8 @@ export async function downloadCartridges( if (!downloadResponse.ok) { throw new Error(`Failed to download zip: ${downloadResponse.status} ${downloadResponse.statusText}`); } - buffer = await downloadResponse.arrayBuffer(); - } finally { - stopProgress(); - } + return downloadResponse.arrayBuffer(); + }); logger.debug({size: buffer.byteLength}, `Archive downloaded: ${buffer.byteLength} bytes`); // Cleanup server-side zip (best-effort) diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts index 5192b7151..3664244d2 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts @@ -127,10 +127,18 @@ export async function executeJob( body: body as unknown as string, }); - // Handle JobAlreadyRunningException + // Handle JobAlreadyRunningException. The fault is reported as 400 with the + // exception name in the body; we check for it textually because the OCAPI + // error envelope is generic. If we can't read the body, fall through to the + // normal error path rather than silently treating it as "not the running case". if (response.status === 400) { - // Need to check fault type - read raw response - const errorBody = await response.text().catch(() => ''); + let errorBody: string; + try { + errorBody = await response.text(); + } catch (textErr) { + logger.debug({jobId, error: textErr}, 'Failed to read 400 response body for JobAlreadyRunning detection'); + errorBody = ''; + } if (errorBody.includes('JobAlreadyRunningException')) { if (waitForRunning) { logger.warn({jobId}, `Job ${jobId} already running, waiting for it to finish...`); diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/bundle.ts b/packages/b2c-tooling-sdk/src/operations/mrt/bundle.ts index 46c37e64b..070677e7f 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/bundle.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/bundle.ts @@ -192,12 +192,17 @@ async function loadServerConfig(buildPath: string): Promise { - res.writeHead(500, { - 'Content-Type': 'text/plain', - }); + // headers may already be flushed if the upstream began streaming before erroring; + // calling writeHead twice throws ERR_HTTP_HEADERS_SENT and masks the original error. + if (!res.headersSent) { + res.writeHead(500, { + 'Content-Type': 'text/plain', + }); + } res.end(`Error in proxy request to ${req.url}: ${err}`); }, diff --git a/packages/mrt-utilities/src/utils/ssr-proxying.ts b/packages/mrt-utilities/src/utils/ssr-proxying.ts index 8c7599593..b8463f1e6 100644 --- a/packages/mrt-utilities/src/utils/ssr-proxying.ts +++ b/packages/mrt-utilities/src/utils/ssr-proxying.ts @@ -5,29 +5,14 @@ */ /** - * @fileoverview SSR (Server-Side Rendering) Proxying utilities for MRT middleware. + * @fileoverview SSR proxying utilities for MRT middleware. * - * This module provides utilities for handling HTTP headers, cookies, and proxying - * in both Express.js applications and AWS Lambda@Edge functions. It's designed - * to work in multiple contexts while maintaining consistency. - * - * Special requirements: - * - Don't add any functionality in here that is not required by the proxying code - * - Avoid importing any other modules not explicitly used by this code - * - Must work in both Express.js and Lambda@Edge environments - * - * @author Salesforce Commerce Cloud - * @version 0.0.1 + * Used by both the SDK (Express) and Lambda@Edge functions on CloudFront, so: + * - keep it free of dependencies that aren't strictly required for proxying + * - avoid features that aren't needed by the proxy path + * - it must work in both Express.js and Lambda@Edge runtimes */ -/* -There are some special requirements for this module, which is used in the -SDK and also in Lambda@Edge functions run by CloudFront. Specifically: -- Don't add any functionality in here that is not required by the -proxying code. -- Avoid importing any other modules not explicitly used by this code -*/ - import {parse as parseSetCookie} from 'set-cookie-parser'; import {trainCase} from 'change-case'; import {URL} from 'url';