Skip to content

Commit b1c9aa1

Browse files
committed
feat: add job log command to retrieve job execution logs
New command to fetch and display job execution logs with three modes: - Specific execution: `b2c job log <jobId> <executionId>` - Most recent with log: `b2c job log <jobId>` - Most recent failed: `b2c job log <jobId> --failed` Includes ANSI syntax highlighting (shared with `b2c logs`) for timestamps and log levels when output is a TTY.
1 parent fe25ae6 commit b1c9aa1

6 files changed

Lines changed: 407 additions & 4 deletions

File tree

.changeset/job-log-command.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-cli': minor
3+
---
4+
5+
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.

docs/cli/jobs.md

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ Configure these resources in Business Manager under **Administration** > **Site
1717
| Resource | Methods | Commands |
1818
|----------|---------|----------|
1919
| `/jobs/*/executions` | POST | `job run` |
20-
| `/jobs/*/executions/*` | GET | `job run --wait`, `job wait` |
21-
| `/job_execution_search` | POST | `job search` |
20+
| `/jobs/*/executions/*` | GET | `job run --wait`, `job wait`, `job log` |
21+
| `/job_execution_search` | POST | `job search`, `job log` |
2222

2323
### WebDAV Access
2424

25-
The `job import` and `job export` commands also require WebDAV access for file transfer.
25+
The `job import`, `job export`, and `job log` commands also require WebDAV access for file transfer.
2626

2727
### Configuration
2828

@@ -199,6 +199,59 @@ The command displays a table of job executions with:
199199

200200
---
201201

202+
## b2c job log
203+
204+
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.
205+
206+
### Usage
207+
208+
```bash
209+
b2c job log JOBID [EXECUTIONID]
210+
```
211+
212+
### Arguments
213+
214+
| Argument | Description | Required |
215+
|----------|-------------|----------|
216+
| `JOBID` | Job ID | Yes |
217+
| `EXECUTIONID` | Execution ID (if omitted, finds the most recent execution with a log) | No |
218+
219+
### Flags
220+
221+
In addition to [global flags](./index#global-flags):
222+
223+
| Flag | Description | Default |
224+
|------|-------------|---------|
225+
| `--failed` | Find the most recent failed execution with a log | `false` |
226+
227+
### Examples
228+
229+
```bash
230+
# Get the most recent log for a job
231+
b2c job log my-custom-job
232+
233+
# Get the most recent failed log
234+
b2c job log my-custom-job --failed
235+
236+
# Get the log for a specific execution
237+
b2c job log my-custom-job abc123-def456
238+
239+
# Output as JSON (includes execution metadata and log content)
240+
b2c job log my-custom-job --json
241+
242+
# Pipe log to a file
243+
b2c job log my-custom-job > job.log
244+
```
245+
246+
### Notes
247+
248+
- Not all job executions produce log files. The command will skip executions without logs when searching.
249+
- Log content is written to stdout, making it easy to pipe to a file or other tools.
250+
- Status messages are written to stderr so they don't interfere with piped output.
251+
- The `job log` command requires WebDAV access to retrieve log files.
252+
253+
---
254+
202255
## b2c job import
203256

204257
Import a site archive to a B2C Commerce instance using the `sfcc-site-archive-import` system job.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import {Args, Flags} from '@oclif/core';
7+
import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli';
8+
import {
9+
searchJobExecutions,
10+
getJobExecution,
11+
getJobLog,
12+
type JobExecution,
13+
} from '@salesforce/b2c-tooling-sdk/operations/jobs';
14+
import {t, withDocs} from '../../i18n/index.js';
15+
import {highlightLogText} from '../../utils/logs/index.js';
16+
17+
interface JobLogResult {
18+
execution: JobExecution;
19+
log: string;
20+
}
21+
22+
export default class JobLog extends InstanceCommand<typeof JobLog> {
23+
static args = {
24+
jobId: Args.string({
25+
description: 'Job ID',
26+
required: true,
27+
}),
28+
executionId: Args.string({
29+
description: 'Execution ID (if omitted, finds the most recent execution with a log)',
30+
required: false,
31+
}),
32+
};
33+
34+
static description = withDocs(
35+
t('commands.job.log.description', 'Retrieve the log for a job execution'),
36+
'/cli/jobs.html#b2c-job-log',
37+
);
38+
39+
static enableJsonFlag = true;
40+
41+
static examples = [
42+
'<%= config.bin %> <%= command.id %> my-job',
43+
'<%= config.bin %> <%= command.id %> my-job --failed',
44+
'<%= config.bin %> <%= command.id %> my-job abc123-def456',
45+
'<%= config.bin %> <%= command.id %> my-job --json',
46+
];
47+
48+
static flags = {
49+
...InstanceCommand.baseFlags,
50+
failed: Flags.boolean({
51+
description: 'Find the most recent failed execution with a log',
52+
default: false,
53+
}),
54+
'no-color': Flags.boolean({
55+
description: 'Disable colored output',
56+
default: false,
57+
}),
58+
};
59+
60+
protected operations = {
61+
searchJobExecutions,
62+
getJobExecution,
63+
getJobLog,
64+
};
65+
66+
async run(): Promise<JobLogResult> {
67+
this.requireOAuthCredentials();
68+
69+
const {jobId, executionId} = this.args;
70+
const {failed} = this.flags;
71+
72+
let execution: JobExecution;
73+
74+
if (executionId) {
75+
this.log(
76+
t('commands.job.log.fetchingSpecific', 'Fetching log for job {{jobId}} execution {{executionId}}...', {
77+
jobId,
78+
executionId,
79+
}),
80+
);
81+
execution = await this.operations.getJobExecution(this.instance, jobId, executionId);
82+
} else {
83+
this.log(
84+
failed
85+
? t(
86+
'commands.job.log.searchingFailed',
87+
'Searching for most recent failed execution with log for job {{jobId}}...',
88+
{jobId},
89+
)
90+
: t('commands.job.log.searching', 'Searching for most recent execution with log for job {{jobId}}...', {
91+
jobId,
92+
}),
93+
);
94+
95+
const results = await this.operations.searchJobExecutions(this.instance, {
96+
jobId,
97+
status: failed ? ['ERROR'] : undefined,
98+
count: 10,
99+
sortBy: 'start_time',
100+
sortOrder: 'desc',
101+
});
102+
103+
const match = results.hits.find((hit) => hit.is_log_file_existing);
104+
if (!match) {
105+
const msg = failed
106+
? t(
107+
'commands.job.log.noFailedExecutionFound',
108+
'No failed execution with a log file found for job {{jobId}}',
109+
{jobId},
110+
)
111+
: t('commands.job.log.noExecutionFound', 'No execution with a log file found for job {{jobId}}', {
112+
jobId,
113+
});
114+
this.error(msg);
115+
}
116+
117+
execution = match;
118+
}
119+
120+
if (!execution.is_log_file_existing) {
121+
this.error(t('commands.job.log.noLogFile', 'No log file exists for this execution'));
122+
}
123+
124+
this.log(
125+
t('commands.job.log.foundExecution', 'Found execution {{executionId}} ({{status}})', {
126+
executionId: execution.id ?? 'unknown',
127+
status: execution.exit_status?.code || execution.execution_status || 'unknown',
128+
}),
129+
);
130+
131+
const log = await this.operations.getJobLog(this.instance, execution);
132+
133+
if (!this.jsonEnabled()) {
134+
const useColor = !this.flags['no-color'] && process.stdout.isTTY;
135+
process.stdout.write(useColor ? highlightLogText(log) : log);
136+
}
137+
138+
return {execution, log};
139+
}
140+
}

packages/b2c-cli/src/utils/logs/format.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,30 @@ export function formatEntry(entry: LogEntry, useColor: boolean): string {
6969
return `${header}\n${entry.message}\n`;
7070
}
7171

72+
/**
73+
* Matches a B2C log line start: [YYYY-MM-DD HH:MM:SS.mmm GMT] LEVEL ...
74+
*/
75+
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/;
76+
77+
/**
78+
* Applies ANSI highlighting to raw log text, line by line.
79+
* Timestamps are dimmed, log levels are colored to match `formatEntry` output.
80+
* Useful for job logs and other raw log content that hasn't been parsed into LogEntry objects.
81+
*/
82+
export function highlightLogText(text: string): string {
83+
return text
84+
.split('\n')
85+
.map((line) => {
86+
const match = LOG_LINE_RE.exec(line);
87+
if (!match) return line;
88+
const [, timestamp, level] = match;
89+
const color = LEVEL_COLORS[level] || '';
90+
const rest = line.slice(match[0].length);
91+
return `${DIM}${timestamp}${RESET} ${color}${level}${RESET}${rest}`;
92+
})
93+
.join('\n');
94+
}
95+
7296
/**
7397
* Sets up a path normalizer for IDE click-to-open functionality.
7498
* Priority: 1) explicit cartridgePath, 2) auto-discover cartridges, 3) undefined (no normalization)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
import {expect} from 'chai';
8+
import {afterEach, beforeEach} from 'mocha';
9+
import sinon from 'sinon';
10+
import JobLog from '../../../src/commands/job/log.js';
11+
import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../helpers/test-setup.js';
12+
13+
describe('job log', () => {
14+
const hooks = createIsolatedConfigHooks();
15+
16+
beforeEach(hooks.beforeEach);
17+
18+
afterEach(hooks.afterEach);
19+
20+
async function createCommand(flags: Record<string, unknown>, args: Record<string, unknown>) {
21+
return createTestCommand(JobLog, hooks.getConfig(), flags, args);
22+
}
23+
24+
function stubCommon(command: any) {
25+
const instance = {config: {hostname: 'example.com'}};
26+
sinon.stub(command, 'requireOAuthCredentials').returns(void 0);
27+
sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}}));
28+
sinon.stub(command, 'instance').get(() => instance);
29+
sinon.stub(command, 'log').returns(void 0);
30+
return instance;
31+
}
32+
33+
it('fetches log for a specific execution', async () => {
34+
const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'});
35+
const instance = stubCommon(command);
36+
sinon.stub(command, 'jsonEnabled').returns(false);
37+
38+
const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}};
39+
const getJobExecutionStub = sinon.stub().resolves(execution);
40+
const getJobLogStub = sinon.stub().resolves('log content here');
41+
command.operations = {...command.operations, getJobExecution: getJobExecutionStub, getJobLog: getJobLogStub};
42+
43+
const result = await runSilent(() => command.run());
44+
45+
expect(getJobExecutionStub.calledOnce).to.equal(true);
46+
expect(getJobExecutionStub.getCall(0).args[0]).to.equal(instance);
47+
expect(getJobExecutionStub.getCall(0).args[1]).to.equal('my-job');
48+
expect(getJobExecutionStub.getCall(0).args[2]).to.equal('exec-1');
49+
expect(getJobLogStub.calledOnce).to.equal(true);
50+
expect(result.log).to.equal('log content here');
51+
expect(result.execution).to.equal(execution);
52+
});
53+
54+
it('searches for most recent execution with log', async () => {
55+
const command: any = await createCommand({}, {jobId: 'my-job'});
56+
const instance = stubCommon(command);
57+
sinon.stub(command, 'jsonEnabled').returns(false);
58+
59+
const execWithoutLog = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: false};
60+
const execWithLog = {id: 'exec-2', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}};
61+
const searchStub = sinon.stub().resolves({total: 2, hits: [execWithoutLog, execWithLog]});
62+
const getJobLogStub = sinon.stub().resolves('log from exec-2');
63+
command.operations = {...command.operations, searchJobExecutions: searchStub, getJobLog: getJobLogStub};
64+
65+
const result = await runSilent(() => command.run());
66+
67+
expect(searchStub.calledOnce).to.equal(true);
68+
expect(searchStub.getCall(0).args[0]).to.equal(instance);
69+
expect(searchStub.getCall(0).args[1]).to.deep.include({jobId: 'my-job'});
70+
expect(getJobLogStub.calledOnce).to.equal(true);
71+
expect(getJobLogStub.getCall(0).args[1]).to.equal(execWithLog);
72+
expect(result.log).to.equal('log from exec-2');
73+
});
74+
75+
it('searches for most recent failed execution with --failed', async () => {
76+
const command: any = await createCommand({failed: true}, {jobId: 'my-job'});
77+
stubCommon(command);
78+
sinon.stub(command, 'jsonEnabled').returns(false);
79+
80+
const execution = {id: 'exec-3', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'ERROR'}};
81+
const searchStub = sinon.stub().resolves({total: 1, hits: [execution]});
82+
const getJobLogStub = sinon.stub().resolves('error log');
83+
command.operations = {...command.operations, searchJobExecutions: searchStub, getJobLog: getJobLogStub};
84+
85+
const result = await runSilent(() => command.run());
86+
87+
expect(searchStub.getCall(0).args[1]).to.deep.include({status: ['ERROR']});
88+
expect(result.log).to.equal('error log');
89+
});
90+
91+
it('errors when specific execution has no log file', async () => {
92+
const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'});
93+
stubCommon(command);
94+
95+
const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: false};
96+
sinon.stub().resolves(execution);
97+
command.operations = {...command.operations, getJobExecution: sinon.stub().resolves(execution)};
98+
99+
try {
100+
await command.run();
101+
expect.fail('should have thrown');
102+
} catch (error: any) {
103+
expect(error.message).to.include('No log file exists');
104+
}
105+
});
106+
107+
it('errors when no executions with log found', async () => {
108+
const command: any = await createCommand({}, {jobId: 'my-job'});
109+
stubCommon(command);
110+
111+
const searchStub = sinon.stub().resolves({total: 0, hits: []});
112+
command.operations = {...command.operations, searchJobExecutions: searchStub};
113+
114+
try {
115+
await command.run();
116+
expect.fail('should have thrown');
117+
} catch (error: any) {
118+
expect(error.message).to.include('No execution with a log file found');
119+
}
120+
});
121+
122+
it('returns structured result in json mode', async () => {
123+
const command: any = await createCommand({json: true}, {jobId: 'my-job', executionId: 'exec-1'});
124+
stubCommon(command);
125+
sinon.stub(command, 'jsonEnabled').returns(true);
126+
127+
const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}};
128+
command.operations = {
129+
...command.operations,
130+
getJobExecution: sinon.stub().resolves(execution),
131+
getJobLog: sinon.stub().resolves('json log content'),
132+
};
133+
134+
const result = await command.run();
135+
136+
expect(result).to.have.property('execution');
137+
expect(result).to.have.property('log', 'json log content');
138+
});
139+
});

0 commit comments

Comments
 (0)