icoa-cli 2.19.349 → 2.19.351
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/dist/commands/ai4ctf.js +1 -1
- package/dist/commands/connect.js +1 -1
- package/dist/commands/ctf.js +1 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/exam.js +1 -1
- package/dist/index.js +1 -355
- package/dist/lib/access.js +1 -184
- package/dist/lib/arena-submit.js +1 -21
- package/dist/lib/budget.js +1 -6
- package/dist/lib/challenge-dir.js +1 -16
- package/dist/lib/comms.js +1 -212
- package/dist/lib/config.js +1 -93
- package/dist/lib/country-lang.js +1 -39
- package/dist/lib/demo-exam.js +1 -478
- package/dist/lib/demo-flags.js +1 -27
- package/dist/lib/demo-stats.js +1 -62
- package/dist/lib/demo2-progress.js +1 -102
- package/dist/lib/exam-client.js +1 -54
- package/dist/lib/exam-state.js +1 -273
- package/dist/lib/gemini.js +1 -247
- package/dist/lib/integrity-snapshot.js +1 -88
- package/dist/lib/interactive-spawn.js +1 -55
- package/dist/lib/ipynb-input.js +1 -65
- package/dist/lib/kernel-protocol.js +1 -88
- package/dist/lib/kernel.js +2 -146
- package/dist/lib/learn-curricula.js +1 -309
- package/dist/lib/learn-i18n.js +1 -184
- package/dist/lib/log-sync.js +1 -155
- package/dist/lib/logger.js +1 -49
- package/dist/lib/open-file.js +1 -55
- package/dist/lib/paper-upgrade.js +1 -119
- package/dist/lib/render-card.js +1 -112
- package/dist/lib/repl-asker.js +1 -67
- package/dist/lib/sample-runner.js +1 -227
- package/dist/lib/shell-split.js +1 -69
- package/dist/lib/sim-cooldown.js +1 -75
- package/dist/lib/theme.js +1 -119
- package/dist/lib/token-format.js +1 -74
- package/dist/lib/toolset-hash.js +1 -48
- package/dist/lib/translations-fetcher.js +1 -95
- package/dist/lib/ui.js +1 -99
- package/dist/lib/version.js +1 -24
- package/dist/postinstall.js +1 -48
- package/dist/repl.js +1 -2251
- package/dist/types/index.js +1 -63
- package/package.json +1 -1
package/dist/lib/log-sync.js
CHANGED
|
@@ -1,155 +1 @@
|
|
|
1
|
-
import {
|
|
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()}
|
package/dist/lib/logger.js
CHANGED
|
@@ -1,49 +1 @@
|
|
|
1
|
-
import
|
|
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[]}}
|
package/dist/lib/open-file.js
CHANGED
|
@@ -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()}
|
package/dist/lib/render-card.js
CHANGED
|
@@ -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)}
|
package/dist/lib/repl-asker.js
CHANGED
|
@@ -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()}}}
|