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 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.0.0",
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
- // Per-category progress.
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 (profile.solved.includes(ch.id)) byCat[cat].solved += 1;
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 catRows = Object.keys(byCat)
275
+ const catEntries = Object.keys(byCat)
227
276
  .sort()
228
- .map((cat) => ({
229
- category: cat,
230
- progress: `${byCat[cat].solved} / ${byCat[cat].total}`,
231
- }));
232
- if (catRows.length > 0) {
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
- if (solvedCount > 0) {
240
- print(ctx, '', c.bold(' Solved challenges:'));
241
- for (const id of profile.solved) {
242
- const ch = store.getChallenge(id);
243
- print(ctx, ' ' + c.ok('✔ ') + c.accent(id) + (ch ? c.dim(' ' + ch.title) : ''));
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
- const SEED_DIR = path.join(__dirname, '..', 'data', 'challenges');
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
- // Seed sample challenges + their files only when the user has none yet.
94
- if (listChallengeIds().length === 0) {
95
- if (fs.existsSync(SEED_DIR)) {
96
- for (const entry of fs.readdirSync(SEED_DIR)) {
97
- if (entry.endsWith('.yml') || entry.endsWith('.yaml')) {
98
- fs.copyFileSync(path.join(SEED_DIR, entry), path.join(CHALLENGES_DIR, entry));
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
- if (fs.existsSync(SEED_FILES_DIR)) {
103
- copyRecursive(SEED_FILES_DIR, FILES_DIR);
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
- fs.writeFileSync(challengePath(id), yaml.dump(doc, { lineWidth: 100 }), 'utf8');
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
- if (points >= 1000) return 'Elite';
85
- if (points >= 600) return 'Operator';
86
- if (points >= 300) return 'Pentester';
87
- if (points >= 100) return 'Script Kiddie';
88
- return 'Recruit';
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
  };