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.
- package/dist/commands/ai4ctf.js +1 -1
- package/dist/commands/ctf.js +787 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/demo2.js +1502 -1
- package/dist/commands/exam.js +1 -1
- package/dist/commands/files.js +59 -1
- package/dist/commands/lang.js +202 -1
- package/dist/commands/log.js +171 -1
- package/dist/commands/shell.js +151 -1
- package/dist/commands/sim.js +389 -1
- package/dist/index.js +355 -1
- package/dist/lib/access.js +184 -1
- package/dist/lib/aienv.js +205 -1
- package/dist/lib/arena-submit.js +21 -1
- package/dist/lib/banner.js +31 -1
- package/dist/lib/budget.js +6 -1
- package/dist/lib/challenge-dir.js +16 -1
- package/dist/lib/colors.js +17 -1
- package/dist/lib/comms.js +212 -1
- package/dist/lib/config.js +93 -1
- package/dist/lib/countdown.js +43 -1
- package/dist/lib/country-lang.js +39 -1
- package/dist/lib/ctfd-client.js +417 -1
- package/dist/lib/demo-exam.js +478 -1
- package/dist/lib/demo-flags.js +27 -1
- package/dist/lib/demo-stats.js +62 -1
- package/dist/lib/demo2-progress.js +102 -1
- package/dist/lib/docker-probe.d.ts +45 -0
- package/dist/lib/docker-probe.js +118 -0
- package/dist/lib/editor-spawn.js +53 -1
- package/dist/lib/exam-client.js +54 -1
- package/dist/lib/exam-sandbox.js +201 -1
- package/dist/lib/exam-setup.js +36 -1
- package/dist/lib/exam-state.js +273 -1
- package/dist/lib/gemini.js +247 -1
- package/dist/lib/i18n.js +302 -1
- package/dist/lib/integrity-snapshot.js +88 -1
- package/dist/lib/interactive-spawn.js +55 -1
- package/dist/lib/ipynb-input.js +65 -1
- package/dist/lib/kernel-protocol.js +88 -1
- package/dist/lib/kernel.js +146 -2
- package/dist/lib/learn-curricula.js +309 -1
- package/dist/lib/learn-i18n.js +184 -1
- package/dist/lib/learn-input.js +101 -1
- package/dist/lib/learn-render.js +863 -1
- package/dist/lib/learn-state.js +103 -1
- package/dist/lib/log-sync.js +155 -1
- package/dist/lib/logger.js +49 -1
- package/dist/lib/main-rl.js +7 -1
- package/dist/lib/menu-nav.js +105 -1
- package/dist/lib/notebook-doc.js +137 -1
- package/dist/lib/open-file.js +55 -1
- package/dist/lib/paper-upgrade.js +119 -1
- package/dist/lib/platform.js +99 -1
- package/dist/lib/render-card.js +112 -1
- package/dist/lib/repl-asker.js +67 -1
- package/dist/lib/sample-runner.js +227 -1
- package/dist/lib/sandbox.d.ts +7 -1
- package/dist/lib/sandbox.js +144 -1
- package/dist/lib/shell-split.js +69 -1
- package/dist/lib/sim-cooldown.js +75 -1
- package/dist/lib/theme.js +119 -1
- package/dist/lib/token-format.js +74 -1
- package/dist/lib/tool-man.js +418 -1
- package/dist/lib/toolset-hash.js +48 -1
- package/dist/lib/translation.js +80 -1
- package/dist/lib/translations-fetcher.js +95 -1
- package/dist/lib/ui.js +99 -1
- package/dist/lib/update-check.js +114 -1
- package/dist/lib/version.js +24 -1
- package/dist/postinstall.js +48 -1
- package/dist/repl.js +2391 -1
- package/dist/types/index.js +63 -1
- package/package.json +1 -1
package/dist/lib/learn-state.js
CHANGED
|
@@ -1 +1,103 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Learn-mode state persistence.
|
|
3
|
+
*
|
|
4
|
+
* Local file: ~/.icoa/learn-state.json
|
|
5
|
+
* Schema designed to mirror server-side learn_progress table for Phase 1 sync.
|
|
6
|
+
*
|
|
7
|
+
* Unlike exam state, learn state ENCOURAGES multi-device: same token can be
|
|
8
|
+
* resumed on any machine (Phase 1 server-sync), no device-hash binding.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
const STATE_DIR = join(homedir(), '.icoa');
|
|
14
|
+
const STATE_PATH = join(STATE_DIR, 'learn-state.json');
|
|
15
|
+
function ensureDir() {
|
|
16
|
+
if (!existsSync(STATE_DIR))
|
|
17
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
export function loadLearnState() {
|
|
20
|
+
if (!existsSync(STATE_PATH))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(STATE_PATH, 'utf-8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function saveLearnState(state) {
|
|
30
|
+
ensureDir();
|
|
31
|
+
state.lastSeenAt = new Date().toISOString();
|
|
32
|
+
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
|
|
33
|
+
}
|
|
34
|
+
export function newLearnState(token, curriculumId, totalCards) {
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
return {
|
|
37
|
+
token,
|
|
38
|
+
curriculumId,
|
|
39
|
+
currentCard: 1,
|
|
40
|
+
totalCards,
|
|
41
|
+
startedAt: now,
|
|
42
|
+
lastSeenAt: now,
|
|
43
|
+
streakDays: 1,
|
|
44
|
+
longestStreak: 1,
|
|
45
|
+
cardsCompleted: [],
|
|
46
|
+
mcqResults: {},
|
|
47
|
+
checkResults: {},
|
|
48
|
+
practicalsCompleted: [],
|
|
49
|
+
bookmarks: [],
|
|
50
|
+
achievements: [],
|
|
51
|
+
totalSecondsActive: 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Update streak based on last-seen-at vs today.
|
|
56
|
+
* - Same day: streak unchanged
|
|
57
|
+
* - Next day: streak + 1
|
|
58
|
+
* - Gap > 1 day: streak resets to 1
|
|
59
|
+
*/
|
|
60
|
+
export function updateStreak(state) {
|
|
61
|
+
const last = new Date(state.lastSeenAt);
|
|
62
|
+
const now = new Date();
|
|
63
|
+
// Compare calendar days (UTC for simplicity)
|
|
64
|
+
const lastDay = Math.floor(last.getTime() / 86400000);
|
|
65
|
+
const nowDay = Math.floor(now.getTime() / 86400000);
|
|
66
|
+
const diff = nowDay - lastDay;
|
|
67
|
+
if (diff === 0)
|
|
68
|
+
return;
|
|
69
|
+
if (diff === 1) {
|
|
70
|
+
state.streakDays += 1;
|
|
71
|
+
if (state.streakDays > state.longestStreak)
|
|
72
|
+
state.longestStreak = state.streakDays;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
state.streakDays = 1; // reset
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function markCardComplete(state, cardNumber) {
|
|
79
|
+
// Records completion only — does NOT advance currentCard. The REPL's `ok`
|
|
80
|
+
// handler is the sole place that advances, to keep card flow predictable
|
|
81
|
+
// (no double-jumps from card N to N+2).
|
|
82
|
+
if (!state.cardsCompleted.includes(cardNumber)) {
|
|
83
|
+
state.cardsCompleted.push(cardNumber);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function recordMCQ(state, cardNumber, result) {
|
|
87
|
+
state.mcqResults[String(cardNumber)] = result;
|
|
88
|
+
}
|
|
89
|
+
export function recordCheck(state, cardNumber, result) {
|
|
90
|
+
if (!state.checkResults)
|
|
91
|
+
state.checkResults = {};
|
|
92
|
+
state.checkResults[String(cardNumber)] = result;
|
|
93
|
+
}
|
|
94
|
+
export function markPracticalComplete(state, cardNumber) {
|
|
95
|
+
if (!state.practicalsCompleted.includes(cardNumber)) {
|
|
96
|
+
state.practicalsCompleted.push(cardNumber);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function addAchievement(state, badge) {
|
|
100
|
+
if (!state.achievements.includes(badge)) {
|
|
101
|
+
state.achievements.push(badge);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/lib/log-sync.js
CHANGED
|
@@ -1 +1,155 @@
|
|
|
1
|
-
import{readFileSync
|
|
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
|
+
}
|
package/dist/lib/logger.js
CHANGED
|
@@ -1 +1,49 @@
|
|
|
1
|
-
import{appendFileSync
|
|
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
|
+
}
|
package/dist/lib/main-rl.js
CHANGED
package/dist/lib/menu-nav.js
CHANGED
|
@@ -1 +1,105 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Global "back to boot screen" navigation.
|
|
3
|
+
*
|
|
4
|
+
* The CLI has several sub-REPLs (ai4ctf, ctf4ai, ctf4vla, learn, demo2,
|
|
5
|
+
* exam REPL inside Selection mode) that own input handling once entered.
|
|
6
|
+
* `menu` is the universal escape hatch: from anywhere in the CLI, typing
|
|
7
|
+
* it returns the user to the launch banner + mode-selector picker.
|
|
8
|
+
*
|
|
9
|
+
* Implementation: re-execs the current node binary with the same argv,
|
|
10
|
+
* inheriting stdio. The new process re-runs startRepl from the top, which
|
|
11
|
+
* always shows the mode selector. The parent exits with the child's code.
|
|
12
|
+
*
|
|
13
|
+
* For real-exam state, the caller should gate via `menu confirm` to avoid
|
|
14
|
+
* surprise exits during a graded session. Exam progress is auto-saved on
|
|
15
|
+
* every answer, so even an unconfirmed exit is non-destructive — the
|
|
16
|
+
* confirmation is purely UX hygiene.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
/**
|
|
21
|
+
* Exit code the worker uses to ask the supervisor (in index.ts) to relaunch a
|
|
22
|
+
* fresh CLI process. Picked from the unreserved 64–113 sysexits range so it
|
|
23
|
+
* can't collide with a normal `process.exit(0|1)`. See index.ts for the
|
|
24
|
+
* supervisor loop that consumes it.
|
|
25
|
+
*/
|
|
26
|
+
export const RELAUNCH_CODE = 75;
|
|
27
|
+
/**
|
|
28
|
+
* One-line standalone hint for sub-REPLs and mode landing screens.
|
|
29
|
+
* Prints a friendly cue that `menu` is always available.
|
|
30
|
+
*/
|
|
31
|
+
export function printMenuHint() {
|
|
32
|
+
console.log(chalk.gray(' 💡 Type ') +
|
|
33
|
+
chalk.bold.cyan('menu') +
|
|
34
|
+
chalk.gray(' anytime to return to the main menu (works from any screen)'));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Inline fragment for embedding in existing Tip / footer lines.
|
|
38
|
+
* Returns a chalk-styled string like: menu returns home
|
|
39
|
+
* so callers can splice it with ` · ` separators.
|
|
40
|
+
*/
|
|
41
|
+
export function menuHintInline() {
|
|
42
|
+
return chalk.cyan('menu') + chalk.gray(' returns home');
|
|
43
|
+
}
|
|
44
|
+
export function returnToMainMenu(rl) {
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(chalk.gray(' Returning to main menu...'));
|
|
47
|
+
console.log();
|
|
48
|
+
// Critical: the parent REPL registers an rl.on('close') handler that
|
|
49
|
+
// calls realExit(0). If we close rl with that handler still attached,
|
|
50
|
+
// the parent process dies BEFORE the child finishes starting — user
|
|
51
|
+
// ends up back at their shell instead of at the mode selector.
|
|
52
|
+
if (rl?.removeAllListeners) {
|
|
53
|
+
try {
|
|
54
|
+
rl.removeAllListeners('close');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Not an EventEmitter — fine
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (rl) {
|
|
61
|
+
try {
|
|
62
|
+
rl.close();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Already closed — fine
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Restore cooked terminal mode before we exit/respawn so the next process
|
|
69
|
+
// inherits a clean TTY. Without this a lingering raw-mode flag from this
|
|
70
|
+
// process's readline/inquirer could corrupt keystroke handling in the fresh
|
|
71
|
+
// boot (was a contributor to the "learn → lan" dropped-character bug).
|
|
72
|
+
try {
|
|
73
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
74
|
+
process.stdin.setRawMode(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Not a TTY / no raw mode — fine.
|
|
79
|
+
}
|
|
80
|
+
// Preferred path: a supervisor process (index.ts) is wrapping this worker.
|
|
81
|
+
// Exit with the relaunch sentinel and let the supervisor start exactly ONE
|
|
82
|
+
// fresh process. This replaces the old spawn-and-stay-alive approach, where
|
|
83
|
+
// every `menu` spawned a child while the parent kept running — after K menu
|
|
84
|
+
// bounces you had K+1 node processes all holding the inherited TTY, which
|
|
85
|
+
// slowed the CLI to a crawl and dropped keystrokes (a 4-deep process chain
|
|
86
|
+
// was observed in prod, 2026-06-04). With the supervisor, only one worker is
|
|
87
|
+
// ever live, so navigation stays fast no matter how many times you bounce.
|
|
88
|
+
if (process.env.ICOA_SUPERVISED === '1') {
|
|
89
|
+
process.exit(RELAUNCH_CODE);
|
|
90
|
+
}
|
|
91
|
+
// Fallback (no supervisor — e.g. a non-TTY/piped run, or a one-shot
|
|
92
|
+
// invocation the supervisor skips): keep the original respawn so behavior is
|
|
93
|
+
// unchanged for any path the supervisor doesn't cover. stdio:'inherit'
|
|
94
|
+
// shares the TTY; the parent waits on the child's exit to keep the OS TTY
|
|
95
|
+
// ownership chain intact.
|
|
96
|
+
const child = spawn(process.argv[0], process.argv.slice(1), {
|
|
97
|
+
stdio: 'inherit',
|
|
98
|
+
env: process.env,
|
|
99
|
+
});
|
|
100
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
101
|
+
child.on('error', (err) => {
|
|
102
|
+
console.error(chalk.yellow(` Could not respawn CLI: ${err.message}`));
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|
|
105
|
+
}
|
package/dist/lib/notebook-doc.js
CHANGED
|
@@ -1 +1,137 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* notebook-doc — the pure cell model behind `icoa ipynb`'s cell-stepper
|
|
3
|
+
* (cells / show / edit / run / save). nbformat v4 is plain JSON, so parse and
|
|
4
|
+
* serialize live here with zero dependencies; keeping the module pure (no I/O,
|
|
5
|
+
* no chalk) means the whole document lifecycle is unit-tested without a kernel.
|
|
6
|
+
*
|
|
7
|
+
* Round-trip safety: cells loaded from disk keep their original outputs in
|
|
8
|
+
* `rawOutputs` and are written back verbatim on save unless the cell was
|
|
9
|
+
* re-run this session (then the fresh FoldedCell result wins).
|
|
10
|
+
*/
|
|
11
|
+
/** Parse a .ipynb (nbformat v4) JSON string. Raw cells are dropped. */
|
|
12
|
+
export function parseIpynb(json) {
|
|
13
|
+
let obj;
|
|
14
|
+
try {
|
|
15
|
+
obj = JSON.parse(json);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error('not valid JSON');
|
|
19
|
+
}
|
|
20
|
+
const cellsRaw = obj?.cells;
|
|
21
|
+
if (!Array.isArray(cellsRaw))
|
|
22
|
+
throw new Error('not a Jupyter notebook (no cells array)');
|
|
23
|
+
const cells = [];
|
|
24
|
+
for (const c of cellsRaw) {
|
|
25
|
+
if (!c || typeof c !== 'object')
|
|
26
|
+
continue;
|
|
27
|
+
const t = c.cell_type;
|
|
28
|
+
if (t !== 'code' && t !== 'markdown')
|
|
29
|
+
continue;
|
|
30
|
+
const srcRaw = c.source;
|
|
31
|
+
const source = Array.isArray(srcRaw) ? srcRaw.join('') : typeof srcRaw === 'string' ? srcRaw : '';
|
|
32
|
+
const cell = { kind: t, source: source.replace(/\s+$/, ''), result: null };
|
|
33
|
+
if (t === 'code') {
|
|
34
|
+
const outs = c.outputs;
|
|
35
|
+
if (Array.isArray(outs) && outs.length > 0)
|
|
36
|
+
cell.rawOutputs = outs;
|
|
37
|
+
const ec = c.execution_count;
|
|
38
|
+
cell.rawExecCount = typeof ec === 'number' ? ec : null;
|
|
39
|
+
}
|
|
40
|
+
cells.push(cell);
|
|
41
|
+
}
|
|
42
|
+
return cells;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Split a Python script into cells on `# %%` markers (jupytext percent
|
|
46
|
+
* format; the arena starter uses the same markers). Marker lines are
|
|
47
|
+
* separators and are dropped. No markers → the whole file is one cell.
|
|
48
|
+
*/
|
|
49
|
+
export function parsePercentPy(text) {
|
|
50
|
+
const cells = [];
|
|
51
|
+
let cur = [];
|
|
52
|
+
const flush = () => {
|
|
53
|
+
const src = cur.join('\n').replace(/^\n+/, '').replace(/\s+$/, '');
|
|
54
|
+
if (src)
|
|
55
|
+
cells.push({ kind: 'code', source: src, result: null });
|
|
56
|
+
cur = [];
|
|
57
|
+
};
|
|
58
|
+
for (const line of text.split('\n')) {
|
|
59
|
+
if (/^#\s*%%/.test(line))
|
|
60
|
+
flush();
|
|
61
|
+
else
|
|
62
|
+
cur.push(line);
|
|
63
|
+
}
|
|
64
|
+
flush();
|
|
65
|
+
return cells;
|
|
66
|
+
}
|
|
67
|
+
/** Map one cell's outputs to nbformat: fresh result wins over disk outputs. */
|
|
68
|
+
function outputsToNbformat(cell) {
|
|
69
|
+
if (cell.result) {
|
|
70
|
+
const outs = [];
|
|
71
|
+
for (const o of cell.result.outputs) {
|
|
72
|
+
if (o.kind === 'stream')
|
|
73
|
+
outs.push({ output_type: 'stream', name: o.name, text: o.text });
|
|
74
|
+
else if (o.kind === 'result')
|
|
75
|
+
outs.push({
|
|
76
|
+
output_type: 'execute_result',
|
|
77
|
+
execution_count: cell.result.execCount,
|
|
78
|
+
data: o.data ?? { 'text/plain': o.text },
|
|
79
|
+
metadata: {},
|
|
80
|
+
});
|
|
81
|
+
else if (o.kind === 'display')
|
|
82
|
+
outs.push({ output_type: 'display_data', data: o.data, metadata: {} });
|
|
83
|
+
else
|
|
84
|
+
outs.push({ output_type: 'error', ename: o.ename, evalue: o.evalue, traceback: o.traceback });
|
|
85
|
+
}
|
|
86
|
+
return { outputs: outs, execution_count: cell.result.execCount };
|
|
87
|
+
}
|
|
88
|
+
return { outputs: cell.rawOutputs ?? [], execution_count: cell.rawExecCount ?? null };
|
|
89
|
+
}
|
|
90
|
+
/** Serialize cells to a Jupyter-openable nbformat v4.5 JSON string. */
|
|
91
|
+
export function serializeIpynb(cells) {
|
|
92
|
+
const nb = {
|
|
93
|
+
nbformat: 4,
|
|
94
|
+
nbformat_minor: 5,
|
|
95
|
+
metadata: {
|
|
96
|
+
kernelspec: { display_name: 'Python 3', language: 'python', name: 'python3' },
|
|
97
|
+
language_info: { name: 'python', version: '3.12' },
|
|
98
|
+
},
|
|
99
|
+
cells: cells.map((c) => c.kind === 'markdown'
|
|
100
|
+
? { cell_type: 'markdown', metadata: {}, source: c.source }
|
|
101
|
+
: { cell_type: 'code', metadata: {}, source: c.source, ...outputsToNbformat(c) }),
|
|
102
|
+
};
|
|
103
|
+
return `${JSON.stringify(nb, null, 1)}\n`;
|
|
104
|
+
}
|
|
105
|
+
/** One-char run state for the `cells` listing. */
|
|
106
|
+
export function statusChar(cell) {
|
|
107
|
+
if (cell.kind === 'markdown')
|
|
108
|
+
return 'md';
|
|
109
|
+
if (cell.result === null)
|
|
110
|
+
return '○';
|
|
111
|
+
return cell.result.ok ? '✓' : '✗';
|
|
112
|
+
}
|
|
113
|
+
/** First non-blank line, trimmed and truncated to `width` (with ellipsis). */
|
|
114
|
+
export function cellPreview(cell, width) {
|
|
115
|
+
const first = cell.source.split('\n').find((l) => l.trim() !== '') ?? '';
|
|
116
|
+
const t = first.trim();
|
|
117
|
+
if (t.length <= width)
|
|
118
|
+
return t;
|
|
119
|
+
return `${t.slice(0, Math.max(0, width - 1))}…`;
|
|
120
|
+
}
|
|
121
|
+
/** Parse `run` arguments: '3' → [3], '1-5' → [1..5], 'all' → [1..count].
|
|
122
|
+
* 1-based, validated against count; null = not a valid range. */
|
|
123
|
+
export function parseRunRange(arg, count) {
|
|
124
|
+
const a = arg.trim().toLowerCase();
|
|
125
|
+
if (!a)
|
|
126
|
+
return null;
|
|
127
|
+
if (a === 'all')
|
|
128
|
+
return Array.from({ length: count }, (_, i) => i + 1);
|
|
129
|
+
const m = a.match(/^(\d+)(?:-(\d+))?$/);
|
|
130
|
+
if (!m)
|
|
131
|
+
return null;
|
|
132
|
+
const from = Number(m[1]);
|
|
133
|
+
const to = m[2] ? Number(m[2]) : from;
|
|
134
|
+
if (from < 1 || to < from || to > count)
|
|
135
|
+
return null;
|
|
136
|
+
return Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
137
|
+
}
|
package/dist/lib/open-file.js
CHANGED
|
@@ -1 +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
|
+
}
|