icoa-cli 2.19.356 → 2.19.357

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.
Files changed (75) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/ctf.js +787 -1
  3. package/dist/commands/ctf4ai-demo.js +1 -1
  4. package/dist/commands/ctf4vla.js +1 -1
  5. package/dist/commands/demo2.js +1502 -1
  6. package/dist/commands/exam.js +1 -1
  7. package/dist/commands/files.js +59 -1
  8. package/dist/commands/lang.js +202 -1
  9. package/dist/commands/log.js +171 -1
  10. package/dist/commands/shell.js +151 -1
  11. package/dist/commands/sim.js +389 -1
  12. package/dist/index.js +355 -1
  13. package/dist/lib/access.js +184 -1
  14. package/dist/lib/aienv.js +205 -1
  15. package/dist/lib/arena-submit.js +21 -1
  16. package/dist/lib/banner.js +31 -1
  17. package/dist/lib/budget.js +6 -1
  18. package/dist/lib/challenge-dir.js +16 -1
  19. package/dist/lib/colors.js +17 -1
  20. package/dist/lib/comms.js +212 -1
  21. package/dist/lib/config.js +93 -1
  22. package/dist/lib/countdown.js +43 -1
  23. package/dist/lib/country-lang.js +39 -1
  24. package/dist/lib/ctfd-client.js +417 -1
  25. package/dist/lib/demo-exam.js +478 -1
  26. package/dist/lib/demo-flags.js +27 -1
  27. package/dist/lib/demo-stats.js +62 -1
  28. package/dist/lib/demo2-progress.js +102 -1
  29. package/dist/lib/docker-probe.d.ts +45 -0
  30. package/dist/lib/docker-probe.js +118 -0
  31. package/dist/lib/editor-spawn.js +53 -1
  32. package/dist/lib/exam-client.js +54 -1
  33. package/dist/lib/exam-sandbox.js +201 -1
  34. package/dist/lib/exam-setup.js +36 -1
  35. package/dist/lib/exam-state.js +273 -1
  36. package/dist/lib/gemini.js +247 -1
  37. package/dist/lib/i18n.js +302 -1
  38. package/dist/lib/integrity-snapshot.js +88 -1
  39. package/dist/lib/interactive-spawn.js +55 -1
  40. package/dist/lib/ipynb-input.js +65 -1
  41. package/dist/lib/kernel-protocol.js +88 -1
  42. package/dist/lib/kernel.js +146 -2
  43. package/dist/lib/learn-curricula.js +309 -1
  44. package/dist/lib/learn-i18n.js +184 -1
  45. package/dist/lib/learn-input.js +101 -1
  46. package/dist/lib/learn-render.js +863 -1
  47. package/dist/lib/learn-state.js +103 -1
  48. package/dist/lib/log-sync.js +155 -1
  49. package/dist/lib/logger.js +49 -1
  50. package/dist/lib/main-rl.js +7 -1
  51. package/dist/lib/menu-nav.js +105 -1
  52. package/dist/lib/notebook-doc.js +137 -1
  53. package/dist/lib/open-file.js +55 -1
  54. package/dist/lib/paper-upgrade.js +119 -1
  55. package/dist/lib/platform.js +99 -1
  56. package/dist/lib/render-card.js +112 -1
  57. package/dist/lib/repl-asker.js +67 -1
  58. package/dist/lib/sample-runner.js +227 -1
  59. package/dist/lib/sandbox.d.ts +7 -1
  60. package/dist/lib/sandbox.js +144 -1
  61. package/dist/lib/shell-split.js +69 -1
  62. package/dist/lib/sim-cooldown.js +75 -1
  63. package/dist/lib/theme.js +119 -1
  64. package/dist/lib/token-format.js +74 -1
  65. package/dist/lib/tool-man.js +418 -1
  66. package/dist/lib/toolset-hash.js +48 -1
  67. package/dist/lib/translation.js +80 -1
  68. package/dist/lib/translations-fetcher.js +95 -1
  69. package/dist/lib/ui.js +99 -1
  70. package/dist/lib/update-check.js +114 -1
  71. package/dist/lib/version.js +24 -1
  72. package/dist/postinstall.js +48 -1
  73. package/dist/repl.js +2391 -1
  74. package/dist/types/index.js +63 -1
  75. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,2 +1,356 @@
1
1
  #!/usr/bin/env node
2
- import{Command as o}from"commander";import chalk from"chalk";import{registerCtfCommands as e}from"./commands/ctf.js";import{registerRefCommand as n}from"./commands/ref.js";import{registerShellCommand as s}from"./commands/shell.js";import{registerFilesCommand as r}from"./commands/files.js";import{registerViewCommand as t}from"./commands/view.js";import{registerConnectCommand as i}from"./commands/connect.js";import{registerChallengeCommand as a}from"./commands/challenge.js";import{registerNoteCommand as l}from"./commands/note.js";import{registerLogCommand as m}from"./commands/log.js";import{registerLangCommand as c}from"./commands/lang.js";import{registerSetupCommand as g}from"./commands/setup.js";import{registerEnvCommand as d}from"./commands/env.js";import{registerAienvCommand as p}from"./commands/aienv.js";import{registerIpynbCommand as y}from"./commands/ipynb.js";import{registerAi4ctfCommand as h}from"./commands/ai4ctf.js";import{registerExamCommand as f}from"./commands/exam.js";import{registerCtf4aiDemoCommand as u}from"./commands/ctf4ai-demo.js";import{registerThemeCommand as w}from"./commands/theme.js";import{registerLearnCommand as T}from"./commands/learn.js";import{registerCtf4VlaCommand as b}from"./commands/ctf4vla.js";import{registerDemo2Command as v}from"./commands/demo2.js";import{registerSimCommand as I}from"./commands/sim.js";import{registerArenaCommand as $}from"./commands/arena.js";import{getConfig as j,saveConfig as A}from"./lib/config.js";import{startRepl as C}from"./repl.js";import{RELAUNCH_CODE as E}from"./lib/menu-nav.js";import{setTerminalTheme as _}from"./lib/theme.js";import{checkForUpdates as S}from"./lib/update-check.js";import{detectIcoaInstalls as x}from"./lib/platform.js";import{ICOA_BIG as L}from"./lib/banner.js";import{readFileSync as N}from"node:fs";import{fileURLToPath as O}from"node:url";import{dirname as k,join as F}from"node:path";const R=k(O(import.meta.url)),U=JSON.parse(N(F(R,"..","package.json"),"utf-8")).version,P=chalk.cyan(" ─────────────────────────────────────────────────────"),M=`\n${P}\n\n${L.map(o=>` ${chalk.bold.white(o)}`).join("\n")}\n\n ${chalk.yellow("International Cyber Olympiad in AI 2026")}\n ${chalk.bold.magenta("The World's First AI-Native CLI Operating System")}\n ${chalk.bold.magenta("for Cybersecurity & AI Security Competition")}\n ${chalk.bold.magenta("and Olympiad for K-12")}\n\n ${chalk.green.bold("AI4CTF")} ${chalk.gray("[Day 1]")} ${chalk.white("AI as your teammate")}\n ${chalk.red.bold("CTF4AI")} ${chalk.gray("[Day 2]")} ${chalk.white("Challenge & evaluate AI systems")}\n ${chalk.cyan.bold("CTF4EAI")} ${chalk.gray("[Pioneer]")} ${chalk.white("Embodied AI security")}\n ${chalk.bold.yellow("AI is your teammate. AI is your target. AI is embodied.")}\n\n ${chalk.white("Sydney, Australia")} ${chalk.gray("Jun 27 - Jul 2, 2026")}\n ${chalk.cyan.underline("https://icoa2026.au")}\n\n ${chalk.gray(`CLI-Native Competition Terminal v${U}`)}\n\n${P}\n`;process.on("uncaughtException",o=>{"__REPL_NO_EXIT__"!==o.message&&(console.error(chalk.red("Error:"),o.message),process.exit(1))}),process.on("unhandledRejection",o=>{const e=o instanceof Error?o.message:String(o);"__REPL_NO_EXIT__"!==e&&(console.error(chalk.red("Error:"),e),process.exit(1))});{const o=process.argv.slice(2).some(o=>["-V","--version","-h","--help"].includes(o));if(!0===process.stdin.isTTY&&"1"!==process.env.ICOA_SUPERVISED&&!o){const{spawnSync:o}=await import("node:child_process");process.on("SIGINT",()=>{}),process.on("SIGTERM",()=>{});let e=0,n=!1;do{const s=o(process.execPath,process.argv.slice(1),{stdio:"inherit",env:{...process.env,ICOA_SUPERVISED:"1",...n?{ICOA_RELAUNCH:"1"}:{}}});if(s.signal){e=0;break}e=s.status,n=!0}while(e===E);process.exit(e??0)}}const G=new o;if(G.name("icoa").version(U).description("ICOA CLI — CLI-Native CTF Competition Terminal").option("--resume","Resume previous session").action(async o=>{const e=j();_("high-contrast"===e.themeVariant?"high-contrast":"dark"),S(),function(){const o=x();if(o.length<=1)return;const e=o[0];if([...o.map(o=>o.version||"0.0.0")].sort((o,e)=>function(o,e){const n=o.split(".").map(o=>parseInt(o,10)||0),s=e.split(".").map(o=>parseInt(o,10)||0);for(let o=0;o<3;o++)if((n[o]||0)!==(s[o]||0))return(n[o]||0)-(s[o]||0);return 0}(e,o))[0]!==e.version){console.log(),console.log(chalk.yellow.bold(" ⚠ Multiple icoa installations detected on PATH:"));for(let e=0;e<o.length;e++){const n=o[e],s=n.version?`v${n.version}`:"(version unreadable)",r=0===e?chalk.yellow("→"):" ",t=0===e?chalk.gray(" ← currently running (older — shadowing newer install)"):chalk.gray(" ← shadowed");console.log(` ${r} ${chalk.cyan(n.path.padEnd(28))} ${chalk.white(s)}${t}`)}console.log(),console.log(chalk.yellow(" The first install on PATH wins. To use the newer one, remove the older:")),console.log(),e.pkgDir?console.log(` ${chalk.bold.cyan(`sudo rm -rf ${e.pkgDir} ${e.path}`)}`):console.log(` ${chalk.bold.cyan(`sudo rm -rf ${e.path} # also delete its node_modules dir`)}`),console.log(),console.log(chalk.gray(" Then re-run icoa to confirm version banner shows the newer release.")),console.log()}}();const n=process.env.LANG||process.env.LC_ALL||process.env.LC_CTYPE||"";if(!/UTF-?8/i.test(n))if("win32"===process.platform){let o="";try{const{execFileSync:e}=await import("node:child_process");o=e("chcp.com",[],{encoding:"utf-8",timeout:1500,stdio:["ignore","pipe","ignore"]}).trim()}catch{}o.includes("65001")||(console.log(chalk.yellow(`⚠ Windows terminal is not using UTF-8 (current: ${o||"unknown"}).`)),console.log(chalk.gray(' Non-English text (Ukrainian, Chinese, Japanese, etc.) may show as "?" or garbled glyphs.')),console.log(chalk.gray(" Fix (run before ")+chalk.cyan("icoa")+chalk.gray("): ")+chalk.cyan("chcp 65001")),console.log(chalk.gray(" Or stay in English inside the CLI: ")+chalk.cyan("lang en")),console.log())}else console.log(chalk.yellow(`⚠ Your terminal locale is not UTF-8 (LANG=${n||"(unset)"}).`)),console.log(chalk.gray(' Non-English text and box characters may display as "?" or garbled glyphs.')),console.log(chalk.gray(" Fix: ")+chalk.cyan("export LANG=en_US.UTF-8")+chalk.gray(" (or your locale, e.g. ")+chalk.cyan("zh_CN.UTF-8")+chalk.gray(", ")+chalk.cyan("uk_UA.UTF-8")+chalk.gray(")")),console.log();if(console.log(M),process.argv.length<=2||o.resume)return o.resume||"1"===process.env.ICOA_RELAUNCH||await async function(){const o=process.stdin;if(o.isTTY&&"function"==typeof o.setRawMode)return new Promise(e=>{let n=!1;const s=()=>{if(!n){n=!0,o.removeListener("data",r);try{o.setRawMode(!1)}catch{}o.pause(),e()}},r=()=>s();o.setRawMode(!0),o.resume(),o.once("data",r),console.log(chalk.gray(" (press any key to continue...)")),setTimeout(s,3e3)});await new Promise(o=>setTimeout(o,3e3))}(),void C(G,!!o.resume)}),e(G),n(G),s(G),r(G),t(G),i(G),a(G),l(G),m(G),c(G),g(G),d(G),p(G),y(G),h(G),f(G),u(G),w(G),T(G),b(G),v(G),I(G),$(G),G.command("model",{hidden:!0}).argument("[name]","model name to switch to").action(o=>{const e=j().geminiModel||"gemini-2.5-flash-lite";o?(A({geminiModel:o}),console.log(),console.log(chalk.green(" Model switched: ")+chalk.gray(e)+chalk.white(" -> ")+chalk.bold.white(o)),console.log()):(console.log(),console.log(chalk.gray(" Current model: ")+chalk.white(e)),console.log(),console.log(chalk.gray(" Available models:")),console.log(chalk.bold.white(" Gemini 3.x (Latest)")),console.log(chalk.white(" model gemini-3.5-flash ")+chalk.gray("Newest stable, paid Tier 1+")),console.log(chalk.white(" model gemini-3-flash-preview ")+chalk.gray("Preview")),console.log(chalk.bold.white(" Gemini 2.5 (Stable)")),console.log(chalk.white(" model gemini-2.5-flash-lite ")+chalk.gray("Fastest, default, free tier")),console.log(chalk.white(" model gemini-2.5-flash ")+chalk.gray("Heavier, thinking enabled")),console.log(chalk.white(" model gemini-2.5-pro ")+chalk.gray("Strong reasoning, paid")),console.log(chalk.bold.white(" Open Source")),console.log(chalk.white(" model gemma-4-31b-it ")+chalk.gray("Free, open-source")),console.log(chalk.white(" model <any-model-id> ")+chalk.gray("Custom model")),console.log(),console.log(chalk.gray(" Translation uses gemini-3.5-flash for best quality.")),console.log())}),"1"===process.env.ICOA_RESET_STATE)try{const{clearExamState:o}=await import("./lib/exam-state.js");o(),console.log(chalk.yellow("⚠ ICOA_RESET_STATE=1 — local exam state wiped.")),console.log(chalk.gray(" (Token NOT revoked server-side. Re-enter a fresh token with `exam <token>`.)")),console.log()}catch(o){console.log(chalk.red("⚠ ICOA_RESET_STATE: could not clear state — ")+chalk.gray(String(o)))}G.parse();
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import { registerCtfCommands } from './commands/ctf.js';
5
+ import { registerRefCommand } from './commands/ref.js';
6
+ import { registerShellCommand } from './commands/shell.js';
7
+ import { registerFilesCommand } from './commands/files.js';
8
+ import { registerViewCommand } from './commands/view.js';
9
+ import { registerConnectCommand } from './commands/connect.js';
10
+ import { registerChallengeCommand } from './commands/challenge.js';
11
+ import { registerNoteCommand } from './commands/note.js';
12
+ import { registerLogCommand } from './commands/log.js';
13
+ import { registerLangCommand } from './commands/lang.js';
14
+ import { registerSetupCommand } from './commands/setup.js';
15
+ import { registerEnvCommand } from './commands/env.js';
16
+ import { registerAienvCommand } from './commands/aienv.js';
17
+ import { registerIpynbCommand } from './commands/ipynb.js';
18
+ import { registerAi4ctfCommand } from './commands/ai4ctf.js';
19
+ import { registerExamCommand } from './commands/exam.js';
20
+ import { registerCtf4aiDemoCommand } from './commands/ctf4ai-demo.js';
21
+ import { registerThemeCommand } from './commands/theme.js';
22
+ import { registerLearnCommand } from './commands/learn.js';
23
+ import { registerCtf4VlaCommand } from './commands/ctf4vla.js';
24
+ import { registerDemo2Command } from './commands/demo2.js';
25
+ import { registerSimCommand } from './commands/sim.js';
26
+ import { registerArenaCommand } from './commands/arena.js';
27
+ import { getConfig, saveConfig } from './lib/config.js';
28
+ import { startRepl } from './repl.js';
29
+ import { RELAUNCH_CODE } from './lib/menu-nav.js';
30
+ import { setTerminalTheme } from './lib/theme.js';
31
+ import { checkForUpdates } from './lib/update-check.js';
32
+ import { detectIcoaInstalls } from './lib/platform.js';
33
+ import { ICOA_BIG } from './lib/banner.js';
34
+ import { readFileSync } from 'node:fs';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { dirname, join } from 'node:path';
37
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
+ const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
39
+ const LINE = chalk.cyan(' ─────────────────────────────────────────────────────');
40
+ const BANNER = `
41
+ ${LINE}
42
+
43
+ ${ICOA_BIG.map((l) => ` ${chalk.bold.white(l)}`).join('\n')}
44
+
45
+ ${chalk.yellow('International Cyber Olympiad in AI 2026')}
46
+ ${chalk.bold.magenta("The World's First AI-Native CLI Operating System")}
47
+ ${chalk.bold.magenta('for Cybersecurity & AI Security Competition')}
48
+ ${chalk.bold.magenta('and Olympiad for K-12')}
49
+
50
+ ${chalk.green.bold('AI4CTF')} ${chalk.gray('[Day 1]')} ${chalk.white('AI as your teammate')}
51
+ ${chalk.red.bold('CTF4AI')} ${chalk.gray('[Day 2]')} ${chalk.white('Challenge & evaluate AI systems')}
52
+ ${chalk.cyan.bold('CTF4EAI')} ${chalk.gray('[Pioneer]')} ${chalk.white('Embodied AI security')}
53
+ ${chalk.bold.yellow('AI is your teammate. AI is your target. AI is embodied.')}
54
+
55
+ ${chalk.white('Sydney, Australia')} ${chalk.gray('Jun 27 - Jul 2, 2026')}
56
+ ${chalk.cyan.underline('https://icoa2026.au')}
57
+
58
+ ${chalk.gray(`CLI-Native Competition Terminal v${PKG_VERSION}`)}
59
+
60
+ ${LINE}
61
+ `;
62
+ // Global error handlers
63
+ process.on('uncaughtException', (err) => {
64
+ if (err.message === '__REPL_NO_EXIT__')
65
+ return;
66
+ console.error(chalk.red('Error:'), err.message);
67
+ process.exit(1);
68
+ });
69
+ process.on('unhandledRejection', (reason) => {
70
+ const msg = reason instanceof Error ? reason.message : String(reason);
71
+ if (msg === '__REPL_NO_EXIT__')
72
+ return;
73
+ console.error(chalk.red('Error:'), msg);
74
+ process.exit(1);
75
+ });
76
+ // ─── Single-process supervisor (fixes the menu-bounce slowdown) ────────────
77
+ // Interactive sessions relaunch on `menu`. The old approach (returnToMainMenu
78
+ // spawning a child while the parent stayed alive) STACKED node processes: K
79
+ // bounces left K+1 live processes all holding the inherited TTY, which slowed
80
+ // the CLI to a crawl and dropped keystrokes ("learn" → "lan"). A 4-deep chain
81
+ // was observed in prod (2026-06-04).
82
+ //
83
+ // Fix: a thin supervisor runs the real CLI as a child via spawnSync and
84
+ // relaunches it ONLY when it exits with RELAUNCH_CODE (what `menu` now does).
85
+ // Exactly one worker is ever alive, so navigation stays fast regardless of how
86
+ // many times the user bounces to the menu.
87
+ //
88
+ // Scoped to interactive TTY sessions only — one-shot flags (--version/--help)
89
+ // and non-TTY/piped runs skip the supervisor entirely, so scripted/CI usage is
90
+ // byte-for-byte unchanged. The worker is marked with ICOA_SUPERVISED=1 so it
91
+ // skips this block and runs the real CLI below.
92
+ {
93
+ const argvRest = process.argv.slice(2);
94
+ const isOneShot = argvRest.some((a) => ['-V', '--version', '-h', '--help'].includes(a));
95
+ const wantSupervisor = process.stdin.isTTY === true && process.env.ICOA_SUPERVISED !== '1' && !isOneShot;
96
+ if (wantSupervisor) {
97
+ const { spawnSync } = await import('node:child_process');
98
+ // The supervisor must survive Ctrl+C: the worker owns SIGINT (exam pause,
99
+ // inquirer cancel) and decides its own exit code. If the supervisor died
100
+ // on SIGINT the whole session would drop to the shell mid-exam.
101
+ process.on('SIGINT', () => { });
102
+ process.on('SIGTERM', () => { });
103
+ let exitCode = 0;
104
+ let relaunch = false;
105
+ do {
106
+ const res = spawnSync(process.execPath, process.argv.slice(1), {
107
+ stdio: 'inherit',
108
+ env: {
109
+ ...process.env,
110
+ ICOA_SUPERVISED: '1',
111
+ // After the first launch, tell the worker it's a relaunch so it can
112
+ // skip the 3s intro pause (the banner still shows — it IS the menu).
113
+ ...(relaunch ? { ICOA_RELAUNCH: '1' } : {}),
114
+ },
115
+ });
116
+ if (res.signal) {
117
+ // Worker killed by a signal (e.g. SIGKILL) — don't relaunch.
118
+ exitCode = 0;
119
+ break;
120
+ }
121
+ exitCode = res.status;
122
+ relaunch = true;
123
+ } while (exitCode === RELAUNCH_CODE);
124
+ process.exit(exitCode ?? 0);
125
+ }
126
+ }
127
+ // Pause for ms, skippable by any key press (so users can read the banner)
128
+ async function pauseWithSkip(ms) {
129
+ const stdin = process.stdin;
130
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
131
+ await new Promise((r) => setTimeout(r, ms));
132
+ return;
133
+ }
134
+ return new Promise((resolve) => {
135
+ let done = false;
136
+ const finish = () => {
137
+ if (done)
138
+ return;
139
+ done = true;
140
+ stdin.removeListener('data', onKey);
141
+ try {
142
+ stdin.setRawMode(false);
143
+ }
144
+ catch { }
145
+ stdin.pause();
146
+ resolve();
147
+ };
148
+ const onKey = () => finish();
149
+ stdin.setRawMode(true);
150
+ stdin.resume();
151
+ stdin.once('data', onKey);
152
+ console.log(chalk.gray(' (press any key to continue...)'));
153
+ setTimeout(finish, ms);
154
+ });
155
+ }
156
+ /**
157
+ * Detect duplicate icoa installations on PATH and warn at startup if the
158
+ * shell is currently resolving to a stale older copy. Catches the
159
+ * "8 versions behind, sudo install changes nothing" failure mode (real
160
+ * incident, 2026-05-05) that happens when npm prefix changed across
161
+ * Node.js install methods (apt vs nodesource vs nvm) without cleaning up
162
+ * previous global installs. Silent when there's only one install (the
163
+ * normal case).
164
+ */
165
+ function warnIfDuplicateInstall() {
166
+ const installs = detectIcoaInstalls();
167
+ if (installs.length <= 1)
168
+ return;
169
+ const first = installs[0];
170
+ // If first is the newest version of all detected installs, the shadowing
171
+ // is benign — the right binary is winning. Skip the warning.
172
+ const versions = installs.map((i) => i.version || '0.0.0');
173
+ const sorted = [...versions].sort((a, b) => cmpSemver(b, a));
174
+ if (sorted[0] === first.version)
175
+ return;
176
+ console.log();
177
+ console.log(chalk.yellow.bold(' ⚠ Multiple icoa installations detected on PATH:'));
178
+ for (let i = 0; i < installs.length; i++) {
179
+ const it = installs[i];
180
+ const verStr = it.version ? `v${it.version}` : '(version unreadable)';
181
+ const arrow = i === 0 ? chalk.yellow('→') : ' ';
182
+ const note = i === 0 ? chalk.gray(' ← currently running (older — shadowing newer install)') : chalk.gray(' ← shadowed');
183
+ console.log(` ${arrow} ${chalk.cyan(it.path.padEnd(28))} ${chalk.white(verStr)}${note}`);
184
+ }
185
+ console.log();
186
+ console.log(chalk.yellow(' The first install on PATH wins. To use the newer one, remove the older:'));
187
+ console.log();
188
+ if (first.pkgDir) {
189
+ console.log(` ${chalk.bold.cyan(`sudo rm -rf ${first.pkgDir} ${first.path}`)}`);
190
+ }
191
+ else {
192
+ console.log(` ${chalk.bold.cyan(`sudo rm -rf ${first.path} # also delete its node_modules dir`)}`);
193
+ }
194
+ console.log();
195
+ console.log(chalk.gray(' Then re-run icoa to confirm version banner shows the newer release.'));
196
+ console.log();
197
+ }
198
+ // Tiny semver comparator — handles 1.2.3 / 10.20.300 etc. but NOT pre-release
199
+ // suffixes (we don't ship those). Returns positive if a > b.
200
+ function cmpSemver(a, b) {
201
+ const pa = a.split('.').map((x) => parseInt(x, 10) || 0);
202
+ const pb = b.split('.').map((x) => parseInt(x, 10) || 0);
203
+ for (let i = 0; i < 3; i++) {
204
+ if ((pa[i] || 0) !== (pb[i] || 0))
205
+ return (pa[i] || 0) - (pb[i] || 0);
206
+ }
207
+ return 0;
208
+ }
209
+ const program = new Command();
210
+ program
211
+ .name('icoa')
212
+ // T4-X2: version now reads live from package.json (PKG_VERSION).
213
+ // Was hardcoded to '1.2.0' which made `icoa --version` misreport on every
214
+ // release — bug since v1.2.0 era, finally fixed.
215
+ .version(PKG_VERSION)
216
+ .description('ICOA CLI — CLI-Native CTF Competition Terminal')
217
+ .option('--resume', 'Resume previous session')
218
+ .action(async (opts) => {
219
+ // Terminal theme — Darcula by default, 'high-contrast' for low-vision users.
220
+ // Variant stored in config.themeVariant; toggled via `theme` REPL command.
221
+ const cfg = getConfig();
222
+ setTerminalTheme(cfg.themeVariant === 'high-contrast' ? 'high-contrast' : 'dark');
223
+ checkForUpdates();
224
+ warnIfDuplicateInstall();
225
+ // T2-7: UTF-8 locale sanity check. Non-UTF-8 terminals mangle the box-
226
+ // drawing banner, the ✓/✗/⚠ glyphs used throughout the CLI, and any
227
+ // Cyrillic/CJK/Arabic/Devanagari/etc. translation text. Warn once at
228
+ // startup (not blocking) so students see a concrete fix hint instead of
229
+ // wondering why "哪个命令..." appears as "????..." on their machine.
230
+ const envLang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '';
231
+ if (!/UTF-?8/i.test(envLang)) {
232
+ if (process.platform === 'win32') {
233
+ // Windows cmd.exe default code page (437 US, 936 CN, etc.) mangles
234
+ // non-Latin script. Fix is `chcp 65001`, not Unix-style `export LANG`.
235
+ // Skip the warning if the code page is already 65001 (PowerShell +
236
+ // Windows Terminal often set it automatically).
237
+ let codepage = '';
238
+ try {
239
+ const { execFileSync } = await import('node:child_process');
240
+ codepage = execFileSync('chcp.com', [], {
241
+ encoding: 'utf-8',
242
+ timeout: 1500,
243
+ stdio: ['ignore', 'pipe', 'ignore'],
244
+ }).trim();
245
+ }
246
+ catch {
247
+ /* chcp.com unavailable or timed out */
248
+ }
249
+ if (!codepage.includes('65001')) {
250
+ console.log(chalk.yellow(`⚠ Windows terminal is not using UTF-8 (current: ${codepage || 'unknown'}).`));
251
+ console.log(chalk.gray(' Non-English text (Ukrainian, Chinese, Japanese, etc.) may show as "?" or garbled glyphs.'));
252
+ console.log(chalk.gray(' Fix (run before ') + chalk.cyan('icoa') + chalk.gray('): ') + chalk.cyan('chcp 65001'));
253
+ console.log(chalk.gray(' Or stay in English inside the CLI: ') + chalk.cyan('lang en'));
254
+ console.log();
255
+ }
256
+ }
257
+ else {
258
+ console.log(chalk.yellow(`⚠ Your terminal locale is not UTF-8 (LANG=${envLang || '(unset)'}).`));
259
+ console.log(chalk.gray(' Non-English text and box characters may display as "?" or garbled glyphs.'));
260
+ console.log(chalk.gray(' Fix: ') +
261
+ chalk.cyan('export LANG=en_US.UTF-8') +
262
+ chalk.gray(' (or your locale, e.g. ') +
263
+ chalk.cyan('zh_CN.UTF-8') +
264
+ chalk.gray(', ') +
265
+ chalk.cyan('uk_UA.UTF-8') +
266
+ chalk.gray(')'));
267
+ console.log();
268
+ }
269
+ }
270
+ console.log(BANNER);
271
+ // If running interactively (no extra args or --resume), start REPL
272
+ if (process.argv.length <= 2 || opts.resume) {
273
+ // Brief pause so users can read the banner + logo before mode selector appears.
274
+ // Skip on --resume (user is coming back) and on a supervisor relaunch
275
+ // (ICOA_RELAUNCH=1 — they just hit `menu`, no need to re-watch the intro).
276
+ if (!opts.resume && process.env.ICOA_RELAUNCH !== '1') {
277
+ await pauseWithSkip(3000);
278
+ }
279
+ startRepl(program, !!opts.resume);
280
+ return;
281
+ }
282
+ });
283
+ registerCtfCommands(program);
284
+ registerRefCommand(program);
285
+ registerShellCommand(program);
286
+ registerFilesCommand(program);
287
+ registerViewCommand(program);
288
+ registerConnectCommand(program);
289
+ registerChallengeCommand(program);
290
+ registerNoteCommand(program);
291
+ registerLogCommand(program);
292
+ registerLangCommand(program);
293
+ registerSetupCommand(program);
294
+ registerEnvCommand(program);
295
+ registerAienvCommand(program);
296
+ registerIpynbCommand(program);
297
+ registerAi4ctfCommand(program);
298
+ registerExamCommand(program);
299
+ registerCtf4aiDemoCommand(program);
300
+ registerThemeCommand(program);
301
+ registerLearnCommand(program);
302
+ registerCtf4VlaCommand(program);
303
+ registerDemo2Command(program);
304
+ registerSimCommand(program);
305
+ registerArenaCommand(program);
306
+ // Hidden command: switch AI model
307
+ program
308
+ .command('model', { hidden: true })
309
+ .argument('[name]', 'model name to switch to')
310
+ .action((name) => {
311
+ const config = getConfig();
312
+ const current = config.geminiModel || 'gemini-2.5-flash-lite';
313
+ if (!name) {
314
+ console.log();
315
+ console.log(chalk.gray(' Current model: ') + chalk.white(current));
316
+ console.log();
317
+ console.log(chalk.gray(' Available models:'));
318
+ console.log(chalk.bold.white(' Gemini 3.x (Latest)'));
319
+ console.log(chalk.white(' model gemini-3.5-flash ') + chalk.gray('Newest stable, paid Tier 1+'));
320
+ console.log(chalk.white(' model gemini-3-flash-preview ') + chalk.gray('Preview'));
321
+ console.log(chalk.bold.white(' Gemini 2.5 (Stable)'));
322
+ console.log(chalk.white(' model gemini-2.5-flash-lite ') + chalk.gray('Fastest, default, free tier'));
323
+ console.log(chalk.white(' model gemini-2.5-flash ') + chalk.gray('Heavier, thinking enabled'));
324
+ console.log(chalk.white(' model gemini-2.5-pro ') + chalk.gray('Strong reasoning, paid'));
325
+ console.log(chalk.bold.white(' Open Source'));
326
+ console.log(chalk.white(' model gemma-4-31b-it ') + chalk.gray('Free, open-source'));
327
+ console.log(chalk.white(' model <any-model-id> ') + chalk.gray('Custom model'));
328
+ console.log();
329
+ console.log(chalk.gray(' Translation uses gemini-3.5-flash for best quality.'));
330
+ console.log();
331
+ }
332
+ else {
333
+ saveConfig({ geminiModel: name });
334
+ console.log();
335
+ console.log(chalk.green(' Model switched: ') + chalk.gray(current) + chalk.white(' -> ') + chalk.bold.white(name));
336
+ console.log();
337
+ }
338
+ });
339
+ // Proctor-only escape hatch: `ICOA_RESET_STATE=1 icoa` wipes any abandoned
340
+ // exam state *before* any subcommand or REPL runs. Fires for ALL invocations
341
+ // (including `icoa exam <token>`) so proctors have a one-liner recovery that
342
+ // works regardless of which subcommand the student would run next.
343
+ // The token itself is untouched server-side.
344
+ if (process.env.ICOA_RESET_STATE === '1') {
345
+ try {
346
+ const { clearExamState } = await import('./lib/exam-state.js');
347
+ clearExamState();
348
+ console.log(chalk.yellow('⚠ ICOA_RESET_STATE=1 — local exam state wiped.'));
349
+ console.log(chalk.gray(' (Token NOT revoked server-side. Re-enter a fresh token with `exam <token>`.)'));
350
+ console.log();
351
+ }
352
+ catch (e) {
353
+ console.log(chalk.red('⚠ ICOA_RESET_STATE: could not clear state — ') + chalk.gray(String(e)));
354
+ }
355
+ }
356
+ program.parse();
@@ -1 +1,184 @@
1
- import{createHash as t,randomUUID as e}from"node:crypto";import{execSync as n}from"node:child_process";import{platform as r}from"node:os";import{getConfig as i,saveConfig as o,getIcoaDir as s}from"./config.js";import{readFileSync as c,writeFileSync as a,existsSync as u}from"node:fs";import{join as f}from"node:path";const p=/^ICOA-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;export function isFirstRunOrUpgrade(t){return i().lastVersion!==t}export function markVersionSeen(t){o({lastVersion:t})}const l=new Set(["ref","help","exit","quit","q","clear","cls","activate"]);export function isActivated(){return!!i().accessToken}export function getDeviceFingerprint(){const s=i();if(s.deviceFingerprint)return s.deviceFingerprint;let a=s.installSalt;a||(a=e(),o({installSalt:a}));const f=function(){try{if("darwin"===r()){const t=n("ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID",{encoding:"utf-8"}).match(/"([A-F0-9-]+)"/);if(t)return t[1]}else if("linux"===r()){if(u("/etc/machine-id"))return c("/etc/machine-id","utf-8").trim()}else if("win32"===r())return n('powershell -Command "(Get-CimInstance Win32_ComputerSystemProduct).UUID"',{encoding:"utf-8"}).trim()}catch{}return""}();return t("sha256").update(`${f}|${a}`).digest("hex")}export function activateToken(e){if(!function(t){return p.test(t.trim().toUpperCase())}(e))return"invalid";const n=getDeviceFingerprint(),r=f(s(),"token-bindings.json");let i={};if(u(r))try{i=JSON.parse(c(r,"utf-8"))}catch{}const l=function(e){return t("sha256").update(e.trim().toUpperCase()).digest("hex")}(e),d=i[l];return d&&d!==n?"already_bound":(i[l]=n,a(r,JSON.stringify(i,null,2)),o({accessToken:e.trim().toUpperCase(),deviceFingerprint:n}),"ok")}export function isDeviceMatch(){const t=i();return!t.deviceFingerprint||t.deviceFingerprint===getDeviceFingerprint()}export function isFreeCommand(t){return l.has(t.toLowerCase())}const d=()=>f(s(),"session-state.json");function m(){return{startedAt:(new Date).toISOString(),lastExitAt:null,lastResumeAt:null,exitCount:0,totalAwaySeconds:0}}function g(){const t=d();if(!u(t))return m();try{return{...m(),...JSON.parse(c(t,"utf-8"))}}catch{return m()}}function x(t){const e={...g(),...t};a(d(),JSON.stringify(e,null,2))}export function recordExit(){const t=g();t.lastExitAt=(new Date).toISOString(),t.exitCount++,x(t)}export function recordResume(){const t=g();if(!t.lastExitAt)return null;const e=new Date(t.lastExitAt).getTime(),n=Date.now(),r=Math.round((n-e)/1e3);return t.lastResumeAt=(new Date).toISOString(),t.totalAwaySeconds+=r,x(t),{awaySeconds:r,exitCount:t.exitCount}}
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { execSync } from 'node:child_process';
3
+ import { platform } from 'node:os';
4
+ import { getConfig, saveConfig, getIcoaDir } from './config.js';
5
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ // Access-token format check. The shipped SHA-256 hash table (~300 valid tokens)
8
+ // was REMOVED 2026-06-21 (B3): it was a redundant-for-security UX gate, and
9
+ // shipping the hashes let anyone enumerate valid tokens offline. The real
10
+ // boundary is server-side — exam/learn/AI all re-validate the token + device
11
+ // against the server's `exam_tokens` table at point of use (token-api.py). So
12
+ // the client now only format-checks; `activate` is a UX unlock, and every
13
+ // privileged action stays server-authoritative. Already-activated users are
14
+ // grandfathered (isActivated() trusts a stored accessToken). See
15
+ // docs/POST_COMPETITION_AUDIT_STANDARD.md §10 (B3).
16
+ const TOKEN_FORMAT = /^ICOA-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
17
+ // Check if first run or version upgrade
18
+ export function isFirstRunOrUpgrade(currentVersion) {
19
+ const config = getConfig();
20
+ return config.lastVersion !== currentVersion;
21
+ }
22
+ export function markVersionSeen(version) {
23
+ saveConfig({ lastVersion: version });
24
+ }
25
+ // Free commands that don't require token
26
+ const FREE_COMMANDS = new Set(['ref', 'help', 'exit', 'quit', 'q', 'clear', 'cls', 'activate']);
27
+ function hashToken(token) {
28
+ return createHash('sha256').update(token.trim().toUpperCase()).digest('hex');
29
+ }
30
+ function validateToken(token) {
31
+ // Format only (B3). A well-formed token unlocks the UI; whether it is a real,
32
+ // unspent, device-matched token is decided server-side at point of use.
33
+ return TOKEN_FORMAT.test(token.trim().toUpperCase());
34
+ }
35
+ export function isActivated() {
36
+ // UX gate only (B3). Grandfather anyone already activated (stored accessToken),
37
+ // else require a format-valid token via activate(). Nothing more to check
38
+ // client-side — the server is authoritative for every privileged action.
39
+ const config = getConfig();
40
+ return !!config.accessToken;
41
+ }
42
+ // Stable hardware identifier: the OS-reported machine UUID. Returns '' when it
43
+ // cannot be read reliably. We deliberately do NOT fall back to a MAC address
44
+ // (the old behaviour): MACs change with network-interface state — Wi-Fi vs
45
+ // ethernet, VPN up/down, USB NICs, macOS private-address randomization — and
46
+ // were the classic source of fingerprint drift. The empty-string fallback is
47
+ // fine because the value is always folded with the persisted install salt and
48
+ // then frozen (see getDeviceFingerprint), so it is only ever read once.
49
+ function _getStableHardwareId() {
50
+ try {
51
+ if (platform() === 'darwin') {
52
+ const out = execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', { encoding: 'utf-8' });
53
+ const match = out.match(/"([A-F0-9-]+)"/);
54
+ if (match)
55
+ return match[1];
56
+ }
57
+ else if (platform() === 'linux') {
58
+ if (existsSync('/etc/machine-id')) {
59
+ return readFileSync('/etc/machine-id', 'utf-8').trim();
60
+ }
61
+ }
62
+ else if (platform() === 'win32') {
63
+ const out = execSync('powershell -Command "(Get-CimInstance Win32_ComputerSystemProduct).UUID"', {
64
+ encoding: 'utf-8',
65
+ });
66
+ return out.trim();
67
+ }
68
+ }
69
+ catch {
70
+ /* hardware UUID unavailable — fall through to the salt-only fingerprint */
71
+ }
72
+ return '';
73
+ }
74
+ // Device fingerprint (NAT-resilient + drift-proof):
75
+ //
76
+ // The Apr-26 Ukraine deployment surfaced a fingerprint *collapse* — students
77
+ // behind a centralized proxy all had identical hostname/platform/arch/MAC at
78
+ // the egress, so 30+ students hashed to one device. v2.19.185 fixed that by
79
+ // mixing a random per-install salt into the hash.
80
+ //
81
+ // This revision fixes the opposite failure — fingerprint *drift*: the salted
82
+ // hash still folded in volatile signals (the MAC fallback, an intermittently
83
+ // readable hardware UUID), so the SAME machine could hash differently between
84
+ // runs and falsely trip the "different device" lockout on re-entry / a second
85
+ // terminal. Fix: (1) the persisted install salt is the stable anchor, (2) only
86
+ // the stable hardware UUID is folded in (never a MAC), and (3) once a
87
+ // fingerprint is stored it is authoritative and returned verbatim — never
88
+ // recomputed. That last rule also preserves backward-compat for every already-
89
+ // bound install (pre-185 salt-less and v185 salted alike): each matches itself.
90
+ export function getDeviceFingerprint() {
91
+ const config = getConfig();
92
+ // Authoritative once bound — return the stored value, never recompute.
93
+ if (config.deviceFingerprint)
94
+ return config.deviceFingerprint;
95
+ // First fingerprint on this install: persisted salt + stable hardware UUID.
96
+ let salt = config.installSalt;
97
+ if (!salt) {
98
+ salt = randomUUID();
99
+ saveConfig({ installSalt: salt });
100
+ }
101
+ const hw = _getStableHardwareId();
102
+ return createHash('sha256').update(`${hw}|${salt}`).digest('hex');
103
+ }
104
+ export function activateToken(token) {
105
+ if (!validateToken(token))
106
+ return 'invalid';
107
+ const fingerprint = getDeviceFingerprint();
108
+ const bindingFile = join(getIcoaDir(), 'token-bindings.json');
109
+ // Load existing bindings
110
+ let bindings = {};
111
+ if (existsSync(bindingFile)) {
112
+ try {
113
+ bindings = JSON.parse(readFileSync(bindingFile, 'utf-8'));
114
+ }
115
+ catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+ const tokenHash = hashToken(token);
120
+ const existingDevice = bindings[tokenHash];
121
+ if (existingDevice && existingDevice !== fingerprint) {
122
+ // Token already bound to a different device
123
+ return 'already_bound';
124
+ }
125
+ // Bind token to this device
126
+ bindings[tokenHash] = fingerprint;
127
+ writeFileSync(bindingFile, JSON.stringify(bindings, null, 2));
128
+ saveConfig({ accessToken: token.trim().toUpperCase(), deviceFingerprint: fingerprint });
129
+ return 'ok';
130
+ }
131
+ export function isDeviceMatch() {
132
+ const config = getConfig();
133
+ if (!config.deviceFingerprint)
134
+ return true; // Legacy: no fingerprint yet
135
+ return config.deviceFingerprint === getDeviceFingerprint();
136
+ }
137
+ export function isFreeCommand(cmd) {
138
+ return FREE_COMMANDS.has(cmd.toLowerCase());
139
+ }
140
+ // Session tracking for anti-cheat
141
+ const SESSION_FILE = () => join(getIcoaDir(), 'session-state.json');
142
+ function getDefaultSession() {
143
+ return {
144
+ startedAt: new Date().toISOString(),
145
+ lastExitAt: null,
146
+ lastResumeAt: null,
147
+ exitCount: 0,
148
+ totalAwaySeconds: 0,
149
+ };
150
+ }
151
+ function getSession() {
152
+ const file = SESSION_FILE();
153
+ if (!existsSync(file))
154
+ return getDefaultSession();
155
+ try {
156
+ return { ...getDefaultSession(), ...JSON.parse(readFileSync(file, 'utf-8')) };
157
+ }
158
+ catch {
159
+ return getDefaultSession();
160
+ }
161
+ }
162
+ function saveSession(session) {
163
+ const current = getSession();
164
+ const merged = { ...current, ...session };
165
+ writeFileSync(SESSION_FILE(), JSON.stringify(merged, null, 2));
166
+ }
167
+ export function recordExit() {
168
+ const session = getSession();
169
+ session.lastExitAt = new Date().toISOString();
170
+ session.exitCount++;
171
+ saveSession(session);
172
+ }
173
+ export function recordResume() {
174
+ const session = getSession();
175
+ if (!session.lastExitAt)
176
+ return null;
177
+ const exitTime = new Date(session.lastExitAt).getTime();
178
+ const now = Date.now();
179
+ const awaySeconds = Math.round((now - exitTime) / 1000);
180
+ session.lastResumeAt = new Date().toISOString();
181
+ session.totalAwaySeconds += awaySeconds;
182
+ saveSession(session);
183
+ return { awaySeconds, exitCount: session.exitCount };
184
+ }