icoa-cli 2.19.13 → 2.19.15

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.
@@ -176,12 +176,27 @@ export function registerCtfCommands(program) {
176
176
  }
177
177
  }
178
178
  catch (err) {
179
- spinner2.fail('Connection test failed');
180
- printError(err.message);
181
- // Still save URL so user can retry
182
- saveConfig({ ctfdUrl: url, sessionCookie: sessionCookie });
183
- console.log();
184
- printInfo('Connection saved. Try: join ' + url);
179
+ if (sessionCookie) {
180
+ // Session login succeeded but API test failed — save anyway
181
+ spinner2.succeed('Connected (session mode limited API)');
182
+ saveConfig({
183
+ ctfdUrl: url,
184
+ token: sessionCookie,
185
+ userName: username,
186
+ sessionCookie: sessionCookie,
187
+ });
188
+ console.log();
189
+ printKeyValue('User', username);
190
+ printWarning('API access limited. Some features may not work.');
191
+ printSuccess('Connection saved.');
192
+ }
193
+ else {
194
+ spinner2.fail('Connection test failed');
195
+ printError(err.message);
196
+ saveConfig({ ctfdUrl: url });
197
+ console.log();
198
+ printInfo('Connection saved. Try: join ' + url);
199
+ }
185
200
  }
186
201
  });
187
202
  // ─── icoa ctf logout ───
@@ -6,6 +6,7 @@ import { getExamState, saveExamState, clearExamState, getExamDeadline } from '..
6
6
  import { logCommand } from '../lib/logger.js';
7
7
  import { printSuccess, printError, printWarning, printInfo, printTable, printHeader, printKeyValue, createSpinner, formatCountdown, } from '../lib/ui.js';
8
8
  import { t } from '../lib/i18n.js';
9
+ import { getDeviceFingerprint } from '../lib/access.js';
9
10
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
11
  function drawProgress(percent, label) {
11
12
  const width = 30;
@@ -29,14 +30,17 @@ function checkTimeRemaining() {
29
30
  return true;
30
31
  if (new Date() >= deadline) {
31
32
  const state = getExamState();
32
- if (state && state.session.examId === 'demo-free') {
33
- // Demo expired — auto-clear
34
- printWarning('Demo time expired. Starting fresh!');
35
- printInfo('Type: demo');
36
- clearExamState();
37
- }
38
- else {
39
- printError('Time expired! Use "exam submit" to submit your answers.');
33
+ if (state) {
34
+ const answered = Object.keys(state.answers).length;
35
+ if (state.session.examId === 'demo-free' || answered === 0) {
36
+ // Demo or unanswered expired exam — auto-clear to avoid deadlock
37
+ clearExamState();
38
+ printWarning('Exam expired and cleared.');
39
+ printInfo('Type: demo to start again.');
40
+ }
41
+ else {
42
+ printError('Time expired! Use "exam submit" to submit your answers.');
43
+ }
40
44
  }
41
45
  return false;
42
46
  }
@@ -639,12 +643,15 @@ export function registerExamCommand(program) {
639
643
  const total = state.session.questionCount;
640
644
  const unanswered = total - answered;
641
645
  if (answered === 0) {
642
- printWarning('No answers to submit.');
643
- if (state.session.examId === 'demo-free') {
646
+ const deadline = getExamDeadline();
647
+ const expired = deadline && new Date() >= deadline;
648
+ if (state.session.examId === 'demo-free' || expired) {
644
649
  clearExamState();
645
- printInfo('Demo cleared. Type: demo to start again.');
650
+ printWarning('No answers to submit. Exam cleared.');
651
+ printInfo('Type: demo to start again.');
646
652
  }
647
653
  else {
654
+ printWarning('No answers to submit.');
648
655
  printInfo('Answer some questions first: exam q 1');
649
656
  }
650
657
  return;
@@ -773,12 +780,35 @@ export function registerExamCommand(program) {
773
780
  return;
774
781
  }
775
782
  // Real exam: submit to server
776
- const client = requireExamConnection();
777
- if (!client)
778
- return;
783
+ // Token-based exam: use direct fetch with exam token
784
+ // CTFd-based exam: use ExamClient
785
+ const examToken = state.session.token;
779
786
  try {
780
787
  drawProgress(0, 'Uploading answers...');
781
- const result = await client.submitExam(state.session.examId, state.answers);
788
+ let result;
789
+ if (examToken) {
790
+ // Submit via exam token
791
+ const config = getConfig();
792
+ const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
793
+ const res = await fetch(`${serverUrl}/api/icoa/exams/${state.session.examId}/submit`, {
794
+ method: 'POST',
795
+ headers: { 'Content-Type': 'application/json' },
796
+ body: JSON.stringify({ token: examToken, answers: state.answers }),
797
+ signal: AbortSignal.timeout(10000),
798
+ });
799
+ if (!res.ok) {
800
+ const err = await res.json().catch(() => ({ message: 'Submit failed' }));
801
+ throw new Error(err.message || 'Submit failed');
802
+ }
803
+ const json = await res.json();
804
+ result = json.data;
805
+ }
806
+ else {
807
+ const client = requireExamConnection();
808
+ if (!client)
809
+ return;
810
+ result = await client.submitExam(state.session.examId, state.answers);
811
+ }
782
812
  drawProgress(50, 'Grading...');
783
813
  await sleep(300);
784
814
  drawProgress(100, 'Complete!');
@@ -853,7 +883,11 @@ export function registerExamCommand(program) {
853
883
  const res = await fetch(`${serverUrl}/api/icoa/exam-token`, {
854
884
  method: 'POST',
855
885
  headers: { 'Content-Type': 'application/json' },
856
- body: JSON.stringify({ token: code.trim() }),
886
+ body: JSON.stringify({
887
+ token: code.trim(),
888
+ deviceHash: getDeviceFingerprint(),
889
+ lang: config.language || 'en',
890
+ }),
857
891
  signal: AbortSignal.timeout(10000),
858
892
  });
859
893
  if (!res.ok) {
@@ -2,6 +2,7 @@ export declare function isFirstRunOrUpgrade(currentVersion: string): boolean;
2
2
  export declare function markVersionSeen(version: string): void;
3
3
  export declare function validateToken(token: string): boolean;
4
4
  export declare function isActivated(): boolean;
5
+ export declare function getDeviceFingerprint(): string;
5
6
  export type ActivateResult = 'ok' | 'invalid' | 'already_bound';
6
7
  export declare function activateToken(token: string): ActivateResult;
7
8
  export declare function isDeviceMatch(): boolean;
@@ -127,7 +127,7 @@ export function isActivated() {
127
127
  const config = getConfig();
128
128
  return !!(config.accessToken && TOKEN_HASHES.has(hashToken(config.accessToken)));
129
129
  }
130
- function getDeviceFingerprint() {
130
+ export function getDeviceFingerprint() {
131
131
  const parts = [hostname(), platform(), arch()];
132
132
  // Add hardware UUID where possible
133
133
  try {
@@ -9,6 +9,7 @@ export declare class CTFdClient {
9
9
  fetchCsrfNonce(): Promise<string>;
10
10
  private request;
11
11
  testConnection(): Promise<CTFdUser>;
12
+ private testConnectionViaProfile;
12
13
  getChallenges(): Promise<CTFdChallengeListItem[]>;
13
14
  getChallenge(id: number): Promise<CTFdChallenge>;
14
15
  submitFlag(challengeId: number, submission: string): Promise<CTFdAttemptResponse['data']>;
@@ -69,7 +69,37 @@ export class CTFdClient {
69
69
  return json.data;
70
70
  }
71
71
  async testConnection() {
72
- return this.request('GET', '/users/me');
72
+ try {
73
+ return await this.request('GET', '/users/me');
74
+ }
75
+ catch (err) {
76
+ // Session mode fallback: API may return 403, try scraping profile page
77
+ if (this.sessionCookie && err.message?.includes('403')) {
78
+ return this.testConnectionViaProfile();
79
+ }
80
+ throw err;
81
+ }
82
+ }
83
+ async testConnectionViaProfile() {
84
+ const res = await fetch(`${this.baseUrl}/settings`, {
85
+ headers: { Cookie: this.sessionCookie },
86
+ });
87
+ if (!res.ok)
88
+ throw new Error('Session expired or invalid.');
89
+ const html = await res.text();
90
+ // Extract user name from settings page
91
+ const nameMatch = html.match(/name="name"[^>]*value="([^"]+)"/) ||
92
+ html.match(/<input[^>]*id="name"[^>]*value="([^"]+)"/);
93
+ const name = nameMatch?.[1] || 'User';
94
+ // Extract user ID from page
95
+ const idMatch = html.match(/user_id['":\s]+(\d+)/) ||
96
+ html.match(/userId['":\s]+(\d+)/);
97
+ const id = idMatch ? parseInt(idMatch[1], 10) : 0;
98
+ // Update CSRF nonce from settings page
99
+ const csrfMatch = html.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
100
+ if (csrfMatch)
101
+ this.csrfNonce = csrfMatch[1];
102
+ return { id, name, score: 0, team_id: 0, country: '' };
73
103
  }
74
104
  async getChallenges() {
75
105
  return this.request('GET', '/challenges');
@@ -98,7 +98,7 @@ export function getLocalizedDemoQuestions() {
98
98
  return DEMO_QUESTIONS;
99
99
  try {
100
100
  const data = JSON.parse(readFileSync(path, 'utf-8'));
101
- if (Array.isArray(data) && data.length === 30)
101
+ if (Array.isArray(data) && data.length >= DEMO_QUESTIONS.length)
102
102
  return data;
103
103
  }
104
104
  catch { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.13",
3
+ "version": "2.19.15",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "build": "tsc",
16
16
  "dev": "tsc --watch",
17
17
  "start": "node dist/index.js",
18
- "postinstall": "node dist/postinstall.js 2>/dev/null || true"
18
+ "postinstall": "node -e \"try{require('./dist/postinstall.js')}catch(e){}\""
19
19
  },
20
20
  "keywords": [
21
21
  "ctf",