icoa-cli 2.19.349 → 2.19.350

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 (46) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/connect.js +1 -1
  3. package/dist/commands/ctf4ai-demo.js +1 -1
  4. package/dist/commands/ctf4vla.js +1 -1
  5. package/dist/commands/exam.js +1 -1
  6. package/dist/index.js +1 -355
  7. package/dist/lib/access.js +1 -184
  8. package/dist/lib/arena-submit.js +1 -21
  9. package/dist/lib/budget.js +1 -6
  10. package/dist/lib/challenge-dir.js +1 -16
  11. package/dist/lib/comms.js +1 -212
  12. package/dist/lib/config.js +1 -93
  13. package/dist/lib/country-lang.js +1 -39
  14. package/dist/lib/demo-exam.js +1 -478
  15. package/dist/lib/demo-flags.js +1 -27
  16. package/dist/lib/demo-stats.js +1 -62
  17. package/dist/lib/demo2-progress.js +1 -102
  18. package/dist/lib/exam-client.js +1 -54
  19. package/dist/lib/exam-state.js +1 -273
  20. package/dist/lib/gemini.js +1 -247
  21. package/dist/lib/integrity-snapshot.js +1 -88
  22. package/dist/lib/interactive-spawn.js +1 -55
  23. package/dist/lib/ipynb-input.js +1 -65
  24. package/dist/lib/kernel-protocol.js +1 -88
  25. package/dist/lib/kernel.js +2 -146
  26. package/dist/lib/learn-curricula.js +1 -309
  27. package/dist/lib/learn-i18n.js +1 -184
  28. package/dist/lib/log-sync.js +1 -155
  29. package/dist/lib/logger.js +1 -49
  30. package/dist/lib/open-file.js +1 -55
  31. package/dist/lib/paper-upgrade.js +1 -119
  32. package/dist/lib/render-card.js +1 -112
  33. package/dist/lib/repl-asker.js +1 -67
  34. package/dist/lib/sample-runner.js +1 -227
  35. package/dist/lib/shell-split.js +1 -69
  36. package/dist/lib/sim-cooldown.js +1 -75
  37. package/dist/lib/theme.js +1 -119
  38. package/dist/lib/token-format.js +1 -74
  39. package/dist/lib/toolset-hash.js +1 -48
  40. package/dist/lib/translations-fetcher.js +1 -95
  41. package/dist/lib/ui.js +1 -99
  42. package/dist/lib/version.js +1 -24
  43. package/dist/postinstall.js +1 -48
  44. package/dist/repl.js +1 -2251
  45. package/dist/types/index.js +1 -63
  46. package/package.json +1 -1
@@ -1,155 +1 @@
1
- import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { getIcoaDir, getConfig } from './config.js';
4
- import { getDeviceFingerprint } from './access.js';
5
- import { getCliVersion } from './version.js';
6
- // Every 30s, post new lines from ~/.icoa/session.log to the server audit
7
- // endpoint. Two auth modes:
8
- //
9
- // 1. Logged-in user (CTFd token present) → Authorization: Token <token>,
10
- // stored on the server as /opt/CTFd/audit/user-<userId>.jsonl
11
- // 2. Anonymous demo user (no token) → X-Device-Fingerprint: <hash>,
12
- // stored as /opt/CTFd/audit/demo-<fingerprint>.jsonl
13
- //
14
- // The demo path is what lets us observe the full prompt/command history of
15
- // users who never log in (the main demo audience).
16
- //
17
- // V7 fix (v2.19.181): Each entry carries a monotonic `seq` counter that
18
- // only increments (never resets, never decreases). Server stores last seq
19
- // per identity. Deleting session.log + sync-state.json no longer hides
20
- // activity — the seq jump tells the server a chunk was wiped.
21
- const SYNC_INTERVAL = 30_000;
22
- const DEFAULT_SERVER = 'https://practice.icoa2026.au';
23
- const SYNC_STATE_FILE = () => join(getIcoaDir(), 'sync-state.json');
24
- let syncTimer = null;
25
- function getSyncState() {
26
- const file = SYNC_STATE_FILE();
27
- if (!existsSync(file))
28
- return { lastSyncedLine: 0, lastSyncAt: null, syncCount: 0, failCount: 0, seq: 0 };
29
- try {
30
- const parsed = JSON.parse(readFileSync(file, 'utf-8'));
31
- return {
32
- lastSyncedLine: parsed.lastSyncedLine ?? 0,
33
- lastSyncAt: parsed.lastSyncAt ?? null,
34
- syncCount: parsed.syncCount ?? 0,
35
- failCount: parsed.failCount ?? 0,
36
- seq: parsed.seq ?? 0,
37
- };
38
- }
39
- catch {
40
- return { lastSyncedLine: 0, lastSyncAt: null, syncCount: 0, failCount: 0, seq: 0 };
41
- }
42
- }
43
- function saveSyncState(state) {
44
- const f = SYNC_STATE_FILE();
45
- writeFileSync(f, JSON.stringify(state, null, 2));
46
- try {
47
- chmodSync(f, 0o600);
48
- }
49
- catch {
50
- /* ignore Windows / restricted FS */
51
- }
52
- }
53
- async function syncLogs() {
54
- const config = getConfig();
55
- const serverUrl = config.ctfdUrl || DEFAULT_SERVER;
56
- const logPath = join(getIcoaDir(), 'session.log');
57
- if (!existsSync(logPath))
58
- return;
59
- const state = getSyncState();
60
- const allLines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
61
- // V7 monotonic guard: if the log shrank below our last-synced position
62
- // (file was deleted/truncated), our cursor would otherwise stay ahead of
63
- // the new content forever. Detect the shrink and resume from 0, but the
64
- // server-side `seq` counter keeps climbing, so the gap is still evident
65
- // to whoever audits the JSONL.
66
- if (allLines.length < state.lastSyncedLine) {
67
- state.lastSyncedLine = 0;
68
- }
69
- const newLines = allLines.slice(state.lastSyncedLine);
70
- if (newLines.length === 0)
71
- return;
72
- // Pick auth mode: token for logged-in users, device fingerprint for anon.
73
- const headers = { 'Content-Type': 'application/json' };
74
- let identity;
75
- if (config.token) {
76
- headers.Authorization = `Token ${config.token}`;
77
- identity = `user:${config.userId ?? 'unknown'}`;
78
- }
79
- else {
80
- const fp = config.deviceFingerprint || getDeviceFingerprint();
81
- headers['X-Device-Fingerprint'] = fp;
82
- identity = `demo:${fp.slice(0, 12)}`;
83
- }
84
- // Tag each entry with a monotonic seq number. The seq counter lives in
85
- // sync-state.json and only ever increments (V7 fix). If the contestant
86
- // deletes session.log to hide activity, the next batch starts at seq=N+k
87
- // and the server sees the jump.
88
- const baseSeq = state.seq ?? 0;
89
- const taggedEntries = newLines.map((line, idx) => {
90
- let parsed;
91
- try {
92
- parsed = JSON.parse(line);
93
- }
94
- catch {
95
- parsed = { raw: line };
96
- }
97
- return { ...parsed, _seq: baseSeq + idx + 1 };
98
- });
99
- const cliVersion = getCliVersion();
100
- // V2.19.185: stamp every entry with the CLI version. Lets future forensic
101
- // audits answer "which CLI version did this contestant run?" — see the
102
- // Paper E dispatcher post-mortem (cross-country-audit-2026-05-23.pdf §6
103
- // Gap #2). Top-level field also kept for backward search ergonomics.
104
- const entriesWithVersion = taggedEntries.map((e) => ({ ...e, _cli: cliVersion }));
105
- const payload = {
106
- identity,
107
- userId: config.userId,
108
- userName: config.userName,
109
- teamId: config.teamId,
110
- sessionId: config.sessionId,
111
- deviceFingerprint: config.deviceFingerprint || getDeviceFingerprint(),
112
- lang: config.language || 'en',
113
- clientVersion: cliVersion,
114
- timestamp: new Date().toISOString(),
115
- seqRange: { from: baseSeq + 1, to: baseSeq + taggedEntries.length },
116
- entries: entriesWithVersion,
117
- };
118
- try {
119
- const url = new URL('/api/icoa/audit', serverUrl).href;
120
- const res = await fetch(url, {
121
- method: 'POST',
122
- headers,
123
- body: JSON.stringify(payload),
124
- signal: AbortSignal.timeout(10_000),
125
- });
126
- if (res.ok) {
127
- state.lastSyncedLine = allLines.length;
128
- state.lastSyncAt = new Date().toISOString();
129
- state.syncCount++;
130
- state.seq = baseSeq + taggedEntries.length;
131
- saveSyncState(state);
132
- }
133
- else {
134
- state.failCount++;
135
- saveSyncState(state);
136
- }
137
- }
138
- catch {
139
- // Silent fail — network issues shouldn't break the CLI
140
- state.failCount++;
141
- saveSyncState(state);
142
- }
143
- }
144
- export function startLogSync() {
145
- setTimeout(() => syncLogs(), 5_000);
146
- syncTimer = setInterval(() => syncLogs(), SYNC_INTERVAL);
147
- }
148
- export function stopLogSync() {
149
- if (syncTimer) {
150
- clearInterval(syncTimer);
151
- syncTimer = null;
152
- }
153
- // Final sync before exit
154
- syncLogs();
155
- }
1
+ import{readFileSync as t,existsSync as n,writeFileSync as e,chmodSync as s}from"node:fs";import{join as i}from"node:path";import{getIcoaDir as o,getConfig as c}from"./config.js";import{getDeviceFingerprint as r}from"./access.js";import{getCliVersion as a}from"./version.js";const l=()=>i(o(),"sync-state.json");let u=null;function f(t){const n=l();e(n,JSON.stringify(t,null,2));try{s(n,384)}catch{}}async function y(){const e=c(),s=e.ctfdUrl||"https://practice.icoa2026.au",u=i(o(),"session.log");if(!n(u))return;const y=function(){const e=l();if(!n(e))return{lastSyncedLine:0,lastSyncAt:null,syncCount:0,failCount:0,seq:0};try{const n=JSON.parse(t(e,"utf-8"));return{lastSyncedLine:n.lastSyncedLine??0,lastSyncAt:n.lastSyncAt??null,syncCount:n.syncCount??0,failCount:n.failCount??0,seq:n.seq??0}}catch{return{lastSyncedLine:0,lastSyncAt:null,syncCount:0,failCount:0,seq:0}}}(),d=t(u,"utf-8").trim().split("\n").filter(Boolean);d.length<y.lastSyncedLine&&(y.lastSyncedLine=0);const p=d.slice(y.lastSyncedLine);if(0===p.length)return;const S={"Content-Type":"application/json"};let m;if(e.token)S.Authorization=`Token ${e.token}`,m=`user:${e.userId??"unknown"}`;else{const t=e.deviceFingerprint||r();S["X-Device-Fingerprint"]=t,m=`demo:${t.slice(0,12)}`}const g=y.seq??0,h=p.map((t,n)=>{let e;try{e=JSON.parse(t)}catch{e={raw:t}}return{...e,_seq:g+n+1}}),C=a(),I=h.map(t=>({...t,_cli:C})),L={identity:m,userId:e.userId,userName:e.userName,teamId:e.teamId,sessionId:e.sessionId,deviceFingerprint:e.deviceFingerprint||r(),lang:e.language||"en",clientVersion:C,timestamp:(new Date).toISOString(),seqRange:{from:g+1,to:g+h.length},entries:I};try{const t=new URL("/api/icoa/audit",s).href;(await fetch(t,{method:"POST",headers:S,body:JSON.stringify(L),signal:AbortSignal.timeout(1e4)})).ok?(y.lastSyncedLine=d.length,y.lastSyncAt=(new Date).toISOString(),y.syncCount++,y.seq=g+h.length,f(y)):(y.failCount++,f(y))}catch{y.failCount++,f(y)}}export function startLogSync(){setTimeout(()=>y(),5e3),u=setInterval(()=>y(),3e4)}export function stopLogSync(){u&&(clearInterval(u),u=null),y()}
@@ -1,49 +1 @@
1
- import { appendFileSync, readFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { getConfig, getIcoaDir } from './config.js';
4
- function getLogPath() {
5
- return join(getIcoaDir(), 'session.log');
6
- }
7
- function logEntry(entry) {
8
- try {
9
- appendFileSync(getLogPath(), `${JSON.stringify(entry)}\n`);
10
- }
11
- catch {
12
- // Silently fail — logging should never break the CLI
13
- }
14
- }
15
- export function logCommand(command) {
16
- const config = getConfig();
17
- logEntry({
18
- timestamp: new Date().toISOString(),
19
- level: 'command',
20
- input: command,
21
- sessionId: config.sessionId,
22
- });
23
- }
24
- export function logSubmission(challengeId, flag) {
25
- const config = getConfig();
26
- logEntry({
27
- timestamp: new Date().toISOString(),
28
- level: 'submit',
29
- input: flag,
30
- challengeId,
31
- sessionId: config.sessionId,
32
- });
33
- }
34
- export function getSessionLog() {
35
- const logPath = getLogPath();
36
- if (!existsSync(logPath))
37
- return [];
38
- try {
39
- const raw = readFileSync(logPath, 'utf-8');
40
- return raw
41
- .trim()
42
- .split('\n')
43
- .filter(Boolean)
44
- .map((line) => JSON.parse(line));
45
- }
46
- catch {
47
- return [];
48
- }
49
- }
1
+ import{appendFileSync as t,readFileSync as n,existsSync as o}from"node:fs";import{join as e}from"node:path";import{getConfig as i,getIcoaDir as s}from"./config.js";function r(){return e(s(),"session.log")}function m(n){try{t(r(),`${JSON.stringify(n)}\n`)}catch{}}export function logCommand(t){const n=i();m({timestamp:(new Date).toISOString(),level:"command",input:t,sessionId:n.sessionId})}export function logSubmission(t,n){const o=i();m({timestamp:(new Date).toISOString(),level:"submit",input:n,challengeId:t,sessionId:o.sessionId})}export function getSessionLog(){const t=r();if(!o(t))return[];try{return n(t,"utf-8").trim().split("\n").filter(Boolean).map(t=>JSON.parse(t))}catch{return[]}}
@@ -1,55 +1 @@
1
- /**
2
- * Cross-platform "open this file in the OS default app".
3
- *
4
- * Pure core `openFileCommand(path, platform, env)` → {cmd, args} is unit-testable
5
- * across all four baseline targets without spawning; `openFile(path)` is the thin
6
- * spawn wrapper. Extracted from arena.ts so `ipynb` (auto-open saved figures) and
7
- * `arena` (open the downloaded baseline) share ONE opener — notably the WSL branch:
8
- * xdg-open silently fails under WSL, so the file must be handed to Windows via
9
- * explorer.exe — AND the Linux path must be translated to a Windows path first
10
- * (explorer.exe can't resolve `/home/...`), else it "opens" nothing.
11
- */
12
- import { spawn, execFileSync } from 'node:child_process';
13
- /** True when running under WSL. WSL2 sets WSL_DISTRO_NAME; WSL_INTEROP is also
14
- * present and survives some shells/sudo invocations that drop WSL_DISTRO_NAME. */
15
- function isWsl(env) {
16
- return !!(env.WSL_DISTRO_NAME || env.WSL_INTEROP);
17
- }
18
- /** Decide the OS "open" command for `path`. Pure — platform + env are injected
19
- * so every baseline target (macOS / Linux / WSL / Windows) is unit-testable. */
20
- export function openFileCommand(path, platform = process.platform, env = process.env) {
21
- if (platform === 'darwin')
22
- return { cmd: 'open', args: [path] };
23
- if (platform === 'win32')
24
- return { cmd: 'cmd.exe', args: ['/c', 'start', '', path] };
25
- // WSL has no Linux GUI — xdg-open silently fails. Hand the file to Windows.
26
- if (isWsl(env))
27
- return { cmd: 'explorer.exe', args: [path] };
28
- return { cmd: 'xdg-open', args: [path] };
29
- }
30
- /** Open `path` in the OS default app (detached). Returns false if spawn throws. */
31
- export function openFile(path) {
32
- const { cmd, args } = openFileCommand(path);
33
- let finalArgs = args;
34
- // explorer.exe cannot resolve a Linux path (`/home/...`). Translate it to a
35
- // Windows path (`\\wsl.localhost\<distro>\...`) via wslpath so the Windows
36
- // default app actually opens the file. Without this, view "succeeds" silently
37
- // but nothing appears — the WSL bug students hit.
38
- if (cmd === 'explorer.exe') {
39
- try {
40
- const win = execFileSync('wslpath', ['-w', path], { encoding: 'utf-8' }).trim();
41
- if (win)
42
- finalArgs = [win];
43
- }
44
- catch {
45
- /* wslpath unavailable — fall back to the raw path */
46
- }
47
- }
48
- try {
49
- spawn(cmd, finalArgs, { stdio: 'ignore', detached: true }).unref();
50
- return true;
51
- }
52
- catch {
53
- return false;
54
- }
55
- }
1
+ import{spawn as e,execFileSync as r}from"node:child_process";export function openFileCommand(e,r=process.platform,n=process.env){return"darwin"===r?{cmd:"open",args:[e]}:"win32"===r?{cmd:"cmd.exe",args:["/c","start","",e]}:function(e){return!(!e.WSL_DISTRO_NAME&&!e.WSL_INTEROP)}(n)?{cmd:"explorer.exe",args:[e]}:{cmd:"xdg-open",args:[e]}}export function openFile(n){const{cmd:o,args:t}=openFileCommand(n);let c=t;if("explorer.exe"===o)try{const e=r("wslpath",["-w",n],{encoding:"utf-8"}).trim();e&&(c=[e])}catch{}try{return e(o,c,{stdio:"ignore",detached:!0}).unref(),!0}catch{return!1}}
@@ -1,119 +1 @@
1
- /**
2
- * Paper C → Paper B upgrade prompt.
3
- *
4
- * Shown after a successful Paper C submission. Paper C is the K-12 entry
5
- * funnel (MCQ only, no tools). Paper B is the next step — adds AI4CTF
6
- * and CTF4AI practical challenges for 150-point total. The upgrade path
7
- * depends on which platform the student is on.
8
- *
9
- * Policy:
10
- * - Only triggered after Paper C submission (examId ends in `-c` or
11
- * starts with a country code + `-2026-c`).
12
- * - Only triggered if student passed. Students who failed C should
13
- * retry C before being pushed toward a harder paper.
14
- * - Always points at https://icoa2026.au/selectionguide/ for detailed
15
- * platform-specific install instructions, so the CLI copy stays
16
- * concise and the canonical guide lives in one place.
17
- * - Never prescribes a token — students must request one from their
18
- * organizer. This prevents the CLI from implying self-service token
19
- * issuance.
20
- */
21
- import chalk from 'chalk';
22
- import { isNativeWindowsCmd, isInWSL, hasPython } from './platform.js';
23
- /**
24
- * True when the just-submitted exam is a C paper (MCQ entry funnel).
25
- * Matches `ua-2026-c`, `pe-2026-c`, `cn-2026-c`, etc. Case-insensitive.
26
- */
27
- function isCPaper(examId) {
28
- if (!examId)
29
- return false;
30
- return /-2026-c$/i.test(examId.trim());
31
- }
32
- /**
33
- * Renders the C→B upgrade prompt to stdout. No-op if conditions aren't met.
34
- *
35
- * @param examId The submitted exam ID (from state.session.examId before clear)
36
- * @param passed Whether the student met the passing bar
37
- */
38
- export function showCToBUpgradePrompt(examId, passed) {
39
- if (!isCPaper(examId))
40
- return;
41
- if (!passed)
42
- return;
43
- const onCmd = isNativeWindowsCmd();
44
- const onWSL = isInWSL();
45
- const pyReady = hasPython();
46
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
47
- console.log();
48
- console.log(chalk.bold.white(' ★ Ready for more? Paper B — K-12 with AI'));
49
- console.log();
50
- console.log(chalk.gray(' Paper B adds ') +
51
- chalk.white('AI4CTF') +
52
- chalk.gray(' (chat with AI to find hidden flags) and ') +
53
- chalk.white('CTF4AI') +
54
- chalk.gray(' (break AI security).'));
55
- console.log(chalk.gray(' 150 points total · 90 minutes · same command interface you already know.'));
56
- console.log();
57
- if (onCmd) {
58
- // Windows cmd / PowerShell — needs WSL2 path for full Paper B
59
- console.log(chalk.bold.white(' To take Paper B on Windows, install WSL2 + Ubuntu 22:'));
60
- console.log();
61
- console.log(chalk.white(' 1. Open PowerShell as Administrator'));
62
- console.log(chalk.gray(' (right-click PowerShell → "Run as administrator")'));
63
- console.log(chalk.white(' 2. Run: ') + chalk.cyan('wsl --install -d Ubuntu-22.04'));
64
- console.log(chalk.white(' 3. Reboot when prompted, create a Linux username + password'));
65
- console.log(chalk.white(' 4. Inside Ubuntu, install Node.js 22 and this CLI:'));
66
- console.log(chalk.gray(' ') + chalk.cyan('curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -'));
67
- console.log(chalk.gray(' ') + chalk.cyan('sudo apt install -y nodejs'));
68
- console.log(chalk.gray(' ') + chalk.cyan('sudo npm install -g icoa-cli'));
69
- console.log();
70
- console.log(chalk.gray(' Setup takes 30-60 min the first time. You only do this once.'));
71
- }
72
- else if (onWSL) {
73
- // Already on WSL2 — just need Python for practicals
74
- console.log(chalk.bold.white(' You are on WSL2 — your setup is almost ready:'));
75
- console.log();
76
- if (!pyReady) {
77
- console.log(chalk.white(' Install Python 3 (for Paper B practical questions):'));
78
- console.log(chalk.gray(' ') + chalk.cyan('sudo apt install -y python3 python3-pip'));
79
- console.log();
80
- }
81
- else {
82
- console.log(chalk.green(' ✓ Python 3 already installed. You are ready.'));
83
- console.log();
84
- }
85
- }
86
- else if (process.platform === 'darwin') {
87
- // macOS
88
- console.log(chalk.bold.white(' On macOS, install Python 3 for Paper B practicals:'));
89
- console.log();
90
- console.log(chalk.white(' With Homebrew:'));
91
- console.log(chalk.gray(' ') + chalk.cyan('brew install python@3.12'));
92
- console.log();
93
- console.log(chalk.white(' Without Homebrew: download from ') + chalk.cyan.underline('https://python.org'));
94
- if (pyReady) {
95
- console.log();
96
- console.log(chalk.green(' ✓ Python 3 already detected. You are ready.'));
97
- }
98
- console.log();
99
- }
100
- else {
101
- // Linux native (not WSL)
102
- console.log(chalk.bold.white(' On Linux, install Python 3 for Paper B practicals:'));
103
- console.log();
104
- console.log(chalk.white(' Ubuntu / Debian:'));
105
- console.log(chalk.gray(' ') + chalk.cyan('sudo apt install -y python3 python3-pip'));
106
- console.log(chalk.white(' Fedora / RHEL:'));
107
- console.log(chalk.gray(' ') + chalk.cyan('sudo dnf install -y python3 python3-pip'));
108
- if (pyReady) {
109
- console.log();
110
- console.log(chalk.green(' ✓ Python 3 already detected. You are ready.'));
111
- }
112
- console.log();
113
- }
114
- console.log(chalk.bold.white(' Then get a Paper B token from your organizer.'));
115
- console.log(chalk.gray(' Each token is one-shot and bound to one device.'));
116
- console.log();
117
- console.log(chalk.gray(' Detailed step-by-step guide: ') + chalk.cyan.underline('https://icoa2026.au/selectionguide/'));
118
- console.log();
119
- }
1
+ import chalk from"chalk";import{isNativeWindowsCmd as o,isInWSL as e,hasPython as l}from"./platform.js";export function showCToBUpgradePrompt(n,t){if(!function(o){return!!o&&/-2026-c$/i.test(o.trim())}(n))return;if(!t)return;const s=o(),a=e(),r=l();console.log(chalk.cyan(" ─────────────────────────────────────────────")),console.log(),console.log(chalk.bold.white(" ★ Ready for more? Paper B — K-12 with AI")),console.log(),console.log(chalk.gray(" Paper B adds ")+chalk.white("AI4CTF")+chalk.gray(" (chat with AI to find hidden flags) and ")+chalk.white("CTF4AI")+chalk.gray(" (break AI security).")),console.log(chalk.gray(" 150 points total · 90 minutes · same command interface you already know.")),console.log(),s?(console.log(chalk.bold.white(" To take Paper B on Windows, install WSL2 + Ubuntu 22:")),console.log(),console.log(chalk.white(" 1. Open PowerShell as Administrator")),console.log(chalk.gray(' (right-click PowerShell → "Run as administrator")')),console.log(chalk.white(" 2. Run: ")+chalk.cyan("wsl --install -d Ubuntu-22.04")),console.log(chalk.white(" 3. Reboot when prompted, create a Linux username + password")),console.log(chalk.white(" 4. Inside Ubuntu, install Node.js 22 and this CLI:")),console.log(chalk.gray(" ")+chalk.cyan("curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -")),console.log(chalk.gray(" ")+chalk.cyan("sudo apt install -y nodejs")),console.log(chalk.gray(" ")+chalk.cyan("sudo npm install -g icoa-cli")),console.log(),console.log(chalk.gray(" Setup takes 30-60 min the first time. You only do this once."))):a?(console.log(chalk.bold.white(" You are on WSL2 — your setup is almost ready:")),console.log(),r?(console.log(chalk.green(" ✓ Python 3 already installed. You are ready.")),console.log()):(console.log(chalk.white(" Install Python 3 (for Paper B practical questions):")),console.log(chalk.gray(" ")+chalk.cyan("sudo apt install -y python3 python3-pip")),console.log())):"darwin"===process.platform?(console.log(chalk.bold.white(" On macOS, install Python 3 for Paper B practicals:")),console.log(),console.log(chalk.white(" With Homebrew:")),console.log(chalk.gray(" ")+chalk.cyan("brew install python@3.12")),console.log(),console.log(chalk.white(" Without Homebrew: download from ")+chalk.cyan.underline("https://python.org")),r&&(console.log(),console.log(chalk.green(" ✓ Python 3 already detected. You are ready."))),console.log()):(console.log(chalk.bold.white(" On Linux, install Python 3 for Paper B practicals:")),console.log(),console.log(chalk.white(" Ubuntu / Debian:")),console.log(chalk.gray(" ")+chalk.cyan("sudo apt install -y python3 python3-pip")),console.log(chalk.white(" Fedora / RHEL:")),console.log(chalk.gray(" ")+chalk.cyan("sudo dnf install -y python3 python3-pip")),r&&(console.log(),console.log(chalk.green(" ✓ Python 3 already detected. You are ready."))),console.log()),console.log(chalk.bold.white(" Then get a Paper B token from your organizer.")),console.log(chalk.gray(" Each token is one-shot and bound to one device.")),console.log(),console.log(chalk.gray(" Detailed step-by-step guide: ")+chalk.cyan.underline("https://icoa2026.au/selectionguide/")),console.log()}
@@ -1,112 +1 @@
1
- /**
2
- * Chart rendering helper — pulls ASCII chart art out of a Curriculum.charts
3
- * lookup and prints it between a card's title and body.
4
- *
5
- * Design contract: [[reference-chart-lang-neutral]]
6
- * - Fixed slot order: title → charts → body
7
- * - Charts are English-only and never translated; 22 languages share one art.
8
- * - Card stores `chart_ids: string[]`; art lives in `curriculum.charts[id]`.
9
- *
10
- * Capability picker (pilot scope = T0 / T1):
11
- * - T0 pure-ASCII fallback — picked when LANG=C / non-UTF-8 / chart wider than
12
- * available columns / explicit user opt-out
13
- * - T1 Unicode-safe default — used on the 4 baseline terminals (macOS Terminal,
14
- * gnome-terminal, WSL2 Windows Terminal, Chromebook
15
- * Crostini); see CLAUDE.md cross-platform baseline.
16
- *
17
- * Output respects the learn-render frame style: each line gets a `│ ` left
18
- * border so charts visually sit inside the same three-sided frame as body text.
19
- */
20
- import chalk from 'chalk';
21
- export function detectCaps() {
22
- const cols = process.stdout.columns || 80;
23
- const lang = process.env.LANG || process.env.LC_ALL || '';
24
- const utf8 = /utf-?8/i.test(lang) || lang === '' || lang === 'C.UTF-8';
25
- // ICOA_RENDER=ascii forces T0 (also documented in [[reference-rendering-tiers]]).
26
- const override = (process.env.ICOA_RENDER || '').toLowerCase();
27
- if (override === 'ascii' || lang === 'C' || lang === 'POSIX' || !utf8) {
28
- return { tier: 'T0', cols, utf8: false };
29
- }
30
- return { tier: 'T1', cols, utf8 };
31
- }
32
- function pickArt(chart, caps) {
33
- // Width fit: if T1 art is wider than the terminal, fall back to T0 — same
34
- // idea as the spike-rich-display fallback.
35
- if (caps.tier === 'T0' && chart.art_t0)
36
- return chart.art_t0;
37
- if (chart.width_cols && chart.width_cols > caps.cols - 6 && chart.art_t0) {
38
- return chart.art_t0;
39
- }
40
- return chart.art ?? chart.art_t0 ?? '';
41
- }
42
- // Resolve a (possibly dotted, e.g. "bold.green") chalk colour spec into a
43
- // styling function. Falls back to gray for unknown specs.
44
- function chalkChain(spec) {
45
- try {
46
- const fn = spec.split('.').reduce((acc, part) => acc[part], chalk);
47
- return typeof fn === 'function' ? fn : chalk.gray;
48
- }
49
- catch {
50
- return chalk.gray;
51
- }
52
- }
53
- // Apply a char→colour map to one art line. Unmapped chars (and spaces) stay
54
- // gray — matches the pre-color_map look. ANSI escapes are zero-width so this
55
- // never disturbs alignment. Caller gates on chalk.level > 0.
56
- function colorizeArtLine(line, colorMap) {
57
- let out = '';
58
- for (const ch of line) {
59
- const spec = ch === ' ' ? undefined : colorMap[ch];
60
- out += spec ? chalkChain(spec)(ch) : chalk.gray(ch);
61
- }
62
- return out;
63
- }
64
- /**
65
- * Render chart art for a list of chart_ids. Each chart prints on its own
66
- * "block" with a blank separator line above. Lines are left-padded with the
67
- * three-sided-frame left bar to match learn-render body style.
68
- *
69
- * Pass `frameLeft = false` for standalone (non-card-frame) contexts.
70
- */
71
- export function renderCharts(chartIds, charts, caps = detectCaps(), opts = {}) {
72
- if (!chartIds || chartIds.length === 0)
73
- return;
74
- if (!charts)
75
- return;
76
- const frameLeft = opts.frameLeft !== false;
77
- const leftBar = frameLeft ? chalk.cyan(' │ ') : ' ';
78
- const leftBarEmpty = frameLeft ? chalk.cyan(' │') : '';
79
- let rendered = 0;
80
- for (const id of chartIds) {
81
- const chart = charts[id];
82
- if (!chart)
83
- continue; // silent miss — server denormalisation may have lagged
84
- // Code-as-insight (6th genre): monospace CODE-LOCK render — cyan, no wrap,
85
- // no width-fit (≤~20 ASCII lines, English comments → renders everywhere).
86
- if (chart.type === 'code') {
87
- const code = chart.code ?? '';
88
- if (code.trim() === '')
89
- continue;
90
- console.log(leftBarEmpty);
91
- for (const line of code.split('\n')) {
92
- console.log(leftBar + chalk.cyan(line));
93
- }
94
- rendered++;
95
- continue;
96
- }
97
- // Chart genres: pick T0/T1 art, then optionally apply color_map (render
98
- // layer only, after selection — escapes stay zero-width). Mono terminals
99
- // (chalk.level === 0, e.g. NO_COLOR / piped) skip colour automatically.
100
- const art = pickArt(chart, caps);
101
- if (art === '')
102
- continue;
103
- const useColor = chalk.level > 0 && chart.color_map && Object.keys(chart.color_map).length > 0;
104
- console.log(leftBarEmpty); // blank line above each chart
105
- for (const line of art.split('\n')) {
106
- console.log(leftBar + (useColor ? colorizeArtLine(line, chart.color_map) : chalk.gray(line)));
107
- }
108
- rendered++;
109
- }
110
- if (rendered > 0)
111
- console.log(leftBarEmpty); // single trailing blank
112
- }
1
+ import chalk from"chalk";export function detectCaps(){const t=process.stdout.columns||80,o=process.env.LANG||process.env.LC_ALL||"",n=/utf-?8/i.test(o)||""===o||"C.UTF-8"===o;return"ascii"!==(process.env.ICOA_RENDER||"").toLowerCase()&&"C"!==o&&"POSIX"!==o&&n?{tier:"T1",cols:t,utf8:n}:{tier:"T0",cols:t,utf8:!1}}function t(t,o){return"T0"===o.tier&&t.art_t0||t.width_cols&&t.width_cols>o.cols-6&&t.art_t0?t.art_t0:t.art??t.art_t0??""}function o(t){try{const o=t.split(".").reduce((t,o)=>t[o],chalk);return"function"==typeof o?o:chalk.gray}catch{return chalk.gray}}function n(t,n){let c="";for(const e of t){const t=" "===e?void 0:n[e];c+=t?o(t)(e):chalk.gray(e)}return c}export function renderCharts(o,c,e=detectCaps(),r={}){if(!o||0===o.length)return;if(!c)return;const s=!1!==r.frameLeft,i=s?chalk.cyan(" │ "):" ",l=s?chalk.cyan(" │"):"";let f=0;for(const r of o){const o=c[r];if(!o)continue;if("code"===o.type){const t=o.code??"";if(""===t.trim())continue;console.log(l);for(const o of t.split("\n"))console.log(i+chalk.cyan(o));f++;continue}const s=t(o,e);if(""===s)continue;const a=chalk.level>0&&o.color_map&&Object.keys(o.color_map).length>0;console.log(l);for(const t of s.split("\n"))console.log(i+(a?n(t,o.color_map):chalk.gray(t)));f++}f>0&&console.log(l)}
@@ -1,67 +1 @@
1
- /**
2
- * BUG-008-safe `question()`-style asker for standalone Commander sub-flows that
3
- * are ALSO dispatched bare from inside the main REPL (e.g. `arena`, `eai`).
4
- *
5
- * The problem: the main REPL holds a `terminal: true` readline.Interface on
6
- * process.stdin. A sub-flow that creates its OWN `createInterface` makes a SECOND
7
- * interface read stdin — both echo every keystroke ("a" → "aa") and both fire
8
- * 'line'. `pause()` does NOT help (the new rl's constructor calls resume()).
9
- *
10
- * The fix (listener-swap, per CLAUDE.md BUG-008): when a main REPL rl exists,
11
- * REUSE it — detach its 'line' listeners, drive it ourselves question-by-question
12
- * with one-shot listeners, then restore the saved listeners on close. No second
13
- * readline is ever created inside the REPL. When there is no main rl (the flow
14
- * was launched as `icoa <cmd>` from the system shell), create a standalone one.
15
- *
16
- * The asker resolves to `null` when the stream closes (EOF / Ctrl-D) so callers
17
- * can exit cleanly instead of crashing on "readline was closed".
18
- */
19
- import { Interface as ReadlineInterface, createInterface } from 'node:readline';
20
- import { getMainRl } from './main-rl.js';
21
- export function makeReplAsker() {
22
- const mainRl = getMainRl();
23
- const usingMainRl = mainRl !== null;
24
- const savedMainListeners = usingMainRl
25
- ? mainRl.listeners('line').slice()
26
- : [];
27
- if (usingMainRl)
28
- mainRl.removeAllListeners('line');
29
- const rl = usingMainRl
30
- ? mainRl
31
- : createInterface({ input: process.stdin, output: process.stdout });
32
- // The main REPL monkey-patches rl.prompt() (src/repl.ts) to force
33
- // computePrompt() for any mode it doesn't recognize — which would clobber our
34
- // sub-flow prompt, showing e.g. `icoa 2026>` instead of `icoa ctf4eai>`. Swap
35
- // in the NATIVE readline prompt for the session so our setPrompt() sticks, and
36
- // restore the patched one on close() so the REPL prompt behaves again after.
37
- const savedPrompt = usingMainRl ? rl.prompt : null;
38
- if (usingMainRl)
39
- rl.prompt = ReadlineInterface.prototype.prompt;
40
- const ask = (q) => new Promise((resolve) => {
41
- const onLine = (line) => {
42
- rl.removeListener('close', onClose);
43
- resolve(line);
44
- };
45
- const onClose = () => {
46
- rl.removeListener('line', onLine);
47
- resolve(null);
48
- };
49
- rl.once('line', onLine);
50
- rl.once('close', onClose);
51
- rl.setPrompt(q);
52
- rl.prompt();
53
- });
54
- const close = () => {
55
- if (usingMainRl) {
56
- if (savedPrompt)
57
- mainRl.prompt = savedPrompt;
58
- mainRl.removeAllListeners('line');
59
- for (const l of savedMainListeners)
60
- mainRl.on('line', l);
61
- }
62
- else {
63
- rl.close();
64
- }
65
- };
66
- return { ask, close };
67
- }
1
+ import{Interface as e,createInterface as o}from"node:readline";import{getMainRl as n}from"./main-rl.js";export function makeReplAsker(){const r=n(),s=null!==r,t=s?r.listeners("line").slice():[];s&&r.removeAllListeners("line");const l=s?r:o({input:process.stdin,output:process.stdout}),i=s?l.prompt:null;return s&&(l.prompt=e.prototype.prompt),{ask:e=>new Promise(o=>{const n=e=>{l.removeListener("close",r),o(e)},r=()=>{l.removeListener("line",n),o(null)};l.once("line",n),l.once("close",r),l.setPrompt(e),l.prompt()}),close:()=>{if(s){i&&(r.prompt=i),r.removeAllListeners("line");for(const e of t)r.on("line",e)}else l.close()}}}