cyberhub-pracenv 1.0.0 → 1.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
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.0] - 2026-06-25
9
+
10
+ ### Fixed
11
+ - **Uploaded challenges no longer disappear after a restart.** Challenges created
12
+ with `upload` (and files attached with `addfile`) are now mirrored into the
13
+ bundled `data/challenges/` seed, and launch-time seeding now *tops up* any
14
+ challenge missing from the per-user data dir instead of only seeding when it is
15
+ empty. A reset or wiped `~/.cyberhub-pracenv/` is restored from the seed, so
16
+ uploads survive restarts and reinstalls and are version-control friendly. The
17
+ seed mirror is best-effort, so a read-only install location can never break an
18
+ upload.
19
+
20
+ ### Changed
21
+ - **Reworked the `stats` command** into a full breakdown: an overall progress bar,
22
+ a progress bar per difficulty, a progress bar per category, and a detailed table
23
+ of solved challenges (id, title, category, difficulty, points) sorted by
24
+ difficulty. Solved entries that no longer exist are flagged.
25
+
26
+ ### Added
27
+ - Progress-bar renderer (`ui.progressBar`) used throughout the new `stats` view.
28
+ - `CHANGELOG.md` (this file).
29
+ - `CHPE_SEED_DIR` environment override so tests redirect the seed/mirror location
30
+ away from the real project data; added regression tests covering upload
31
+ persistence across a data-dir reset.
32
+
33
+ ## [1.0.0] - 2026-06-25
34
+
35
+ ### Added
36
+ - Initial release: a fully terminal-based, beginner-friendly CTF practice platform
37
+ in the ICOA terminal style, installed globally and run as an interactive shell.
38
+ - Commands: `challenges`/`ls`, `open`, `files`, `cat`, `get`, `submit`, `stats`,
39
+ `admin`, `clear`, `help`, `exit`, plus admin commands `upload`, `addfile`,
40
+ `rmchallenge`, `passwd`, `logout`.
41
+ - Single local profile with points, ranks, and per-category progress; admin mode
42
+ unlocked by a password (default `admin`).
43
+ - YAML-based challenges with attached files, seeded from a bundled sample set into
44
+ `~/.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.0",
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
@@ -198,21 +198,50 @@ function cmdSubmit(ctx, args) {
198
198
  }
199
199
  }
200
200
 
201
+ // Known difficulties listed in ramp-up order; anything else sorts after these
202
+ // alphabetically so custom difficulties still appear.
203
+ const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'insane', 'expert', 'elite'];
204
+
205
+ function difficultyRank(diff) {
206
+ const i = DIFFICULTY_ORDER.indexOf(String(diff).toLowerCase());
207
+ return i === -1 ? DIFFICULTY_ORDER.length : i;
208
+ }
209
+
210
+ // Print a list of "label progress-bar" lines with the labels padded to a
211
+ // common width so the bars line up.
212
+ function printBarGroup(ctx, entries) {
213
+ if (entries.length === 0) return;
214
+ const labelWidth = Math.max(...entries.map((e) => e.label.length));
215
+ for (const e of entries) {
216
+ const label = e.label + ' '.repeat(labelWidth - e.label.length);
217
+ print(ctx, ` ${c.accent(label)} ${ui.progressBar(e.solved, e.total)}`);
218
+ }
219
+ }
220
+
201
221
  function cmdStats(ctx) {
202
222
  const profile = store.getProfile();
203
223
  const challenges = store.listChallenges();
204
224
  const total = challenges.length;
225
+ const solvedSet = new Set(profile.solved);
205
226
  const solvedCount = profile.solved.length;
206
227
 
207
- // Per-category progress.
228
+ // Bucket challenges by difficulty and category for the progress breakdowns.
229
+ const byDiff = {};
208
230
  const byCat = {};
209
231
  for (const ch of challenges) {
232
+ const diff = (ch.difficulty || 'unknown').toLowerCase();
210
233
  const cat = ch.category || 'misc';
234
+ byDiff[diff] = byDiff[diff] || { total: 0, solved: 0 };
211
235
  byCat[cat] = byCat[cat] || { total: 0, solved: 0 };
236
+ byDiff[diff].total += 1;
212
237
  byCat[cat].total += 1;
213
- if (profile.solved.includes(ch.id)) byCat[cat].solved += 1;
238
+ if (solvedSet.has(ch.id)) {
239
+ byDiff[diff].solved += 1;
240
+ byCat[cat].solved += 1;
241
+ }
214
242
  }
215
243
 
244
+ // --- Summary -------------------------------------------------------------
216
245
  print(ctx, c.title('User breakdown'));
217
246
  print(ctx, ` ${c.bold('Operative')} : ${profile.name}`);
218
247
  print(ctx, ` ${c.bold('Rank')} : ${c.accent(ui.rankFor(profile.points))}`);
@@ -222,26 +251,65 @@ function cmdStats(ctx) {
222
251
  print(ctx, ` ${c.bold('Member since')} : ${new Date(profile.createdAt).toLocaleString()}`);
223
252
  print(ctx, ` ${c.bold('Admin mode')} : ${auth.isAdmin() ? c.ok('ON') : c.dim('off')}`);
224
253
 
254
+ // --- Overall progress bar ------------------------------------------------
255
+ print(ctx, '', c.bold(' Overall progress:'));
256
+ print(ctx, ` ${ui.progressBar(solvedCount, total)}`);
257
+
258
+ // --- Progress bar per difficulty ----------------------------------------
259
+ print(ctx, '', c.bold(' Progress by difficulty:'));
260
+ const diffEntries = Object.keys(byDiff)
261
+ .sort((a, b) => difficultyRank(a) - difficultyRank(b) || a.localeCompare(b))
262
+ .map((diff) => ({ label: diff, solved: byDiff[diff].solved, total: byDiff[diff].total }));
263
+ if (diffEntries.length > 0) {
264
+ printBarGroup(ctx, diffEntries);
265
+ } else {
266
+ print(ctx, c.dim(' No challenges available yet.'));
267
+ }
268
+
269
+ // --- Progress bar per category ------------------------------------------
225
270
  print(ctx, '', c.bold(' Progress by category:'));
226
- const catRows = Object.keys(byCat)
271
+ const catEntries = Object.keys(byCat)
227
272
  .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
- ]));
273
+ .map((cat) => ({ label: cat, solved: byCat[cat].solved, total: byCat[cat].total }));
274
+ if (catEntries.length > 0) {
275
+ printBarGroup(ctx, catEntries);
276
+ } else {
277
+ print(ctx, c.dim(' No challenges available yet.'));
237
278
  }
238
279
 
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
- }
280
+ // --- Detailed breakdown of solved challenges ----------------------------
281
+ print(ctx, '', c.bold(' Solved challenges:'));
282
+ if (solvedCount === 0) {
283
+ print(ctx, c.dim(' Nothing solved yet — run `challenges` to get started.'));
284
+ return;
285
+ }
286
+ const solvedRows = profile.solved
287
+ .map((id) => store.getChallenge(id))
288
+ .filter(Boolean)
289
+ .sort(
290
+ (a, b) =>
291
+ difficultyRank(a.difficulty) - difficultyRank(b.difficulty) ||
292
+ String(a.id).localeCompare(String(b.id))
293
+ )
294
+ .map((ch) => ({
295
+ id: ch.id,
296
+ title: ch.title,
297
+ category: ch.category,
298
+ difficulty: ch.difficulty,
299
+ points: ch.points,
300
+ }));
301
+ print(ctx, ui.table(solvedRows, [
302
+ { key: 'id', label: 'ID', color: c.accent },
303
+ { key: 'title', label: 'Title' },
304
+ { key: 'category', label: 'Category' },
305
+ { key: 'difficulty', label: 'Difficulty' },
306
+ { key: 'points', label: 'Pts', color: c.ok },
307
+ ]));
308
+
309
+ // Note any solved ids whose challenge file is no longer present.
310
+ const missing = profile.solved.filter((id) => !store.getChallenge(id));
311
+ if (missing.length > 0) {
312
+ print(ctx, c.dim(` (${missing.length} solved challenge(s) no longer exist: ${missing.join(', ')})`));
245
313
  }
246
314
  }
247
315
 
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,6 +80,21 @@ 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
+
83
98
  function rankFor(points) {
84
99
  if (points >= 1000) return 'Elite';
85
100
  if (points >= 600) return 'Operator';
@@ -93,5 +108,6 @@ module.exports = {
93
108
  BANNER,
94
109
  welcomeScreen,
95
110
  table,
111
+ progressBar,
96
112
  rankFor,
97
113
  };