cyberhub-pracenv 1.0.0 → 1.1.1
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/CHANGELOG.md +60 -0
- package/package.json +3 -2
- package/src/commands.js +173 -18
- package/src/store.js +64 -12
- package/src/ui.js +41 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **cyberhub-pracenv** (`chpe`) are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.1.1] - 2026-06-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`changelog` command** — view this changelog from inside the platform so you
|
|
12
|
+
can see what has changed across versions without leaving the shell.
|
|
13
|
+
- **`requirements` command (alias `rq`)** — show the rank ladder and how many
|
|
14
|
+
more points are needed to reach the next rank. The rank thresholds now live in
|
|
15
|
+
one place (`ui.RANKS`) shared by `rankFor` and this view.
|
|
16
|
+
- **`scoreboard` command** — an operative leaderboard listing every user with
|
|
17
|
+
their rank, points, and solved count. A secret `sb` shortcut is also available.
|
|
18
|
+
- A secret `ch` shortcut for the `challenges`/`ls` listing.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `help` now lists the `changelog`, `requirements`, and `scoreboard` commands.
|
|
22
|
+
The `ch` and `sb` shortcuts are intentionally left undocumented.
|
|
23
|
+
|
|
24
|
+
## [1.1.0] - 2026-06-25
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **Uploaded challenges no longer disappear after a restart.** Challenges created
|
|
28
|
+
with `upload` (and files attached with `addfile`) are now mirrored into the
|
|
29
|
+
bundled `data/challenges/` seed, and launch-time seeding now *tops up* any
|
|
30
|
+
challenge missing from the per-user data dir instead of only seeding when it is
|
|
31
|
+
empty. A reset or wiped `~/.cyberhub-pracenv/` is restored from the seed, so
|
|
32
|
+
uploads survive restarts and reinstalls and are version-control friendly. The
|
|
33
|
+
seed mirror is best-effort, so a read-only install location can never break an
|
|
34
|
+
upload.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- **Reworked the `stats` command** into a full breakdown: an overall progress bar,
|
|
38
|
+
a progress bar per difficulty, a progress bar per category, and a detailed table
|
|
39
|
+
of solved challenges (id, title, category, difficulty, points) sorted by
|
|
40
|
+
difficulty. Solved entries that no longer exist are flagged.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- Progress-bar renderer (`ui.progressBar`) used throughout the new `stats` view.
|
|
44
|
+
- `CHANGELOG.md` (this file).
|
|
45
|
+
- `CHPE_SEED_DIR` environment override so tests redirect the seed/mirror location
|
|
46
|
+
away from the real project data; added regression tests covering upload
|
|
47
|
+
persistence across a data-dir reset.
|
|
48
|
+
|
|
49
|
+
## [1.0.0] - 2026-06-25
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- Initial release: a fully terminal-based, beginner-friendly CTF practice platform
|
|
53
|
+
in the ICOA terminal style, installed globally and run as an interactive shell.
|
|
54
|
+
- Commands: `challenges`/`ls`, `open`, `files`, `cat`, `get`, `submit`, `stats`,
|
|
55
|
+
`admin`, `clear`, `help`, `exit`, plus admin commands `upload`, `addfile`,
|
|
56
|
+
`rmchallenge`, `passwd`, `logout`.
|
|
57
|
+
- Single local profile with points, ranks, and per-category progress; admin mode
|
|
58
|
+
unlocked by a password (default `admin`).
|
|
59
|
+
- YAML-based challenges with attached files, seeded from a bundled sample set into
|
|
60
|
+
`~/.cyberhub-pracenv/` on first run.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyberhub-pracenv",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "A beginner-friendly, fully terminal-based cybersecurity (CTF) practice platform in the ICOA terminal style.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cybersecurity",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"bin/",
|
|
25
25
|
"src/",
|
|
26
26
|
"data/",
|
|
27
|
-
"README.md"
|
|
27
|
+
"README.md",
|
|
28
|
+
"CHANGELOG.md"
|
|
28
29
|
],
|
|
29
30
|
"scripts": {
|
|
30
31
|
"start": "node bin/cyberhub.js",
|
package/src/commands.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// The command table at the bottom drives both dispatch and the `help` listing.
|
|
8
8
|
|
|
9
|
+
const fs = require('fs');
|
|
9
10
|
const path = require('path');
|
|
10
11
|
const store = require('./store');
|
|
11
12
|
const auth = require('./auth');
|
|
@@ -38,6 +39,9 @@ function cmdHelp(ctx) {
|
|
|
38
39
|
['get <id> <file>', 'Copy an attached file into your launch directory'],
|
|
39
40
|
['submit <id> <flag>', 'Submit a flag to solve a challenge'],
|
|
40
41
|
['stats (whoami)', 'Show your full user breakdown'],
|
|
42
|
+
['scoreboard', 'Show the operative leaderboard'],
|
|
43
|
+
['requirements (rq)', 'Show the points needed to rank up'],
|
|
44
|
+
['changelog', 'Show what has changed across versions'],
|
|
41
45
|
['admin', 'Unlock admin mode (requires the admin password)'],
|
|
42
46
|
['clear', 'Clear the screen'],
|
|
43
47
|
['help', 'Show this help'],
|
|
@@ -198,21 +202,50 @@ function cmdSubmit(ctx, args) {
|
|
|
198
202
|
}
|
|
199
203
|
}
|
|
200
204
|
|
|
205
|
+
// Known difficulties listed in ramp-up order; anything else sorts after these
|
|
206
|
+
// alphabetically so custom difficulties still appear.
|
|
207
|
+
const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'insane', 'expert', 'elite'];
|
|
208
|
+
|
|
209
|
+
function difficultyRank(diff) {
|
|
210
|
+
const i = DIFFICULTY_ORDER.indexOf(String(diff).toLowerCase());
|
|
211
|
+
return i === -1 ? DIFFICULTY_ORDER.length : i;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Print a list of "label progress-bar" lines with the labels padded to a
|
|
215
|
+
// common width so the bars line up.
|
|
216
|
+
function printBarGroup(ctx, entries) {
|
|
217
|
+
if (entries.length === 0) return;
|
|
218
|
+
const labelWidth = Math.max(...entries.map((e) => e.label.length));
|
|
219
|
+
for (const e of entries) {
|
|
220
|
+
const label = e.label + ' '.repeat(labelWidth - e.label.length);
|
|
221
|
+
print(ctx, ` ${c.accent(label)} ${ui.progressBar(e.solved, e.total)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
201
225
|
function cmdStats(ctx) {
|
|
202
226
|
const profile = store.getProfile();
|
|
203
227
|
const challenges = store.listChallenges();
|
|
204
228
|
const total = challenges.length;
|
|
229
|
+
const solvedSet = new Set(profile.solved);
|
|
205
230
|
const solvedCount = profile.solved.length;
|
|
206
231
|
|
|
207
|
-
//
|
|
232
|
+
// Bucket challenges by difficulty and category for the progress breakdowns.
|
|
233
|
+
const byDiff = {};
|
|
208
234
|
const byCat = {};
|
|
209
235
|
for (const ch of challenges) {
|
|
236
|
+
const diff = (ch.difficulty || 'unknown').toLowerCase();
|
|
210
237
|
const cat = ch.category || 'misc';
|
|
238
|
+
byDiff[diff] = byDiff[diff] || { total: 0, solved: 0 };
|
|
211
239
|
byCat[cat] = byCat[cat] || { total: 0, solved: 0 };
|
|
240
|
+
byDiff[diff].total += 1;
|
|
212
241
|
byCat[cat].total += 1;
|
|
213
|
-
if (
|
|
242
|
+
if (solvedSet.has(ch.id)) {
|
|
243
|
+
byDiff[diff].solved += 1;
|
|
244
|
+
byCat[cat].solved += 1;
|
|
245
|
+
}
|
|
214
246
|
}
|
|
215
247
|
|
|
248
|
+
// --- Summary -------------------------------------------------------------
|
|
216
249
|
print(ctx, c.title('User breakdown'));
|
|
217
250
|
print(ctx, ` ${c.bold('Operative')} : ${profile.name}`);
|
|
218
251
|
print(ctx, ` ${c.bold('Rank')} : ${c.accent(ui.rankFor(profile.points))}`);
|
|
@@ -222,26 +255,65 @@ function cmdStats(ctx) {
|
|
|
222
255
|
print(ctx, ` ${c.bold('Member since')} : ${new Date(profile.createdAt).toLocaleString()}`);
|
|
223
256
|
print(ctx, ` ${c.bold('Admin mode')} : ${auth.isAdmin() ? c.ok('ON') : c.dim('off')}`);
|
|
224
257
|
|
|
258
|
+
// --- Overall progress bar ------------------------------------------------
|
|
259
|
+
print(ctx, '', c.bold(' Overall progress:'));
|
|
260
|
+
print(ctx, ` ${ui.progressBar(solvedCount, total)}`);
|
|
261
|
+
|
|
262
|
+
// --- Progress bar per difficulty ----------------------------------------
|
|
263
|
+
print(ctx, '', c.bold(' Progress by difficulty:'));
|
|
264
|
+
const diffEntries = Object.keys(byDiff)
|
|
265
|
+
.sort((a, b) => difficultyRank(a) - difficultyRank(b) || a.localeCompare(b))
|
|
266
|
+
.map((diff) => ({ label: diff, solved: byDiff[diff].solved, total: byDiff[diff].total }));
|
|
267
|
+
if (diffEntries.length > 0) {
|
|
268
|
+
printBarGroup(ctx, diffEntries);
|
|
269
|
+
} else {
|
|
270
|
+
print(ctx, c.dim(' No challenges available yet.'));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Progress bar per category ------------------------------------------
|
|
225
274
|
print(ctx, '', c.bold(' Progress by category:'));
|
|
226
|
-
const
|
|
275
|
+
const catEntries = Object.keys(byCat)
|
|
227
276
|
.sort()
|
|
228
|
-
.map((cat) => ({
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
print(ctx, ui.table(catRows, [
|
|
234
|
-
{ key: 'category', label: 'Category', color: c.accent },
|
|
235
|
-
{ key: 'progress', label: 'Solved' },
|
|
236
|
-
]));
|
|
277
|
+
.map((cat) => ({ label: cat, solved: byCat[cat].solved, total: byCat[cat].total }));
|
|
278
|
+
if (catEntries.length > 0) {
|
|
279
|
+
printBarGroup(ctx, catEntries);
|
|
280
|
+
} else {
|
|
281
|
+
print(ctx, c.dim(' No challenges available yet.'));
|
|
237
282
|
}
|
|
238
283
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
284
|
+
// --- Detailed breakdown of solved challenges ----------------------------
|
|
285
|
+
print(ctx, '', c.bold(' Solved challenges:'));
|
|
286
|
+
if (solvedCount === 0) {
|
|
287
|
+
print(ctx, c.dim(' Nothing solved yet — run `challenges` to get started.'));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const solvedRows = profile.solved
|
|
291
|
+
.map((id) => store.getChallenge(id))
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.sort(
|
|
294
|
+
(a, b) =>
|
|
295
|
+
difficultyRank(a.difficulty) - difficultyRank(b.difficulty) ||
|
|
296
|
+
String(a.id).localeCompare(String(b.id))
|
|
297
|
+
)
|
|
298
|
+
.map((ch) => ({
|
|
299
|
+
id: ch.id,
|
|
300
|
+
title: ch.title,
|
|
301
|
+
category: ch.category,
|
|
302
|
+
difficulty: ch.difficulty,
|
|
303
|
+
points: ch.points,
|
|
304
|
+
}));
|
|
305
|
+
print(ctx, ui.table(solvedRows, [
|
|
306
|
+
{ key: 'id', label: 'ID', color: c.accent },
|
|
307
|
+
{ key: 'title', label: 'Title' },
|
|
308
|
+
{ key: 'category', label: 'Category' },
|
|
309
|
+
{ key: 'difficulty', label: 'Difficulty' },
|
|
310
|
+
{ key: 'points', label: 'Pts', color: c.ok },
|
|
311
|
+
]));
|
|
312
|
+
|
|
313
|
+
// Note any solved ids whose challenge file is no longer present.
|
|
314
|
+
const missing = profile.solved.filter((id) => !store.getChallenge(id));
|
|
315
|
+
if (missing.length > 0) {
|
|
316
|
+
print(ctx, c.dim(` (${missing.length} solved challenge(s) no longer exist: ${missing.join(', ')})`));
|
|
245
317
|
}
|
|
246
318
|
}
|
|
247
319
|
|
|
@@ -377,6 +449,83 @@ function cmdClear(ctx) {
|
|
|
377
449
|
print(ctx, c.title('CyberHub Practice Environment') + c.dim(' — type `help` for commands'));
|
|
378
450
|
}
|
|
379
451
|
|
|
452
|
+
// Show the bundled CHANGELOG.md so operatives can see what has changed without
|
|
453
|
+
// leaving the platform. The markdown is lightly styled for the terminal.
|
|
454
|
+
function cmdChangelog(ctx) {
|
|
455
|
+
const file = path.join(__dirname, '..', 'CHANGELOG.md');
|
|
456
|
+
let text;
|
|
457
|
+
try {
|
|
458
|
+
text = fs.readFileSync(file, 'utf8');
|
|
459
|
+
} catch (_err) {
|
|
460
|
+
print(ctx, c.err('Changelog is not available.'));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
print(ctx, c.title('Changelog'));
|
|
464
|
+
for (const line of text.replace(/\s+$/, '').split('\n')) {
|
|
465
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
466
|
+
print(ctx, c.accent(line.replace(/^#{1,6}\s+/, '')));
|
|
467
|
+
} else if (/^\s*[-*]\s/.test(line)) {
|
|
468
|
+
print(ctx, c.dim(' • ') + line.replace(/^\s*[-*]\s+/, ''));
|
|
469
|
+
} else {
|
|
470
|
+
print(ctx, line);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Show the rank ladder and how far the operative is from the next rank.
|
|
476
|
+
function cmdRequirements(ctx) {
|
|
477
|
+
const profile = store.getProfile();
|
|
478
|
+
const points = profile.points || 0;
|
|
479
|
+
const current = ui.rankFor(points);
|
|
480
|
+
|
|
481
|
+
print(ctx, c.title('Rank requirements'));
|
|
482
|
+
const rows = ui.RANKS.map((r) => ({
|
|
483
|
+
cur: r.name === current ? '➤' : ' ',
|
|
484
|
+
rank: r.name,
|
|
485
|
+
points: r.min,
|
|
486
|
+
}));
|
|
487
|
+
print(ctx, ui.table(rows, [
|
|
488
|
+
{ key: 'cur', label: ' ', color: c.ok },
|
|
489
|
+
{ key: 'rank', label: 'Rank', color: c.accent },
|
|
490
|
+
{ key: 'points', label: 'Min points' },
|
|
491
|
+
]));
|
|
492
|
+
|
|
493
|
+
print(ctx, '');
|
|
494
|
+
print(ctx, ` ${c.bold('You')} : ${c.accent(current)} with ${c.ok(points)} pts`);
|
|
495
|
+
const next = ui.nextRank(points);
|
|
496
|
+
if (next) {
|
|
497
|
+
print(ctx, c.dim(` ${next.min - points} more point(s) to reach ${next.name}.`));
|
|
498
|
+
} else {
|
|
499
|
+
print(ctx, c.dim(' You have reached the top rank. Outstanding.'));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Show a leaderboard of operatives. The platform uses a single local profile,
|
|
504
|
+
// so the scoreboard ranks that operative — but it is built to render any number
|
|
505
|
+
// of profiles so it keeps working if more are ever added.
|
|
506
|
+
function cmdScoreboard(ctx) {
|
|
507
|
+
const total = store.listChallenges().length;
|
|
508
|
+
const users = [store.getProfile()];
|
|
509
|
+
const rows = users
|
|
510
|
+
.slice()
|
|
511
|
+
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
|
512
|
+
.map((u, i) => ({
|
|
513
|
+
pos: i + 1,
|
|
514
|
+
name: u.name,
|
|
515
|
+
rank: ui.rankFor(u.points || 0),
|
|
516
|
+
points: u.points || 0,
|
|
517
|
+
solved: `${(u.solved || []).length}/${total}`,
|
|
518
|
+
}));
|
|
519
|
+
print(ctx, c.title('Scoreboard'));
|
|
520
|
+
print(ctx, ui.table(rows, [
|
|
521
|
+
{ key: 'pos', label: '#', color: c.dim },
|
|
522
|
+
{ key: 'name', label: 'Operative', color: c.accent },
|
|
523
|
+
{ key: 'rank', label: 'Rank' },
|
|
524
|
+
{ key: 'points', label: 'Pts', color: c.ok },
|
|
525
|
+
{ key: 'solved', label: 'Solved' },
|
|
526
|
+
]));
|
|
527
|
+
}
|
|
528
|
+
|
|
380
529
|
function cmdExit(ctx) {
|
|
381
530
|
print(ctx, c.dim('Stay sharp. Goodbye.'));
|
|
382
531
|
ctx.requestExit();
|
|
@@ -391,6 +540,7 @@ const COMMANDS = {
|
|
|
391
540
|
'?': cmdHelp,
|
|
392
541
|
challenges: cmdChallenges,
|
|
393
542
|
ls: cmdChallenges,
|
|
543
|
+
ch: cmdChallenges, // secret shortcut (intentionally undocumented)
|
|
394
544
|
open: cmdOpen,
|
|
395
545
|
show: cmdOpen,
|
|
396
546
|
files: cmdFiles,
|
|
@@ -401,6 +551,11 @@ const COMMANDS = {
|
|
|
401
551
|
stats: cmdStats,
|
|
402
552
|
whoami: cmdStats,
|
|
403
553
|
profile: cmdStats,
|
|
554
|
+
scoreboard: cmdScoreboard,
|
|
555
|
+
sb: cmdScoreboard, // secret shortcut (intentionally undocumented)
|
|
556
|
+
requirements: cmdRequirements,
|
|
557
|
+
rq: cmdRequirements,
|
|
558
|
+
changelog: cmdChangelog,
|
|
404
559
|
admin: cmdAdmin,
|
|
405
560
|
login: cmdAdmin,
|
|
406
561
|
logout: cmdLogout,
|
package/src/store.js
CHANGED
|
@@ -26,8 +26,15 @@ const FILES_DIR = path.join(DATA_DIR, 'files');
|
|
|
26
26
|
const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
|
|
27
27
|
const PROFILE_PATH = path.join(DATA_DIR, 'profile.json');
|
|
28
28
|
|
|
29
|
-
// Bundled seed data shipped with the package.
|
|
30
|
-
|
|
29
|
+
// Bundled seed data shipped with the package. Uploaded challenges are mirrored
|
|
30
|
+
// back into here so they (a) live alongside the project and are version-control
|
|
31
|
+
// friendly, and (b) survive a wipe/reset of the per-user data dir — the seeding
|
|
32
|
+
// step below tops the user dir back up from this directory on every launch.
|
|
33
|
+
// CHPE_SEED_DIR lets tests redirect the seed/mirror location so they never write
|
|
34
|
+
// into the real bundled project data.
|
|
35
|
+
const SEED_DIR = process.env.CHPE_SEED_DIR
|
|
36
|
+
? path.resolve(process.env.CHPE_SEED_DIR)
|
|
37
|
+
: path.join(__dirname, '..', 'data', 'challenges');
|
|
31
38
|
const SEED_FILES_DIR = path.join(SEED_DIR, 'files');
|
|
32
39
|
|
|
33
40
|
const DEFAULT_ADMIN_PASSWORD = 'admin';
|
|
@@ -90,17 +97,29 @@ function init() {
|
|
|
90
97
|
});
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
// Top the user dir up from the bundled seed on every launch: copy across any
|
|
101
|
+
// challenge (and its files) that the seed has but the user dir is missing.
|
|
102
|
+
// This both provides the starter set on first run and restores challenges —
|
|
103
|
+
// including ones uploaded in a previous session and mirrored into the seed —
|
|
104
|
+
// if the per-user data dir was emptied or reset. Existing user challenges are
|
|
105
|
+
// never overwritten.
|
|
106
|
+
if (fs.existsSync(SEED_DIR)) {
|
|
107
|
+
for (const entry of fs.readdirSync(SEED_DIR)) {
|
|
108
|
+
if (!entry.endsWith('.yml') && !entry.endsWith('.yaml')) continue;
|
|
109
|
+
const dest = path.join(CHALLENGES_DIR, entry);
|
|
110
|
+
if (!fs.existsSync(dest)) {
|
|
111
|
+
fs.copyFileSync(path.join(SEED_DIR, entry), dest);
|
|
100
112
|
}
|
|
101
113
|
}
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
}
|
|
115
|
+
if (fs.existsSync(SEED_FILES_DIR)) {
|
|
116
|
+
for (const id of fs.readdirSync(SEED_FILES_DIR)) {
|
|
117
|
+
const srcDir = path.join(SEED_FILES_DIR, id);
|
|
118
|
+
if (!fs.statSync(srcDir).isDirectory()) continue;
|
|
119
|
+
const destDir = path.join(FILES_DIR, id);
|
|
120
|
+
if (!fs.existsSync(destDir)) {
|
|
121
|
+
copyRecursive(srcDir, destDir);
|
|
122
|
+
}
|
|
104
123
|
}
|
|
105
124
|
}
|
|
106
125
|
}
|
|
@@ -229,6 +248,16 @@ function createChallenge(challenge) {
|
|
|
229
248
|
return id;
|
|
230
249
|
}
|
|
231
250
|
|
|
251
|
+
// Mirror a write into the bundled seed directory. Best-effort: a read-only
|
|
252
|
+
// install location (e.g. a global npm prefix) must never break an upload.
|
|
253
|
+
function mirrorToSeed(fn) {
|
|
254
|
+
try {
|
|
255
|
+
fn();
|
|
256
|
+
} catch (_err) {
|
|
257
|
+
/* seed mirror is optional; ignore failures */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
232
261
|
function saveChallenge(challenge) {
|
|
233
262
|
ensureDir(CHALLENGES_DIR);
|
|
234
263
|
const id = String(challenge.id).trim();
|
|
@@ -242,7 +271,14 @@ function saveChallenge(challenge) {
|
|
|
242
271
|
flag: challenge.flag || '',
|
|
243
272
|
files: Array.isArray(challenge.files) ? challenge.files : [],
|
|
244
273
|
};
|
|
245
|
-
|
|
274
|
+
const text = yaml.dump(doc, { lineWidth: 100 });
|
|
275
|
+
fs.writeFileSync(challengePath(id), text, 'utf8');
|
|
276
|
+
// Also persist into the bundled seed so the challenge lives with the project
|
|
277
|
+
// and is restored if the per-user data dir is ever reset.
|
|
278
|
+
mirrorToSeed(() => {
|
|
279
|
+
ensureDir(SEED_DIR);
|
|
280
|
+
fs.writeFileSync(path.join(SEED_DIR, `${id}.yml`), text, 'utf8');
|
|
281
|
+
});
|
|
246
282
|
}
|
|
247
283
|
|
|
248
284
|
function deleteChallenge(id) {
|
|
@@ -259,6 +295,15 @@ function deleteChallenge(id) {
|
|
|
259
295
|
if (fs.existsSync(dir)) {
|
|
260
296
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
261
297
|
}
|
|
298
|
+
// Remove the seed mirror too, otherwise the challenge would be restored on the
|
|
299
|
+
// next launch by the top-up seeding step.
|
|
300
|
+
mirrorToSeed(() => {
|
|
301
|
+
for (const f of [path.join(SEED_DIR, `${id}.yml`), path.join(SEED_DIR, `${id}.yaml`)]) {
|
|
302
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
303
|
+
}
|
|
304
|
+
const seedFileDir = path.join(SEED_FILES_DIR, id);
|
|
305
|
+
if (fs.existsSync(seedFileDir)) fs.rmSync(seedFileDir, { recursive: true, force: true });
|
|
306
|
+
});
|
|
262
307
|
return removed;
|
|
263
308
|
}
|
|
264
309
|
|
|
@@ -296,6 +341,13 @@ function attachFile(id, sourcePath) {
|
|
|
296
341
|
ensureDir(challengeFileDir(id));
|
|
297
342
|
fs.copyFileSync(sourcePath, challengeFilePath(id, name));
|
|
298
343
|
|
|
344
|
+
// Mirror the attached file into the bundled seed alongside the challenge yml.
|
|
345
|
+
mirrorToSeed(() => {
|
|
346
|
+
const seedFileDir = path.join(SEED_FILES_DIR, id);
|
|
347
|
+
ensureDir(seedFileDir);
|
|
348
|
+
fs.copyFileSync(sourcePath, path.join(seedFileDir, name));
|
|
349
|
+
});
|
|
350
|
+
|
|
299
351
|
if (!challenge.files.includes(name)) {
|
|
300
352
|
challenge.files.push(name);
|
|
301
353
|
saveChallenge(challenge);
|
package/src/ui.js
CHANGED
|
@@ -80,12 +80,45 @@ function table(rows, columns) {
|
|
|
80
80
|
return [header, sep, ...body].join('\n');
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Render a text progress bar: filled blocks for the done portion, dim blocks for
|
|
84
|
+
// the rest, followed by a `done/total (pct%)` readout. A fully-complete bar is
|
|
85
|
+
// highlighted green; partial progress is cyan.
|
|
86
|
+
function progressBar(done, total, width = 24) {
|
|
87
|
+
const safeTotal = Math.max(0, Number(total) || 0);
|
|
88
|
+
const safeDone = Math.max(0, Math.min(Number(done) || 0, safeTotal));
|
|
89
|
+
const ratio = safeTotal > 0 ? safeDone / safeTotal : 0;
|
|
90
|
+
const filled = Math.round(ratio * width);
|
|
91
|
+
const pct = Math.round(ratio * 100);
|
|
92
|
+
const complete = safeTotal > 0 && safeDone === safeTotal;
|
|
93
|
+
const fillColor = complete ? c.ok : c.accent;
|
|
94
|
+
const bar = fillColor('█'.repeat(filled)) + c.dim('░'.repeat(width - filled));
|
|
95
|
+
return `${bar} ${safeDone}/${safeTotal} ${c.dim(`(${pct}%)`)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The rank ladder: the minimum points required to hold each rank, ascending.
|
|
99
|
+
// rankFor() and the in-platform `requirements` command both read from this so
|
|
100
|
+
// the thresholds live in exactly one place.
|
|
101
|
+
const RANKS = [
|
|
102
|
+
{ name: 'Recruit', min: 0 },
|
|
103
|
+
{ name: 'Script Kiddie', min: 100 },
|
|
104
|
+
{ name: 'Pentester', min: 300 },
|
|
105
|
+
{ name: 'Operator', min: 600 },
|
|
106
|
+
{ name: 'Elite', min: 1000 },
|
|
107
|
+
];
|
|
108
|
+
|
|
83
109
|
function rankFor(points) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
110
|
+
const pts = Number(points) || 0;
|
|
111
|
+
let rank = RANKS[0].name;
|
|
112
|
+
for (const r of RANKS) {
|
|
113
|
+
if (pts >= r.min) rank = r.name;
|
|
114
|
+
}
|
|
115
|
+
return rank;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The next rank above the given points, or null if already at the top.
|
|
119
|
+
function nextRank(points) {
|
|
120
|
+
const pts = Number(points) || 0;
|
|
121
|
+
return RANKS.find((r) => r.min > pts) || null;
|
|
89
122
|
}
|
|
90
123
|
|
|
91
124
|
module.exports = {
|
|
@@ -93,5 +126,8 @@ module.exports = {
|
|
|
93
126
|
BANNER,
|
|
94
127
|
welcomeScreen,
|
|
95
128
|
table,
|
|
129
|
+
progressBar,
|
|
130
|
+
RANKS,
|
|
96
131
|
rankFor,
|
|
132
|
+
nextRank,
|
|
97
133
|
};
|