Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/job-log-command.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 56 additions & 3 deletions docs/cli/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/b2c-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions packages/b2c-cli/src/commands/job/log.ts
Original file line number Diff line number Diff line change
@@ -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<typeof JobLog> {
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<JobLogResult> {
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};
}
}
24 changes: 24 additions & 0 deletions packages/b2c-cli/src/utils/logs/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading