@kata.dev/challenge-cli 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/bin/challenge.js +6 -0
- package/package.json +2 -1
- package/src/commands/score.js +33 -0
- package/src/commands/start.js +97 -0
- package/src/commands/submit.js +119 -0
- package/src/lib/polling.js +111 -0
package/README.md
CHANGED
|
@@ -102,3 +102,54 @@ You can also use environment variables:
|
|
|
102
102
|
- `CKS_API_BASE_URL` — API URL
|
|
103
103
|
- `CKS_API_TOKEN` — Auth token
|
|
104
104
|
- `CHALLENGE_ARTIFACT_SIGNING_SECRET` — Signing secret for validation
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Candidate Workflow
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# 1. Login with your candidate token
|
|
112
|
+
npx @kata.dev/challenge-cli login --api https://eval.example.com --token <your-token>
|
|
113
|
+
|
|
114
|
+
# 2. Download starter files for a challenge
|
|
115
|
+
npx @kata.dev/challenge-cli start --slug hello-express
|
|
116
|
+
|
|
117
|
+
# 3. Solve the challenge (edit files in hello-express/)
|
|
118
|
+
|
|
119
|
+
# 4. Submit your solution
|
|
120
|
+
npx @kata.dev/challenge-cli submit --slug hello-express
|
|
121
|
+
|
|
122
|
+
# 5. (Or check score separately)
|
|
123
|
+
npx @kata.dev/challenge-cli score --job <jobId>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### `start`
|
|
127
|
+
|
|
128
|
+
Download starter files for a published challenge:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
challenge start --slug my-challenge
|
|
132
|
+
challenge start --slug my-challenge --dir ./workspace
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### `submit`
|
|
136
|
+
|
|
137
|
+
Zip and submit your solution for evaluation:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
challenge submit --slug my-challenge
|
|
141
|
+
challenge submit --slug my-challenge --dir ./my-challenge
|
|
142
|
+
challenge submit --slug my-challenge --no-poll # skip auto-polling
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
By default, `submit` will automatically poll for results after uploading.
|
|
146
|
+
Use `--no-poll` to skip this and just get the job ID.
|
|
147
|
+
|
|
148
|
+
### `score`
|
|
149
|
+
|
|
150
|
+
Check the score/status of a previously submitted job:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
challenge score --job <jobId>
|
|
154
|
+
```
|
|
155
|
+
|
package/bin/challenge.js
CHANGED
|
@@ -10,6 +10,9 @@ import { registerInitCommand } from '../src/commands/init.js';
|
|
|
10
10
|
import { registerPackCommand } from '../src/commands/pack.js';
|
|
11
11
|
import { registerValidateCommand } from '../src/commands/validate.js';
|
|
12
12
|
import { registerPublishCommand } from '../src/commands/publish.js';
|
|
13
|
+
import { registerStartCommand } from '../src/commands/start.js';
|
|
14
|
+
import { registerSubmitCommand } from '../src/commands/submit.js';
|
|
15
|
+
import { registerScoreCommand } from '../src/commands/score.js';
|
|
13
16
|
|
|
14
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
18
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -26,6 +29,9 @@ registerInitCommand(program);
|
|
|
26
29
|
registerPackCommand(program);
|
|
27
30
|
registerValidateCommand(program);
|
|
28
31
|
registerPublishCommand(program);
|
|
32
|
+
registerStartCommand(program);
|
|
33
|
+
registerSubmitCommand(program);
|
|
34
|
+
registerScoreCommand(program);
|
|
29
35
|
|
|
30
36
|
program.parseAsync(process.argv).catch((err) => {
|
|
31
37
|
console.error(chalk.red('Error:'), err.message);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kata.dev/challenge-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI for authoring, packing, validating, and publishing Eval Engine challenges",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"archiver": "^7.0.1",
|
|
24
25
|
"chalk": "^5.4.1",
|
|
25
26
|
"commander": "^13.1.0",
|
|
26
27
|
"ora": "^8.2.0",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { resolveApiUrl, resolveToken } from '../lib/config.js';
|
|
3
|
+
import { pollJobUntilDone } from '../lib/polling.js';
|
|
4
|
+
|
|
5
|
+
export function registerScoreCommand(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('score')
|
|
8
|
+
.description('Check the score/status of a submitted evaluation job')
|
|
9
|
+
.requiredOption('--job <jobId>', 'Job ID from a previous submission')
|
|
10
|
+
.option('--api <url>', 'API base URL (falls back to config)')
|
|
11
|
+
.option('--token <token>', 'Auth token (falls back to config)')
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
const apiBase = resolveApiUrl(opts.api);
|
|
14
|
+
const token = resolveToken(opts.token);
|
|
15
|
+
if (!token) {
|
|
16
|
+
console.error(
|
|
17
|
+
chalk.red('Error:') + ' No auth token found.\n' +
|
|
18
|
+
'Run ' + chalk.cyan('npx @kata.dev/challenge-cli login') + ' first.'
|
|
19
|
+
);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const jobId = opts.job;
|
|
24
|
+
if (!jobId || typeof jobId !== 'string' || jobId.trim() === '') {
|
|
25
|
+
console.error(chalk.red('Error:') + ' --job <jobId> is required.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(chalk.bold(`\nChecking score for job ${chalk.cyan(jobId)}\n`));
|
|
30
|
+
|
|
31
|
+
await pollJobUntilDone({ apiBase, token, jobId });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { resolveApiUrl, resolveToken } from '../lib/config.js';
|
|
6
|
+
import { requestJson, fetchWithTimeout } from '../lib/http.js';
|
|
7
|
+
import { assertSlug } from '../lib/helpers.js';
|
|
8
|
+
import tar from 'tar';
|
|
9
|
+
|
|
10
|
+
export function registerStartCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('start')
|
|
13
|
+
.description('Download starter files for a challenge')
|
|
14
|
+
.requiredOption('--slug <slug>', 'Challenge slug')
|
|
15
|
+
.option('--dir <path>', 'Directory to extract into (defaults to ./<slug>)')
|
|
16
|
+
.option('--api <url>', 'API base URL (falls back to config)')
|
|
17
|
+
.option('--token <token>', 'Auth token (falls back to config)')
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const slug = opts.slug;
|
|
20
|
+
assertSlug(slug);
|
|
21
|
+
|
|
22
|
+
const apiBase = resolveApiUrl(opts.api);
|
|
23
|
+
const token = resolveToken(opts.token);
|
|
24
|
+
if (!token) {
|
|
25
|
+
console.error(
|
|
26
|
+
chalk.red('Error:') + ' No auth token found.\n' +
|
|
27
|
+
'Run ' + chalk.cyan('npx @kata.dev/challenge-cli login') + ' first.'
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const targetDir = path.resolve(opts.dir || `./${slug}`);
|
|
33
|
+
|
|
34
|
+
console.log(chalk.bold(`\nDownloading starter for ${chalk.cyan(slug)}\n`));
|
|
35
|
+
|
|
36
|
+
// Step 1: Get presigned download URL
|
|
37
|
+
let spinner = ora('Fetching starter download URL…').start();
|
|
38
|
+
let downloadUrl;
|
|
39
|
+
try {
|
|
40
|
+
const result = await requestJson(`${apiBase}/challenges/${slug}/starter`, {
|
|
41
|
+
method: 'GET',
|
|
42
|
+
token,
|
|
43
|
+
});
|
|
44
|
+
downloadUrl = result.downloadUrl;
|
|
45
|
+
spinner.succeed('Starter URL resolved');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
spinner.fail('Failed to get starter URL');
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Step 2: Download the tar.gz
|
|
52
|
+
spinner = ora('Downloading starter archive…').start();
|
|
53
|
+
let buffer;
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetchWithTimeout(downloadUrl);
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error(`Download failed: HTTP ${response.status}`);
|
|
58
|
+
}
|
|
59
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
60
|
+
buffer = Buffer.from(arrayBuffer);
|
|
61
|
+
spinner.succeed(`Downloaded (${(buffer.length / 1024).toFixed(1)} KB)`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
spinner.fail('Download failed');
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Step 3: Extract
|
|
68
|
+
spinner = ora(`Extracting to ${chalk.dim(targetDir)}…`).start();
|
|
69
|
+
try {
|
|
70
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
// Write temp archive and extract
|
|
73
|
+
const tmpPath = path.join(targetDir, '.starter-download.tar.gz');
|
|
74
|
+
fs.writeFileSync(tmpPath, buffer);
|
|
75
|
+
await tar.x({
|
|
76
|
+
file: tmpPath,
|
|
77
|
+
cwd: targetDir,
|
|
78
|
+
strip: 0,
|
|
79
|
+
});
|
|
80
|
+
fs.unlinkSync(tmpPath);
|
|
81
|
+
|
|
82
|
+
spinner.succeed('Extracted starter files');
|
|
83
|
+
} catch (err) {
|
|
84
|
+
spinner.fail('Extraction failed');
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log();
|
|
89
|
+
console.log(chalk.green('✔') + ` Starter files ready in ${chalk.cyan(targetDir)}`);
|
|
90
|
+
console.log();
|
|
91
|
+
console.log(chalk.dim('Next steps:'));
|
|
92
|
+
console.log(` 1. ${chalk.dim('cd')} ${slug}`);
|
|
93
|
+
console.log(` 2. Solve the challenge`);
|
|
94
|
+
console.log(` 3. ${chalk.cyan(`npx @kata.dev/challenge-cli submit --slug ${slug} --dir ./${slug}`)}`);
|
|
95
|
+
console.log();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createWriteStream } from 'node:fs';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import archiver from 'archiver';
|
|
7
|
+
import { resolveApiUrl, resolveToken } from '../lib/config.js';
|
|
8
|
+
import { fetchWithTimeout } from '../lib/http.js';
|
|
9
|
+
import { assertSlug } from '../lib/helpers.js';
|
|
10
|
+
import { pollJobUntilDone } from '../lib/polling.js';
|
|
11
|
+
|
|
12
|
+
function zipDirectory(sourceDir) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
16
|
+
|
|
17
|
+
archive.on('data', (chunk) => chunks.push(chunk));
|
|
18
|
+
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
19
|
+
archive.on('error', (err) => reject(err));
|
|
20
|
+
|
|
21
|
+
archive.directory(sourceDir, false);
|
|
22
|
+
archive.finalize();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerSubmitCommand(program) {
|
|
27
|
+
program
|
|
28
|
+
.command('submit')
|
|
29
|
+
.description('Zip and submit your solution for evaluation')
|
|
30
|
+
.requiredOption('--slug <slug>', 'Challenge slug')
|
|
31
|
+
.option('--dir <path>', 'Solution directory (defaults to ./<slug>)')
|
|
32
|
+
.option('--api <url>', 'API base URL (falls back to config)')
|
|
33
|
+
.option('--token <token>', 'Auth token (falls back to config)')
|
|
34
|
+
.option('--no-poll', 'Skip automatic polling for results')
|
|
35
|
+
.action(async (opts) => {
|
|
36
|
+
const slug = opts.slug;
|
|
37
|
+
assertSlug(slug);
|
|
38
|
+
|
|
39
|
+
const apiBase = resolveApiUrl(opts.api);
|
|
40
|
+
const token = resolveToken(opts.token);
|
|
41
|
+
if (!token) {
|
|
42
|
+
console.error(
|
|
43
|
+
chalk.red('Error:') + ' No auth token found.\n' +
|
|
44
|
+
'Run ' + chalk.cyan('npx @kata.dev/challenge-cli login') + ' first.'
|
|
45
|
+
);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const solutionDir = path.resolve(opts.dir || `./${slug}`);
|
|
50
|
+
|
|
51
|
+
if (!fs.existsSync(solutionDir)) {
|
|
52
|
+
console.error(chalk.red('Error:') + ` Directory not found: ${solutionDir}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(chalk.bold(`\nSubmitting solution for ${chalk.cyan(slug)}\n`));
|
|
57
|
+
|
|
58
|
+
// Step 1: Zip the solution
|
|
59
|
+
let spinner = ora('Zipping solution…').start();
|
|
60
|
+
let zipBuffer;
|
|
61
|
+
try {
|
|
62
|
+
zipBuffer = await zipDirectory(solutionDir);
|
|
63
|
+
spinner.succeed(`Zipped (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
spinner.fail('Failed to zip solution');
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Step 2: Upload via POST /evaluate
|
|
70
|
+
spinner = ora('Uploading submission…').start();
|
|
71
|
+
let jobId;
|
|
72
|
+
try {
|
|
73
|
+
const formData = new FormData();
|
|
74
|
+
formData.append('challengeId', slug);
|
|
75
|
+
formData.append(
|
|
76
|
+
'submission',
|
|
77
|
+
new Blob([zipBuffer], { type: 'application/zip' }),
|
|
78
|
+
`${slug}-submission.zip`
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const response = await fetchWithTimeout(`${apiBase}/evaluate`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
authorization: `Bearer ${token}`,
|
|
85
|
+
},
|
|
86
|
+
body: formData,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const errorBody = await response.text();
|
|
91
|
+
let errorMsg;
|
|
92
|
+
try {
|
|
93
|
+
errorMsg = JSON.parse(errorBody).error || errorBody;
|
|
94
|
+
} catch {
|
|
95
|
+
errorMsg = errorBody;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Submission failed (${response.status}): ${errorMsg}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const result = await response.json();
|
|
101
|
+
jobId = result.jobId;
|
|
102
|
+
spinner.succeed(`Submitted — Job ID: ${chalk.cyan(jobId)}`);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
spinner.fail('Submission failed');
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log();
|
|
109
|
+
|
|
110
|
+
// Step 3: Auto-poll if enabled
|
|
111
|
+
if (opts.poll !== false) {
|
|
112
|
+
await pollJobUntilDone({ apiBase, token, jobId });
|
|
113
|
+
} else {
|
|
114
|
+
console.log(chalk.dim('Check your score with:'));
|
|
115
|
+
console.log(` ${chalk.cyan(`npx @kata.dev/challenge-cli score --job ${jobId}`)}`);
|
|
116
|
+
console.log();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { requestJson } from './http.js';
|
|
4
|
+
|
|
5
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'error', 'timed_out']);
|
|
6
|
+
const POLL_INTERVAL_MS = 3000;
|
|
7
|
+
const MAX_POLLS = 200;
|
|
8
|
+
|
|
9
|
+
export async function pollJobUntilDone({ apiBase, token, jobId }) {
|
|
10
|
+
const spinner = ora('Waiting for evaluation results…').start();
|
|
11
|
+
|
|
12
|
+
let polls = 0;
|
|
13
|
+
let job = null;
|
|
14
|
+
|
|
15
|
+
while (polls < MAX_POLLS) {
|
|
16
|
+
try {
|
|
17
|
+
job = await requestJson(`${apiBase}/jobs/${jobId}`, {
|
|
18
|
+
method: 'GET',
|
|
19
|
+
token,
|
|
20
|
+
});
|
|
21
|
+
} catch (err) {
|
|
22
|
+
spinner.fail('Failed to poll job status');
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (TERMINAL_STATUSES.has(job.status)) {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
spinner.text = `Status: ${chalk.yellow(job.status)} — polling… (${polls + 1})`;
|
|
31
|
+
polls += 1;
|
|
32
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
spinner.stop();
|
|
36
|
+
|
|
37
|
+
if (!job || !TERMINAL_STATUSES.has(job.status)) {
|
|
38
|
+
console.log(chalk.yellow('⚠') + ' Timed out waiting for results.');
|
|
39
|
+
console.log(chalk.dim('Check again with:'));
|
|
40
|
+
console.log(` ${chalk.cyan(`npx @kata.dev/challenge-cli score --job ${jobId}`)}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Display result
|
|
45
|
+
console.log(formatResult(job, jobId));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatResult(job, jobId) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
|
|
51
|
+
const statusIcon = job.status === 'completed' ? chalk.green('✔') : chalk.red('✘');
|
|
52
|
+
const statusLabel = job.status === 'completed'
|
|
53
|
+
? chalk.green.bold('PASSED')
|
|
54
|
+
: chalk.red.bold(job.status.toUpperCase());
|
|
55
|
+
|
|
56
|
+
lines.push(`${statusIcon} Result: ${statusLabel}`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
|
|
59
|
+
if (job.resultJson) {
|
|
60
|
+
const result = typeof job.resultJson === 'string'
|
|
61
|
+
? JSON.parse(job.resultJson)
|
|
62
|
+
: job.resultJson;
|
|
63
|
+
|
|
64
|
+
if (result.summary) {
|
|
65
|
+
lines.push(chalk.bold('Summary:'));
|
|
66
|
+
lines.push(` ${result.summary}`);
|
|
67
|
+
lines.push('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (result.passed !== undefined && result.total !== undefined) {
|
|
71
|
+
const scoreColor = result.passed === result.total ? chalk.green : chalk.yellow;
|
|
72
|
+
lines.push(
|
|
73
|
+
chalk.bold('Score: ') +
|
|
74
|
+
scoreColor(`${result.passed}/${result.total} tests passed`)
|
|
75
|
+
);
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (result.score !== undefined) {
|
|
80
|
+
lines.push(chalk.bold('Score: ') + chalk.cyan(result.score));
|
|
81
|
+
lines.push('');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(result.testResults)) {
|
|
85
|
+
lines.push(chalk.bold('Test Results:'));
|
|
86
|
+
for (const test of result.testResults) {
|
|
87
|
+
const icon = test.status === 'passed' ? chalk.green(' ✔') : chalk.red(' ✘');
|
|
88
|
+
const name = test.name || test.title || 'unnamed';
|
|
89
|
+
lines.push(`${icon} ${name}`);
|
|
90
|
+
if (test.status !== 'passed' && test.message) {
|
|
91
|
+
lines.push(chalk.dim(` ${test.message}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
lines.push('');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (job.errorCode) {
|
|
99
|
+
lines.push(chalk.red(`Error: ${job.errorCode}`));
|
|
100
|
+
if (job.errorMessage) {
|
|
101
|
+
lines.push(chalk.dim(` ${job.errorMessage}`));
|
|
102
|
+
}
|
|
103
|
+
lines.push('');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
lines.push(chalk.dim(`Job ID: ${jobId}`));
|
|
107
|
+
lines.push(chalk.dim(`View logs: npx @kata.dev/challenge-cli score --job ${jobId}`));
|
|
108
|
+
lines.push('');
|
|
109
|
+
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
}
|