diff --git a/.changeset/job-log-command.md b/.changeset/job-log-command.md new file mode 100644 index 00000000..c3b6770c --- /dev/null +++ b/.changeset/job-log-command.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-cli': minor +--- + +Add `job log` command to retrieve and display job execution logs. Supports fetching logs for a specific execution or automatically finding the most recent (or most recent failed) execution with a log file. diff --git a/docs/cli/jobs.md b/docs/cli/jobs.md index 243996fc..cee1fa22 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -17,12 +17,12 @@ Configure these resources in Business Manager under **Administration** > **Site | Resource | Methods | Commands | |----------|---------|----------| | `/jobs/*/executions` | POST | `job run` | -| `/jobs/*/executions/*` | GET | `job run --wait`, `job wait` | -| `/job_execution_search` | POST | `job search` | +| `/jobs/*/executions/*` | GET | `job run --wait`, `job wait`, `job log` | +| `/job_execution_search` | POST | `job search`, `job log` | ### WebDAV Access -The `job import` and `job export` commands also require WebDAV access for file transfer. +The `job import`, `job export`, and `job log` commands also require WebDAV access for file transfer. ### Configuration @@ -199,6 +199,59 @@ The command displays a table of job executions with: --- +## b2c job log + +Retrieve the log for a job execution. When no execution ID is provided, the command finds the most recent execution that has a log file. + +### Usage + +```bash +b2c job log JOBID [EXECUTIONID] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `JOBID` | Job ID | Yes | +| `EXECUTIONID` | Execution ID (if omitted, finds the most recent execution with a log) | No | + +### Flags + +In addition to [global flags](./index#global-flags): + +| Flag | Description | Default | +|------|-------------|---------| +| `--failed` | Find the most recent failed execution with a log | `false` | + +### Examples + +```bash +# Get the most recent log for a job +b2c job log my-custom-job + +# Get the most recent failed log +b2c job log my-custom-job --failed + +# Get the log for a specific execution +b2c job log my-custom-job abc123-def456 + +# Output as JSON (includes execution metadata and log content) +b2c job log my-custom-job --json + +# Pipe log to a file +b2c job log my-custom-job > job.log +``` + +### Notes + +- Not all job executions produce log files. The command will skip executions without logs when searching. +- Log content is written to stdout, making it easy to pipe to a file or other tools. +- Status messages are written to stderr so they don't interfere with piped output. +- The `job log` command requires WebDAV access to retrieve log files. + +--- + ## b2c job import Import a site archive to a B2C Commerce instance using the `sfcc-site-archive-import` system job. diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index a7b4393d..7a4d224c 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -311,7 +311,7 @@ "build": "shx rm -rf dist && tsc -p tsconfig.build.json", "lint": "eslint", "lint:agent": "eslint --quiet", - "typecheck:agent": "tsc --noEmit --pretty false", + "typecheck:agent": "tsc --noEmit -p test --pretty false", "format": "prettier --write src", "format:check": "prettier --check src", "postpack": "shx rm -f oclif.manifest.json", diff --git a/packages/b2c-cli/src/commands/job/log.ts b/packages/b2c-cli/src/commands/job/log.ts new file mode 100644 index 00000000..7771bd87 --- /dev/null +++ b/packages/b2c-cli/src/commands/job/log.ts @@ -0,0 +1,140 @@ +/* + * 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 {Args, Flags} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import { + searchJobExecutions, + getJobExecution, + getJobLog, + type JobExecution, +} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {t, withDocs} from '../../i18n/index.js'; +import {highlightLogText} from '../../utils/logs/index.js'; + +interface JobLogResult { + execution: JobExecution; + log: string; +} + +export default class JobLog extends InstanceCommand { + static args = { + jobId: Args.string({ + description: 'Job ID', + required: true, + }), + executionId: Args.string({ + description: 'Execution ID (if omitted, finds the most recent execution with a log)', + required: false, + }), + }; + + static description = withDocs( + t('commands.job.log.description', 'Retrieve the log for a job execution'), + '/cli/jobs.html#b2c-job-log', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> my-job', + '<%= config.bin %> <%= command.id %> my-job --failed', + '<%= config.bin %> <%= command.id %> my-job abc123-def456', + '<%= config.bin %> <%= command.id %> my-job --json', + ]; + + static flags = { + ...InstanceCommand.baseFlags, + failed: Flags.boolean({ + description: 'Find the most recent failed execution with a log', + default: false, + }), + 'no-color': Flags.boolean({ + description: 'Disable colored output', + default: false, + }), + }; + + protected operations = { + searchJobExecutions, + getJobExecution, + getJobLog, + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {jobId, executionId} = this.args; + const {failed} = this.flags; + + let execution: JobExecution; + + if (executionId) { + this.log( + t('commands.job.log.fetchingSpecific', 'Fetching log for job {{jobId}} execution {{executionId}}...', { + jobId, + executionId, + }), + ); + execution = await this.operations.getJobExecution(this.instance, jobId, executionId); + } else { + this.log( + failed + ? t( + 'commands.job.log.searchingFailed', + 'Searching for most recent failed execution with log for job {{jobId}}...', + {jobId}, + ) + : t('commands.job.log.searching', 'Searching for most recent execution with log for job {{jobId}}...', { + jobId, + }), + ); + + const results = await this.operations.searchJobExecutions(this.instance, { + jobId, + status: failed ? ['ERROR'] : undefined, + count: 10, + sortBy: 'start_time', + sortOrder: 'desc', + }); + + const match = results.hits.find((hit) => hit.is_log_file_existing); + if (!match) { + const msg = failed + ? t( + 'commands.job.log.noFailedExecutionFound', + 'No failed execution with a log file found for job {{jobId}}', + {jobId}, + ) + : t('commands.job.log.noExecutionFound', 'No execution with a log file found for job {{jobId}}', { + jobId, + }); + this.error(msg); + } + + execution = match; + } + + if (!execution.is_log_file_existing) { + this.error(t('commands.job.log.noLogFile', 'No log file exists for this execution')); + } + + this.log( + t('commands.job.log.foundExecution', 'Found execution {{executionId}} ({{status}})', { + executionId: execution.id ?? 'unknown', + status: execution.exit_status?.code || execution.execution_status || 'unknown', + }), + ); + + const log = await this.operations.getJobLog(this.instance, execution); + + if (!this.jsonEnabled()) { + const useColor = !this.flags['no-color'] && process.stdout.isTTY; + process.stdout.write(useColor ? highlightLogText(log) : log); + } + + return {execution, log}; + } +} diff --git a/packages/b2c-cli/src/utils/logs/format.ts b/packages/b2c-cli/src/utils/logs/format.ts index 235c6bad..a09f71cf 100644 --- a/packages/b2c-cli/src/utils/logs/format.ts +++ b/packages/b2c-cli/src/utils/logs/format.ts @@ -69,6 +69,30 @@ export function formatEntry(entry: LogEntry, useColor: boolean): string { return `${header}\n${entry.message}\n`; } +/** + * Matches a B2C log line start: [YYYY-MM-DD HH:MM:SS.mmm GMT] LEVEL ... + */ +const LOG_LINE_RE = /^(\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+\w+\])\s+(ERROR|FATAL|WARN|INFO|DEBUG|TRACE)\b/; + +/** + * Applies ANSI highlighting to raw log text, line by line. + * Timestamps are dimmed, log levels are colored to match `formatEntry` output. + * Useful for job logs and other raw log content that hasn't been parsed into LogEntry objects. + */ +export function highlightLogText(text: string): string { + return text + .split('\n') + .map((line) => { + const match = LOG_LINE_RE.exec(line); + if (!match) return line; + const [, timestamp, level] = match; + const color = LEVEL_COLORS[level] || ''; + const rest = line.slice(match[0].length); + return `${DIM}${timestamp}${RESET} ${color}${level}${RESET}${rest}`; + }) + .join('\n'); +} + /** * Sets up a path normalizer for IDE click-to-open functionality. * Priority: 1) explicit cartridgePath, 2) auto-discover cartridges, 3) undefined (no normalization) diff --git a/packages/b2c-cli/test/commands/job/log.test.ts b/packages/b2c-cli/test/commands/job/log.test.ts new file mode 100644 index 00000000..9358c83e --- /dev/null +++ b/packages/b2c-cli/test/commands/job/log.test.ts @@ -0,0 +1,139 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import JobLog from '../../../src/commands/job/log.js'; +import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../helpers/test-setup.js'; + +describe('job log', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(JobLog, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any) { + const instance = {config: {hostname: 'example.com'}}; + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'log').returns(void 0); + return instance; + } + + it('fetches log for a specific execution', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + const instance = stubCommon(command); + sinon.stub(command, 'jsonEnabled').returns(false); + + const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; + const getJobExecutionStub = sinon.stub().resolves(execution); + const getJobLogStub = sinon.stub().resolves('log content here'); + command.operations = {...command.operations, getJobExecution: getJobExecutionStub, getJobLog: getJobLogStub}; + + const result = (await runSilent(() => command.run())) as {execution: unknown; log: string}; + + expect(getJobExecutionStub.calledOnce).to.equal(true); + expect(getJobExecutionStub.getCall(0).args[0]).to.equal(instance); + expect(getJobExecutionStub.getCall(0).args[1]).to.equal('my-job'); + expect(getJobExecutionStub.getCall(0).args[2]).to.equal('exec-1'); + expect(getJobLogStub.calledOnce).to.equal(true); + expect(result.log).to.equal('log content here'); + expect(result.execution).to.equal(execution); + }); + + it('searches for most recent execution with log', async () => { + const command: any = await createCommand({}, {jobId: 'my-job'}); + const instance = stubCommon(command); + sinon.stub(command, 'jsonEnabled').returns(false); + + const execWithoutLog = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: false}; + const execWithLog = {id: 'exec-2', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; + const searchStub = sinon.stub().resolves({total: 2, hits: [execWithoutLog, execWithLog]}); + const getJobLogStub = sinon.stub().resolves('log from exec-2'); + command.operations = {...command.operations, searchJobExecutions: searchStub, getJobLog: getJobLogStub}; + + const result = (await runSilent(() => command.run())) as {log: string}; + + expect(searchStub.calledOnce).to.equal(true); + expect(searchStub.getCall(0).args[0]).to.equal(instance); + expect(searchStub.getCall(0).args[1]).to.deep.include({jobId: 'my-job'}); + expect(getJobLogStub.calledOnce).to.equal(true); + expect(getJobLogStub.getCall(0).args[1]).to.equal(execWithLog); + expect(result.log).to.equal('log from exec-2'); + }); + + it('searches for most recent failed execution with --failed', async () => { + const command: any = await createCommand({failed: true}, {jobId: 'my-job'}); + stubCommon(command); + sinon.stub(command, 'jsonEnabled').returns(false); + + const execution = {id: 'exec-3', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'ERROR'}}; + const searchStub = sinon.stub().resolves({total: 1, hits: [execution]}); + const getJobLogStub = sinon.stub().resolves('error log'); + command.operations = {...command.operations, searchJobExecutions: searchStub, getJobLog: getJobLogStub}; + + const result = (await runSilent(() => command.run())) as {log: string}; + + expect(searchStub.getCall(0).args[1]).to.deep.include({status: ['ERROR']}); + expect(result.log).to.equal('error log'); + }); + + it('errors when specific execution has no log file', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + stubCommon(command); + + const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: false}; + sinon.stub().resolves(execution); + command.operations = {...command.operations, getJobExecution: sinon.stub().resolves(execution)}; + + try { + await command.run(); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).to.include('No log file exists'); + } + }); + + it('errors when no executions with log found', async () => { + const command: any = await createCommand({}, {jobId: 'my-job'}); + stubCommon(command); + + const searchStub = sinon.stub().resolves({total: 0, hits: []}); + command.operations = {...command.operations, searchJobExecutions: searchStub}; + + try { + await command.run(); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).to.include('No execution with a log file found'); + } + }); + + it('returns structured result in json mode', async () => { + const command: any = await createCommand({json: true}, {jobId: 'my-job', executionId: 'exec-1'}); + stubCommon(command); + sinon.stub(command, 'jsonEnabled').returns(true); + + const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; + command.operations = { + ...command.operations, + getJobExecution: sinon.stub().resolves(execution), + getJobLog: sinon.stub().resolves('json log content'), + }; + + const result = await command.run(); + + expect(result).to.have.property('execution'); + expect(result).to.have.property('log', 'json log content'); + }); +}); diff --git a/packages/b2c-cli/test/utils/logs/format.test.ts b/packages/b2c-cli/test/utils/logs/format.test.ts index e57350de..eb0b0493 100644 --- a/packages/b2c-cli/test/utils/logs/format.test.ts +++ b/packages/b2c-cli/test/utils/logs/format.test.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 {expect} from 'chai'; -import {formatEntry, setupPathNormalizer, DEFAULT_PREFIXES} from '../../../src/utils/logs/format.js'; +import {formatEntry, highlightLogText, setupPathNormalizer, DEFAULT_PREFIXES} from '../../../src/utils/logs/format.js'; describe('utils/logs/format', () => { describe('DEFAULT_PREFIXES', () => { @@ -78,6 +78,48 @@ describe('utils/logs/format', () => { }); }); + describe('highlightLogText', () => { + it('highlights log levels and dims timestamps', () => { + const input = '[2026-03-16 17:05:08.685 GMT] INFO system-job|12345 - Starting import'; + const result = highlightLogText(input); + // Should contain ANSI codes + expect(result).to.include('\u001B['); + // Should still contain the text content + expect(result).to.include('INFO'); + expect(result).to.include('Starting import'); + }); + + it('highlights ERROR lines in red', () => { + const input = '[2026-03-16 17:05:08.685 GMT] ERROR system-job|12345 - Import failed'; + const result = highlightLogText(input); + expect(result).to.include('\u001B[31m'); // Red + expect(result).to.include('ERROR'); + }); + + it('leaves non-matching lines unchanged', () => { + const input = ' at com.example.SomeClass.method(SomeClass.java:42)'; + const result = highlightLogText(input); + expect(result).to.equal(input); + }); + + it('handles multi-line text with mixed content', () => { + const input = [ + '[2026-03-16 17:05:08.685 GMT] ERROR system-job|1 - Failed', + ' stack trace line 1', + ' stack trace line 2', + '[2026-03-16 17:05:09.000 GMT] INFO system-job|2 - Done', + ].join('\n'); + const result = highlightLogText(input); + const lines = result.split('\n'); + // First and last lines should have ANSI codes + expect(lines[0]).to.include('\u001B['); + expect(lines[3]).to.include('\u001B['); + // Stack trace lines should be unchanged + expect(lines[1]).to.equal(' stack trace line 1'); + expect(lines[2]).to.equal(' stack trace line 2'); + }); + }); + describe('setupPathNormalizer', () => { it('returns undefined when noNormalize is true', () => { const result = setupPathNormalizer(undefined, true);