icoa-cli 2.19.33 → 2.19.34

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.
@@ -88,6 +88,9 @@ function showDemoHint(tier) {
88
88
  export async function handleChatMessage(input) {
89
89
  if (!chatSession)
90
90
  return 'exit';
91
+ // Capture every input (including special commands like hint/submit/exit)
92
+ // so the full flow shows up in session.log even before any early return.
93
+ logCommand(`ai4ctf: ${input}`);
91
94
  // Scripted demo hints — intercept before the AI chat so that typing
92
95
  // `hint a` / `hint b` / `hint c` behaves like a real competition command
93
96
  // instead of becoming a generic AI chat turn.
@@ -237,7 +240,6 @@ export async function handleChatMessage(input) {
237
240
  console.log();
238
241
  return 'exit';
239
242
  }
240
- logCommand(`ai4ctf: ${input}`);
241
243
  console.log(chalk.gray(` ${t('ai4ctfThinking')}`));
242
244
  try {
243
245
  const response = await chatSession.sendMessage(input);
@@ -89,6 +89,9 @@ export function isCtf4aiActive() {
89
89
  export async function handleCtf4aiMessage(input) {
90
90
  if (!ctf4aiSession)
91
91
  return 'exit';
92
+ // Capture every input (including special commands) for the audit trail
93
+ // before any early-return branches.
94
+ logCommand(`ctf4ai: ${input}`);
92
95
  if (input === 'exit' || input === 'back' || input === 'quit') {
93
96
  ctf4aiActive = false;
94
97
  ctf4aiSession = null;
@@ -118,7 +121,6 @@ export async function handleCtf4aiMessage(input) {
118
121
  printDemoReport(false, ctf4aiTokens);
119
122
  return 'exit';
120
123
  }
121
- logCommand(`ctf4ai: ${input}`);
122
124
  try {
123
125
  console.log(chalk.gray(` ${t('ctf4aiThinking')}`));
124
126
  const { text, tokensUsed } = await ctf4aiSession.sendMessage(input);
@@ -9,6 +9,23 @@ import { printSuccess, printError, printWarning, printInfo, printTable, printHea
9
9
  import { t } from '../lib/i18n.js';
10
10
  import { getDeviceFingerprint } from '../lib/access.js';
11
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
+ // Fire-and-forget event POST to /api/icoa/demo-stats. Used for demo-flow
13
+ // choice points (demo enter, retry, back) where we want a server-side
14
+ // timestamp of the user's decision but have nothing more to report.
15
+ function reportDemoEvent(type, extra = {}) {
16
+ const config = getConfig();
17
+ fetch('https://practice.icoa2026.au/api/icoa/demo-stats', {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify({
21
+ type,
22
+ lang: config.language || 'en',
23
+ timestamp: new Date().toISOString(),
24
+ ...extra,
25
+ }),
26
+ signal: AbortSignal.timeout(5000),
27
+ }).catch(() => { });
28
+ }
12
29
  /**
13
30
  * Skippable intro animation for first-time demo users.
14
31
  * Resolves immediately on any keypress. Max 30s auto-advance.
@@ -1077,6 +1094,7 @@ export function registerExamCommand(program) {
1077
1094
  .description('Try a free practice exam (no account needed)')
1078
1095
  .action(async () => {
1079
1096
  logCommand('exam demo');
1097
+ reportDemoEvent('demo-enter');
1080
1098
  const { pickDemoQuestions, getLocalizedDemoSession, DEMO_PICK_SIZE, DEMO_POOL_SIZE } = await import('../lib/demo-exam.js');
1081
1099
  // First-time intro animation (skippable)
1082
1100
  const cfg = getConfig();
@@ -1150,6 +1168,7 @@ export function registerExamCommand(program) {
1150
1168
  .description('Retry only the questions you got wrong last demo attempt')
1151
1169
  .action(async () => {
1152
1170
  logCommand('exam demo-retry');
1171
+ reportDemoEvent('post-report-retry');
1153
1172
  const { getLocalizedDemoSession } = await import('../lib/demo-exam.js');
1154
1173
  const retryQueue = getRetryQueue();
1155
1174
  if (!retryQueue || retryQueue.length === 0) {
@@ -1,7 +1,19 @@
1
1
  import { readFileSync, existsSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { getIcoaDir, getConfig } from './config.js';
4
- const SYNC_INTERVAL = 30_000; // 30 seconds
4
+ import { getDeviceFingerprint } from './access.js';
5
+ // Every 30s, post new lines from ~/.icoa/session.log to the server audit
6
+ // endpoint. Two auth modes:
7
+ //
8
+ // 1. Logged-in user (CTFd token present) → Authorization: Token <token>,
9
+ // stored on the server as /opt/CTFd/audit/user-<userId>.jsonl
10
+ // 2. Anonymous demo user (no token) → X-Device-Fingerprint: <hash>,
11
+ // stored as /opt/CTFd/audit/demo-<fingerprint>.jsonl
12
+ //
13
+ // The demo path is what lets us observe the full prompt/command history of
14
+ // users who never log in (the main demo audience).
15
+ const SYNC_INTERVAL = 30_000;
16
+ const DEFAULT_SERVER = 'https://practice.icoa2026.au';
5
17
  const SYNC_STATE_FILE = () => join(getIcoaDir(), 'sync-state.json');
6
18
  let syncTimer = null;
7
19
  function getSyncState() {
@@ -20,25 +32,37 @@ function saveSyncState(state) {
20
32
  }
21
33
  async function syncLogs() {
22
34
  const config = getConfig();
23
- if (!config.ctfdUrl)
24
- return; // Not connected, skip
35
+ const serverUrl = config.ctfdUrl || DEFAULT_SERVER;
25
36
  const logPath = join(getIcoaDir(), 'session.log');
26
37
  if (!existsSync(logPath))
27
38
  return;
28
39
  const state = getSyncState();
29
40
  const allLines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
30
- // Only send new lines since last sync
31
41
  const newLines = allLines.slice(state.lastSyncedLine);
32
42
  if (newLines.length === 0)
33
43
  return;
44
+ // Pick auth mode: token for logged-in users, device fingerprint for anon.
45
+ const headers = { 'Content-Type': 'application/json' };
46
+ let identity;
47
+ if (config.token) {
48
+ headers['Authorization'] = `Token ${config.token}`;
49
+ identity = `user:${config.userId ?? 'unknown'}`;
50
+ }
51
+ else {
52
+ const fp = config.deviceFingerprint || getDeviceFingerprint();
53
+ headers['X-Device-Fingerprint'] = fp;
54
+ identity = `demo:${fp.slice(0, 12)}`;
55
+ }
34
56
  const payload = {
57
+ identity,
35
58
  userId: config.userId,
36
59
  userName: config.userName,
37
60
  teamId: config.teamId,
38
61
  sessionId: config.sessionId,
39
- deviceFingerprint: config.deviceFingerprint,
62
+ deviceFingerprint: config.deviceFingerprint || getDeviceFingerprint(),
63
+ lang: config.language || 'en',
40
64
  timestamp: new Date().toISOString(),
41
- entries: newLines.map(line => {
65
+ entries: newLines.map((line) => {
42
66
  try {
43
67
  return JSON.parse(line);
44
68
  }
@@ -48,16 +72,12 @@ async function syncLogs() {
48
72
  }),
49
73
  };
50
74
  try {
51
- // POST to CTFd server audit endpoint
52
- const url = new URL('/api/icoa/audit', config.ctfdUrl).href;
75
+ const url = new URL('/api/icoa/audit', serverUrl).href;
53
76
  const res = await fetch(url, {
54
77
  method: 'POST',
55
- headers: {
56
- 'Content-Type': 'application/json',
57
- 'Authorization': `Token ${config.token}`,
58
- },
78
+ headers,
59
79
  body: JSON.stringify(payload),
60
- signal: AbortSignal.timeout(10_000), // 10s timeout
80
+ signal: AbortSignal.timeout(10_000),
61
81
  });
62
82
  if (res.ok) {
63
83
  state.lastSyncedLine = allLines.length;
@@ -77,9 +97,7 @@ async function syncLogs() {
77
97
  }
78
98
  }
79
99
  export function startLogSync() {
80
- // Initial sync after 5 seconds
81
100
  setTimeout(() => syncLogs(), 5_000);
82
- // Then every 30 seconds
83
101
  syncTimer = setInterval(() => syncLogs(), SYNC_INTERVAL);
84
102
  }
85
103
  export function stopLogSync() {
package/dist/repl.js CHANGED
@@ -360,6 +360,20 @@ export async function startRepl(program, resumeMode) {
360
360
  console.log();
361
361
  }
362
362
  else {
363
+ // No active exam — typically this is the "post-report back" choice
364
+ // after the user has finished the 3-stage demo flow. Report it as a
365
+ // distinct event so the admin can count retry vs back decisions.
366
+ const cfg = getConfig();
367
+ fetch('https://practice.icoa2026.au/api/icoa/demo-stats', {
368
+ method: 'POST',
369
+ headers: { 'Content-Type': 'application/json' },
370
+ body: JSON.stringify({
371
+ type: 'post-report-back',
372
+ lang: cfg.language || 'en',
373
+ timestamp: new Date().toISOString(),
374
+ }),
375
+ signal: AbortSignal.timeout(5000),
376
+ }).catch(() => { });
363
377
  console.log(chalk.gray(' Already at main menu.'));
364
378
  }
365
379
  rl.prompt();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.33",
3
+ "version": "2.19.34",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {