@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 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.1.2",
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
+ }