icoa-cli 1.7.2 → 1.8.0
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/log.js +154 -27
- package/dist/index.js +1 -1
- package/dist/lib/log-sync.d.ts +2 -0
- package/dist/lib/log-sync.js +92 -0
- package/dist/repl.js +11 -3
- package/package.json +1 -1
package/dist/commands/log.js
CHANGED
|
@@ -1,36 +1,163 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { getSessionLog } from '../lib/logger.js';
|
|
5
|
+
import { getIcoaDir, getConfig } from '../lib/config.js';
|
|
3
6
|
import { printHeader, printInfo, printTable } from '../lib/ui.js';
|
|
4
7
|
export function registerLogCommand(program) {
|
|
5
|
-
program
|
|
8
|
+
const logCmd = program
|
|
6
9
|
.command('log')
|
|
7
10
|
.description('Display session history')
|
|
8
11
|
.action(() => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
12
|
+
showLog();
|
|
13
|
+
});
|
|
14
|
+
// icoa log export — export full audit log for post-competition review
|
|
15
|
+
logCmd
|
|
16
|
+
.command('export')
|
|
17
|
+
.description('Export full audit log for review')
|
|
18
|
+
.action(async () => {
|
|
19
|
+
await exportLog();
|
|
20
|
+
});
|
|
21
|
+
// icoa log stats — show summary statistics
|
|
22
|
+
logCmd
|
|
23
|
+
.command('stats')
|
|
24
|
+
.description('Show session statistics')
|
|
25
|
+
.action(() => {
|
|
26
|
+
showStats();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function showLog() {
|
|
30
|
+
const entries = getSessionLog();
|
|
31
|
+
if (entries.length === 0) {
|
|
32
|
+
printInfo('No session log entries yet.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
printHeader('Session Log');
|
|
36
|
+
const rows = entries.map((entry) => {
|
|
37
|
+
const time = entry.timestamp.replace('T', ' ').substring(0, 19);
|
|
38
|
+
const levelColor = {
|
|
39
|
+
A: chalk.green,
|
|
40
|
+
B: chalk.yellow,
|
|
41
|
+
C: chalk.red,
|
|
42
|
+
command: chalk.blue,
|
|
43
|
+
submit: chalk.magenta,
|
|
44
|
+
};
|
|
45
|
+
const colorFn = levelColor[entry.level] || chalk.gray;
|
|
46
|
+
const input = entry.input.length > 60 ? entry.input.substring(0, 57) + '...' : entry.input;
|
|
47
|
+
return [
|
|
48
|
+
chalk.gray(time),
|
|
49
|
+
colorFn(entry.level.padEnd(7)),
|
|
50
|
+
input,
|
|
51
|
+
];
|
|
52
|
+
});
|
|
53
|
+
printTable(['Time', 'Type', 'Content'], rows);
|
|
54
|
+
console.log(chalk.gray(` ${entries.length} entries total`));
|
|
55
|
+
console.log();
|
|
56
|
+
}
|
|
57
|
+
async function exportLog() {
|
|
58
|
+
const config = getConfig();
|
|
59
|
+
const icoaDir = getIcoaDir();
|
|
60
|
+
const logFile = join(icoaDir, 'session.log');
|
|
61
|
+
const sessionFile = join(icoaDir, 'session-state.json');
|
|
62
|
+
const configFile = join(icoaDir, 'config.json');
|
|
63
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
|
64
|
+
const userName = config.userName || 'unknown';
|
|
65
|
+
const exportName = `icoa-audit-${userName}-${timestamp}.json`;
|
|
66
|
+
const exportPath = join(process.cwd(), exportName);
|
|
67
|
+
// Gather all audit data
|
|
68
|
+
const audit = {
|
|
69
|
+
exportedAt: new Date().toISOString(),
|
|
70
|
+
version: '1.7.2',
|
|
71
|
+
competitor: {
|
|
72
|
+
userName: config.userName,
|
|
73
|
+
userId: config.userId,
|
|
74
|
+
teamName: config.teamName,
|
|
75
|
+
teamId: config.teamId,
|
|
76
|
+
sessionId: config.sessionId,
|
|
77
|
+
},
|
|
78
|
+
connection: {
|
|
79
|
+
ctfdUrl: config.ctfdUrl,
|
|
80
|
+
},
|
|
81
|
+
session: existsSync(sessionFile) ? JSON.parse(readFileSync(sessionFile, 'utf-8')) : null,
|
|
82
|
+
commands: getSessionLog(),
|
|
83
|
+
};
|
|
84
|
+
// Count by type
|
|
85
|
+
const entries = getSessionLog();
|
|
86
|
+
const counts = {};
|
|
87
|
+
for (const e of entries) {
|
|
88
|
+
counts[e.level] = (counts[e.level] || 0) + 1;
|
|
89
|
+
}
|
|
90
|
+
audit.summary = {
|
|
91
|
+
totalCommands: entries.length,
|
|
92
|
+
byType: counts,
|
|
93
|
+
firstEntry: entries[0]?.timestamp || null,
|
|
94
|
+
lastEntry: entries[entries.length - 1]?.timestamp || null,
|
|
95
|
+
};
|
|
96
|
+
// Write export
|
|
97
|
+
const { writeFileSync } = await import('node:fs');
|
|
98
|
+
writeFileSync(exportPath, JSON.stringify(audit, null, 2));
|
|
99
|
+
console.log();
|
|
100
|
+
console.log(chalk.green(` ✓ Audit log exported`));
|
|
101
|
+
console.log(chalk.white(` ${exportPath}`));
|
|
102
|
+
console.log();
|
|
103
|
+
console.log(chalk.gray(' Contents:'));
|
|
104
|
+
console.log(chalk.gray(` Commands: ${entries.length}`));
|
|
105
|
+
Object.entries(counts).forEach(([type, count]) => {
|
|
106
|
+
console.log(chalk.gray(` ${type}: ${count}`));
|
|
35
107
|
});
|
|
108
|
+
console.log();
|
|
109
|
+
console.log(chalk.gray(' This file contains the complete session audit trail.'));
|
|
110
|
+
console.log(chalk.gray(' Submit to organizers for post-competition verification.'));
|
|
111
|
+
console.log();
|
|
112
|
+
}
|
|
113
|
+
function showStats() {
|
|
114
|
+
const entries = getSessionLog();
|
|
115
|
+
const icoaDir = getIcoaDir();
|
|
116
|
+
const sessionFile = join(icoaDir, 'session-state.json');
|
|
117
|
+
console.log();
|
|
118
|
+
console.log(chalk.bold.white(' Session Statistics'));
|
|
119
|
+
console.log(chalk.gray(' ─────────────────────────────────────────────'));
|
|
120
|
+
if (entries.length === 0) {
|
|
121
|
+
console.log(chalk.gray(' No activity recorded yet.'));
|
|
122
|
+
console.log();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Time range
|
|
126
|
+
const first = new Date(entries[0].timestamp);
|
|
127
|
+
const last = new Date(entries[entries.length - 1].timestamp);
|
|
128
|
+
const durationMin = Math.round((last.getTime() - first.getTime()) / 60000);
|
|
129
|
+
console.log(chalk.gray(' First activity: ') + chalk.white(first.toLocaleString()));
|
|
130
|
+
console.log(chalk.gray(' Last activity: ') + chalk.white(last.toLocaleString()));
|
|
131
|
+
console.log(chalk.gray(' Duration: ') + chalk.white(`${durationMin} min`));
|
|
132
|
+
console.log();
|
|
133
|
+
// Count by type
|
|
134
|
+
const counts = {};
|
|
135
|
+
for (const e of entries) {
|
|
136
|
+
counts[e.level] = (counts[e.level] || 0) + 1;
|
|
137
|
+
}
|
|
138
|
+
console.log(chalk.gray(' Total commands: ') + chalk.white(String(entries.length)));
|
|
139
|
+
if (counts['command'])
|
|
140
|
+
console.log(chalk.blue(' commands: ') + chalk.white(String(counts['command'])));
|
|
141
|
+
if (counts['A'])
|
|
142
|
+
console.log(chalk.green(' hint A: ') + chalk.white(String(counts['A'])));
|
|
143
|
+
if (counts['B'])
|
|
144
|
+
console.log(chalk.yellow(' hint B: ') + chalk.white(String(counts['B'])));
|
|
145
|
+
if (counts['C'])
|
|
146
|
+
console.log(chalk.red(' hint C: ') + chalk.white(String(counts['C'])));
|
|
147
|
+
if (counts['submit'])
|
|
148
|
+
console.log(chalk.magenta(' submissions: ') + chalk.white(String(counts['submit'])));
|
|
149
|
+
// Exit info
|
|
150
|
+
if (existsSync(sessionFile)) {
|
|
151
|
+
try {
|
|
152
|
+
const session = JSON.parse(readFileSync(sessionFile, 'utf-8'));
|
|
153
|
+
console.log();
|
|
154
|
+
console.log(chalk.gray(' Exit count: ') + chalk.white(String(session.exitCount || 0)));
|
|
155
|
+
if (session.totalAwaySeconds) {
|
|
156
|
+
const awayMin = Math.round(session.totalAwaySeconds / 60);
|
|
157
|
+
console.log(chalk.gray(' Total away: ') + chalk.white(`${awayMin} min`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* ignore */ }
|
|
161
|
+
}
|
|
162
|
+
console.log();
|
|
36
163
|
}
|
package/dist/index.js
CHANGED
|
@@ -36,7 +36,7 @@ ${LINE}
|
|
|
36
36
|
${chalk.white('Sydney, Australia')} ${chalk.gray('Jun 27 - Jul 2, 2026')}
|
|
37
37
|
${chalk.cyan.underline('https://icoa2026.au')}
|
|
38
38
|
|
|
39
|
-
${chalk.gray('CLI-Native Competition Terminal v1.
|
|
39
|
+
${chalk.gray('CLI-Native Competition Terminal v1.8.0')}
|
|
40
40
|
|
|
41
41
|
${LINE}
|
|
42
42
|
`;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getIcoaDir, getConfig } from './config.js';
|
|
4
|
+
const SYNC_INTERVAL = 30_000; // 30 seconds
|
|
5
|
+
const SYNC_STATE_FILE = () => join(getIcoaDir(), 'sync-state.json');
|
|
6
|
+
let syncTimer = null;
|
|
7
|
+
function getSyncState() {
|
|
8
|
+
const file = SYNC_STATE_FILE();
|
|
9
|
+
if (!existsSync(file))
|
|
10
|
+
return { lastSyncedLine: 0, lastSyncAt: null, syncCount: 0, failCount: 0 };
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { lastSyncedLine: 0, lastSyncAt: null, syncCount: 0, failCount: 0 };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function saveSyncState(state) {
|
|
19
|
+
writeFileSync(SYNC_STATE_FILE(), JSON.stringify(state, null, 2));
|
|
20
|
+
}
|
|
21
|
+
async function syncLogs() {
|
|
22
|
+
const config = getConfig();
|
|
23
|
+
if (!config.ctfdUrl)
|
|
24
|
+
return; // Not connected, skip
|
|
25
|
+
const logPath = join(getIcoaDir(), 'session.log');
|
|
26
|
+
if (!existsSync(logPath))
|
|
27
|
+
return;
|
|
28
|
+
const state = getSyncState();
|
|
29
|
+
const allLines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
|
|
30
|
+
// Only send new lines since last sync
|
|
31
|
+
const newLines = allLines.slice(state.lastSyncedLine);
|
|
32
|
+
if (newLines.length === 0)
|
|
33
|
+
return;
|
|
34
|
+
const payload = {
|
|
35
|
+
userId: config.userId,
|
|
36
|
+
userName: config.userName,
|
|
37
|
+
teamId: config.teamId,
|
|
38
|
+
sessionId: config.sessionId,
|
|
39
|
+
deviceFingerprint: config.deviceFingerprint,
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
entries: newLines.map(line => {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(line);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { raw: line };
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
// POST to CTFd server audit endpoint
|
|
52
|
+
const url = new URL('/api/v1/audit/logs', config.ctfdUrl).href;
|
|
53
|
+
const res = await fetch(url, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'Authorization': `Token ${config.token}`,
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify(payload),
|
|
60
|
+
signal: AbortSignal.timeout(10_000), // 10s timeout
|
|
61
|
+
});
|
|
62
|
+
if (res.ok) {
|
|
63
|
+
state.lastSyncedLine = allLines.length;
|
|
64
|
+
state.lastSyncAt = new Date().toISOString();
|
|
65
|
+
state.syncCount++;
|
|
66
|
+
saveSyncState(state);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
state.failCount++;
|
|
70
|
+
saveSyncState(state);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Silent fail — network issues shouldn't break the CLI
|
|
75
|
+
state.failCount++;
|
|
76
|
+
saveSyncState(state);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function startLogSync() {
|
|
80
|
+
// Initial sync after 5 seconds
|
|
81
|
+
setTimeout(() => syncLogs(), 5_000);
|
|
82
|
+
// Then every 30 seconds
|
|
83
|
+
syncTimer = setInterval(() => syncLogs(), SYNC_INTERVAL);
|
|
84
|
+
}
|
|
85
|
+
export function stopLogSync() {
|
|
86
|
+
if (syncTimer) {
|
|
87
|
+
clearInterval(syncTimer);
|
|
88
|
+
syncTimer = null;
|
|
89
|
+
}
|
|
90
|
+
// Final sync before exit
|
|
91
|
+
syncLogs();
|
|
92
|
+
}
|
package/dist/repl.js
CHANGED
|
@@ -5,8 +5,10 @@ import { isConnected, getConfig } from './lib/config.js';
|
|
|
5
5
|
import { isActivated, activateToken, isFreeCommand, isDeviceMatch, recordExit, recordResume, isFirstRunOrUpgrade, markVersionSeen } from './lib/access.js';
|
|
6
6
|
import { resetTerminalTheme } from './lib/theme.js';
|
|
7
7
|
import { ensureSandbox, runInSandbox, isDockerAvailable } from './lib/sandbox.js';
|
|
8
|
+
import { logCommand } from './lib/logger.js';
|
|
9
|
+
import { startLogSync, stopLogSync } from './lib/log-sync.js';
|
|
8
10
|
const INTERCEPT = '__REPL_NO_EXIT__';
|
|
9
|
-
const VERSION = '1.
|
|
11
|
+
const VERSION = '1.8.0';
|
|
10
12
|
export async function startRepl(program, resumeMode) {
|
|
11
13
|
const config = getConfig();
|
|
12
14
|
const connected = isConnected();
|
|
@@ -115,6 +117,8 @@ export async function startRepl(program, resumeMode) {
|
|
|
115
117
|
terminal: true,
|
|
116
118
|
});
|
|
117
119
|
let processing = false;
|
|
120
|
+
// Start background log sync (every 30s to server)
|
|
121
|
+
startLogSync();
|
|
118
122
|
rl.prompt();
|
|
119
123
|
rl.on('line', async (line) => {
|
|
120
124
|
if (processing)
|
|
@@ -124,8 +128,11 @@ export async function startRepl(program, resumeMode) {
|
|
|
124
128
|
rl.prompt();
|
|
125
129
|
return;
|
|
126
130
|
}
|
|
131
|
+
// Log ALL commands for audit trail
|
|
132
|
+
logCommand(input);
|
|
127
133
|
// Exit — record, reset terminal colors, and quit
|
|
128
134
|
if (input === 'exit' || input === 'quit' || input === 'q') {
|
|
135
|
+
stopLogSync();
|
|
129
136
|
recordExit();
|
|
130
137
|
console.log(chalk.gray(' Session saved. Use ') + chalk.white('icoa --resume') + chalk.gray(' to continue.'));
|
|
131
138
|
resetTerminalTheme();
|
|
@@ -182,8 +189,8 @@ export async function startRepl(program, resumeMode) {
|
|
|
182
189
|
const knownCommands = [
|
|
183
190
|
'join', 'activate', 'challenges', 'ch', 'open', 'submit', 'flag',
|
|
184
191
|
'scoreboard', 'sb', 'status', 'time', 'hint', 'hint-b', 'hint-c',
|
|
185
|
-
'hint-budget', 'ref', 'shell', 'files', 'connect', 'note',
|
|
186
|
-
'lang', 'setup', 'env', 'model', 'ctf',
|
|
192
|
+
'hint-budget', 'ref', 'shell', 'files', 'connect', 'note',
|
|
193
|
+
'log', 'lang', 'setup', 'env', 'model', 'ctf',
|
|
187
194
|
];
|
|
188
195
|
if (!knownCommands.includes(cmd)) {
|
|
189
196
|
// Route to Docker sandbox if available, otherwise system shell
|
|
@@ -238,6 +245,7 @@ export async function startRepl(program, resumeMode) {
|
|
|
238
245
|
rl.prompt();
|
|
239
246
|
});
|
|
240
247
|
rl.on('close', () => {
|
|
248
|
+
stopLogSync();
|
|
241
249
|
recordExit();
|
|
242
250
|
resetTerminalTheme();
|
|
243
251
|
realExit(0);
|