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 +44 -0
- package/package.json +3 -2
- package/src/commands.js +86 -18
- package/src/store.js +64 -12
- package/src/ui.js +16 -0
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.
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
271
|
+
const catEntries = Object.keys(byCat)
|
|
227
272
|
.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
|
-
]));
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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,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
|
};
|