icoa-cli 2.19.21 → 2.19.23

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.
@@ -51,25 +51,55 @@ export function registerLangCommand(program) {
51
51
  }
52
52
  saveConfig({ language: code });
53
53
  printSuccess(`Language set to: ${LANG_NAMES[code] || code}`);
54
- // If demo exam in progress, restart with fresh random pick in new language.
55
- // (Progress is lost because questions are randomly picked from a 30-question pool
56
- // and each attempt shuffles option positions mid-exam translation would
57
- // desync the answer key.)
54
+ // If demo in progress, re-translate each drawn question in place using
55
+ // sourceNumber + sourceOrder so the user's answers and option positions are
56
+ // preserved. If an older state lacks these fields (pre-v2.19.22), fall back
57
+ // to restart-with-fresh-pick so nothing crashes.
58
58
  const state = getExamState();
59
59
  if (state && state.session.examId === 'demo-free') {
60
60
  try {
61
- const { pickDemoQuestions, getLocalizedDemoSession, DEMO_PICK_SIZE } = await import('../lib/demo-exam.js');
62
- const freshQuestions = pickDemoQuestions(DEMO_PICK_SIZE);
63
- state.questions = freshQuestions;
64
- state.answers = {};
65
- state.session.examName = getLocalizedDemoSession().examName;
66
- state.session.startedAt = new Date().toISOString();
67
- state._lastQ = 1;
61
+ const { pickDemoQuestions, getLocalizedDemoSession, getLocalizedDemoQuestions, getLocalizedExplanations, DEMO_PICK_SIZE, } = await import('../lib/demo-exam.js');
68
62
  const { saveExamState } = await import('../lib/exam-state.js');
69
- saveExamState(state);
70
- console.log();
71
- console.log(chalk.green(` Demo restarted in ${LANG_NAMES[code] || code}.`));
72
- console.log(chalk.white(' Type: exam q 1'));
63
+ const canRetranslate = state.questions.every((q) => q.sourceNumber != null && Array.isArray(q.sourceOrder) && q.sourceOrder.length === 4);
64
+ if (canRetranslate) {
65
+ const pool = getLocalizedDemoQuestions();
66
+ const explanations = getLocalizedExplanations();
67
+ state.questions = state.questions.map((q) => {
68
+ const src = pool.find((p) => p.number === q.sourceNumber);
69
+ if (!src || !q.sourceOrder)
70
+ return q;
71
+ return {
72
+ ...q,
73
+ text: src.text,
74
+ category: src.category,
75
+ options: {
76
+ A: src.options[q.sourceOrder[0]],
77
+ B: src.options[q.sourceOrder[1]],
78
+ C: src.options[q.sourceOrder[2]],
79
+ D: src.options[q.sourceOrder[3]],
80
+ },
81
+ explanation: explanations[q.sourceNumber],
82
+ };
83
+ });
84
+ state.session.examName = getLocalizedDemoSession().examName;
85
+ saveExamState(state);
86
+ const currentQ = state._lastQ || 1;
87
+ console.log();
88
+ console.log(chalk.green(` Demo continues in ${LANG_NAMES[code] || code}. Your progress is kept.`));
89
+ console.log(chalk.white(` Resume: exam q ${currentQ}`));
90
+ }
91
+ else {
92
+ // Legacy state from before v2.19.22 — safely reset
93
+ state.questions = pickDemoQuestions(DEMO_PICK_SIZE);
94
+ state.answers = {};
95
+ state.session.examName = getLocalizedDemoSession().examName;
96
+ state.session.startedAt = new Date().toISOString();
97
+ state._lastQ = 1;
98
+ saveExamState(state);
99
+ console.log();
100
+ console.log(chalk.green(` Demo restarted in ${LANG_NAMES[code] || code}.`));
101
+ console.log(chalk.white(' Type: exam q 1'));
102
+ }
73
103
  }
74
104
  catch {
75
105
  console.log(chalk.gray(' Language changed. Type: demo'));
@@ -0,0 +1,15 @@
1
+ export declare const c: {
2
+ fg: (s: string) => string;
3
+ muted: (s: string) => string;
4
+ red: (s: string) => string;
5
+ green: (s: string) => string;
6
+ yellow: (s: string) => string;
7
+ blue: (s: string) => string;
8
+ cyan: (s: string) => string;
9
+ orange: (s: string) => string;
10
+ white: (s: string) => string;
11
+ };
12
+ export declare const DARCULA_BG_HEX = "#2B2B2B";
13
+ export declare const DARCULA_FG_HEX = "#A9B7C6";
14
+ export declare const DARCULA_BG_RGB: readonly [43, 43, 43];
15
+ export declare const DARCULA_FG_RGB: readonly [169, 183, 198];
@@ -0,0 +1,21 @@
1
+ // Darcula palette — from ICOA Terminal xterm.js theme spec.
2
+ // Use c.* helpers when you need brand-accurate truecolor (e.g. the orange
3
+ // accent #CC7832). For generic success/error/warning, chalk.green/.red/.yellow
4
+ // remain fine — they render as the terminal's Darcula 16-color when using an
5
+ // ICOA theme and as close-enough defaults elsewhere.
6
+ const tc = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
7
+ export const c = {
8
+ fg: tc(169, 183, 198), // #A9B7C6 body text
9
+ muted: tc(85, 85, 85), // #555555 comments / secondary
10
+ red: tc(255, 107, 104), // #FF6B68 error
11
+ green: tc(168, 192, 35), // #A8C023 success
12
+ yellow: tc(214, 191, 85), // #D6BF55 warning
13
+ blue: tc(126, 174, 241), // #7EAEF1 link
14
+ cyan: tc(40, 123, 222), // #287BDE command / path
15
+ orange: tc(204, 120, 50), // #CC7832 brand accent
16
+ white: tc(255, 255, 255), // #FFFFFF highlight
17
+ };
18
+ export const DARCULA_BG_HEX = '#2B2B2B';
19
+ export const DARCULA_FG_HEX = '#A9B7C6';
20
+ export const DARCULA_BG_RGB = [43, 43, 43];
21
+ export const DARCULA_FG_RGB = [169, 183, 198];
@@ -127,16 +127,24 @@ function shuffle(arr) {
127
127
  }
128
128
  return out;
129
129
  }
130
- /** Shuffle the four options within a single question; recompute the answer letter. */
130
+ /**
131
+ * Shuffle the four options within a single question; recompute the answer letter
132
+ * and record `sourceOrder` so the shuffle can be replayed against a different
133
+ * translation of the same question (used by lang-switch to keep answers).
134
+ */
131
135
  function shuffleQuestionOptions(q) {
132
136
  if (!q.answer)
133
137
  return q;
134
- const correctText = q.options[q.answer];
135
138
  const keys = ['A', 'B', 'C', 'D'];
136
- const values = shuffle(keys.map((k) => q.options[k]));
137
- const newOptions = { A: values[0], B: values[1], C: values[2], D: values[3] };
138
- const newAnswer = keys.find((k) => newOptions[k] === correctText);
139
- return { ...q, options: newOptions, answer: newAnswer };
139
+ const sourceOrder = shuffle(keys);
140
+ const newOptions = {
141
+ A: q.options[sourceOrder[0]],
142
+ B: q.options[sourceOrder[1]],
143
+ C: q.options[sourceOrder[2]],
144
+ D: q.options[sourceOrder[3]],
145
+ };
146
+ const newAnswer = keys[sourceOrder.indexOf(q.answer)];
147
+ return { ...q, options: newOptions, answer: newAnswer, sourceOrder };
140
148
  }
141
149
  /**
142
150
  * Pick `n` random questions from the pool, shuffle each question's options,
@@ -152,12 +160,14 @@ export function pickDemoQuestions(n = DEMO_PICK_SIZE) {
152
160
  answer: DEMO_ANSWERS[q.number],
153
161
  explanation: explanations[q.number],
154
162
  }));
155
- // Shuffle pool, pick n, shuffle each question's options, renumber 1..n
163
+ // Shuffle pool, pick n, shuffle each question's options, renumber 1..n.
164
+ // Keep the original pool number as `sourceNumber` so lang-switch can re-translate
165
+ // this exact question without losing the user's answer.
156
166
  const picked = shuffle(enriched).slice(0, n);
157
- return picked.map((q, i) => ({
158
- ...shuffleQuestionOptions(q),
159
- number: i + 1,
160
- }));
167
+ return picked.map((q, i) => {
168
+ const shuffled = shuffleQuestionOptions(q);
169
+ return { ...shuffled, sourceNumber: q.number, number: i + 1 };
170
+ });
161
171
  }
162
172
  /**
163
173
  * Get localized explanations for demo questions.
package/dist/lib/theme.js CHANGED
@@ -1,52 +1,62 @@
1
- import { execSync } from 'node:child_process';
2
- import { platform } from 'node:os';
1
+ // Unified Darcula terminal theme — works identically on macOS Terminal.app,
2
+ // iTerm2, GNOME Terminal, Konsole, Windows Terminal (cmd/PowerShell/WSL).
3
+ //
4
+ // Two mechanisms are combined so every modern terminal gets the best it can:
5
+ //
6
+ // 1. OSC 10/11/12 sets the terminal's *default* fg/bg/cursor colors.
7
+ // Honored by iTerm2, GNOME Terminal, Konsole, Windows Terminal → lossless
8
+ // background, no scrollback or resize artifacts. Ignored by macOS
9
+ // Terminal.app.
10
+ //
11
+ // 2. SGR 38/48 + \x1b[2J paints the visible grid cells with Darcula colors.
12
+ // Works everywhere including Terminal.app. The known limits on Terminal.app
13
+ // (resize edge, scrollback of pre-launch history, copy-paste carrying bg)
14
+ // are protocol-level and accepted; fix path is importing a .terminal profile.
15
+ //
16
+ // Legacy cmd.exe (pre-Win10 1809) can't run Node 22 anyway, so no separate
17
+ // fallback path is needed.
18
+ const OSC_INIT = '\x1b]10;#A9B7C6\x07' + // default fg
19
+ '\x1b]11;#2B2B2B\x07' + // default bg
20
+ '\x1b]12;#A9B7C6\x07'; // cursor color
21
+ const OSC_RESET = '\x1b]110\x07' + // reset default fg
22
+ '\x1b]111\x07' + // reset default bg
23
+ '\x1b]112\x07'; // reset cursor color
24
+ const SGR_INIT = '\x1b[38;2;169;183;198m' + // fg #A9B7C6
25
+ '\x1b[48;2;43;43;43m' + // bg #2B2B2B
26
+ '\x1b[2J' + // paint grid with current bg
27
+ '\x1b[H'; // cursor home
28
+ const SGR_RESET = '\x1b[0m\x1b[2J\x1b[H';
29
+ function supportsAnsi() {
30
+ if (!process.stdout.isTTY)
31
+ return false;
32
+ const depth = process.stdout.getColorDepth?.();
33
+ if (typeof depth === 'number')
34
+ return depth >= 8;
35
+ return true;
36
+ }
37
+ let armed = false;
3
38
  export function setTerminalTheme() {
4
- const os = platform();
5
- if (os === 'darwin') {
6
- // macOS Terminal.app: use AppleScript to change colors
7
- try {
8
- execSync(`osascript -e '
9
- tell application "Terminal"
10
- set bg to {0, 0, 0}
11
- set fg to {0, 65535, 16896}
12
- set background color of selected tab of front window to bg
13
- set normal text color of selected tab of front window to fg
14
- set cursor color of selected tab of front window to fg
15
- end tell
16
- '`, { stdio: 'ignore' });
17
- }
18
- catch {
19
- // Fallback to ANSI if AppleScript fails (e.g. not Terminal.app)
20
- process.stdout.write('\x1b[0m\x1b[40m\x1b[38;2;0;255;65m\x1b[2J\x1b[H');
21
- }
22
- }
23
- else {
24
- // Linux / Windows: ANSI escape codes
25
- process.stdout.write('\x1b[0m\x1b[40m\x1b[38;2;0;255;65m\x1b[2J\x1b[H');
39
+ if (!supportsAnsi())
40
+ return;
41
+ process.stdout.write(OSC_INIT + SGR_INIT);
42
+ if (!armed) {
43
+ armed = true;
44
+ // Belt-and-braces cleanup on every exit path. Without these, Ctrl+C leaves
45
+ // the user's shell stuck with our SGR state.
46
+ const cleanup = () => {
47
+ try {
48
+ process.stdout.write(OSC_RESET + SGR_RESET);
49
+ }
50
+ catch { }
51
+ };
52
+ process.on('exit', cleanup);
53
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
54
+ process.on('SIGTERM', () => { cleanup(); process.exit(143); });
55
+ process.on('SIGHUP', () => { cleanup(); process.exit(129); });
26
56
  }
27
- // Clear screen
28
- process.stdout.write('\x1b[2J\x1b[H');
29
57
  }
30
58
  export function resetTerminalTheme() {
31
- const os = platform();
32
- if (os === 'darwin') {
33
- try {
34
- // Reset to macOS Terminal.app default profile colors
35
- execSync(`osascript -e '
36
- tell application "Terminal"
37
- set bg to {65535, 65535, 65535}
38
- set fg to {0, 0, 0}
39
- set background color of selected tab of front window to bg
40
- set normal text color of selected tab of front window to fg
41
- set cursor color of selected tab of front window to fg
42
- end tell
43
- '`, { stdio: 'ignore' });
44
- }
45
- catch {
46
- process.stdout.write('\x1b[0m\x1b[2J\x1b[H');
47
- }
48
- }
49
- else {
50
- process.stdout.write('\x1b[0m\x1b[2J\x1b[H');
51
- }
59
+ if (!supportsAnsi())
60
+ return;
61
+ process.stdout.write(OSC_RESET + SGR_RESET);
52
62
  }
package/dist/lib/ui.js CHANGED
@@ -3,18 +3,23 @@ import Table from 'cli-table3';
3
3
  import ora from 'ora';
4
4
  import { Marked } from 'marked';
5
5
  import { markedTerminal } from 'marked-terminal';
6
+ import { c } from './colors.js';
6
7
  const marked = new Marked(markedTerminal());
8
+ // Wrap the message body in explicit Darcula fg (#A9B7C6). Without this,
9
+ // chalk.green('✓ ') emits \x1b[39m at the end and the unstyled msg falls
10
+ // back to the terminal's profile fg — which is black on macOS Terminal.app
11
+ // default, invisible on our forced #2B2B2B background.
7
12
  export function printSuccess(msg) {
8
- console.log(chalk.green('✓ ') + msg);
13
+ console.log(chalk.green('✓ ') + c.fg(msg));
9
14
  }
10
15
  export function printError(msg) {
11
- console.log(chalk.red('✗ ') + msg);
16
+ console.log(chalk.red('✗ ') + c.fg(msg));
12
17
  }
13
18
  export function printWarning(msg) {
14
- console.log(chalk.yellow('⚠ ') + msg);
19
+ console.log(chalk.yellow('⚠ ') + c.fg(msg));
15
20
  }
16
21
  export function printInfo(msg) {
17
- console.log(chalk.blue('ℹ ') + msg);
22
+ console.log(chalk.blue('ℹ ') + c.fg(msg));
18
23
  }
19
24
  export function printTable(headers, rows) {
20
25
  const table = new Table({
@@ -71,5 +76,5 @@ export function printHeader(title) {
71
76
  console.log(chalk.cyan(' ' + '─'.repeat(title.length + 4)));
72
77
  }
73
78
  export function printKeyValue(key, value) {
74
- console.log(` ${chalk.gray(key + ':')} ${value}`);
79
+ console.log(` ${chalk.gray(key + ':')} ${c.fg(value)}`);
75
80
  }
@@ -152,6 +152,8 @@ export interface ExamQuestion {
152
152
  };
153
153
  answer?: 'A' | 'B' | 'C' | 'D';
154
154
  explanation?: string;
155
+ sourceNumber?: number;
156
+ sourceOrder?: ('A' | 'B' | 'C' | 'D')[];
155
157
  }
156
158
  export interface ExamSession {
157
159
  examId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.21",
3
+ "version": "2.19.23",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {