icoa-cli 2.19.93 → 2.19.95

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.
@@ -1578,10 +1578,36 @@ export function registerExamCommand(program) {
1578
1578
  .description('Enter exam with access token (no login needed)')
1579
1579
  .action(async (code) => {
1580
1580
  logCommand(`exam token ${code}`);
1581
- const { getRealExamState, saveExamState: saveExamStateFn } = await import('../lib/exam-state.js');
1581
+ const { getRealExamState, saveExamState: saveExamStateFn, clearExamState: clearState } = await import('../lib/exam-state.js');
1582
1582
  const existing = getRealExamState();
1583
1583
  if (existing) {
1584
1584
  const existingToken = existing.session.token;
1585
+ // Auto-recover from abandoned/expired sessions. If the stored state's
1586
+ // deadline is >30 min in the past, the session is unusable (past the
1587
+ // server's submit grace window). If user types a DIFFERENT token in
1588
+ // this state, they clearly mean "start fresh". Clear silently rather
1589
+ // than locking them out forever. Prevents the stale-A-paper-content +
1590
+ // "Could not reload questions in new language" lockout.
1591
+ const deadlineNow = getExamDeadline();
1592
+ const typedDiffers = !!existingToken && existingToken.trim().toUpperCase() !== code.trim().toUpperCase();
1593
+ const expiredPast30min = deadlineNow ? (deadlineNow.getTime() + 30 * 60 * 1000) < Date.now() : false;
1594
+ if (typedDiffers && expiredPast30min) {
1595
+ clearState();
1596
+ console.log();
1597
+ console.log(chalk.gray(' (previous exam session expired — cleared state, starting fresh with your new token)'));
1598
+ console.log();
1599
+ // Fall through to the fresh-start flow below (skip the "already in progress" guard)
1600
+ // Re-run this action: easiest to just continue out of the `if (existing)` block
1601
+ // by setting a flag. We do that by falling through — but since we're inside
1602
+ // `if (existing)`, we need to proceed. Easiest: re-invoke the inner start flow.
1603
+ // Actually: since we cleared state, the subsequent code paths will treat it
1604
+ // as a fresh session. But we're inside `if (existing)`. Jump to the fresh path
1605
+ // by executing a goto-equivalent via function call. Simplest: return here
1606
+ // after instructing the user to retype the command.
1607
+ console.log(chalk.white(' Please re-type: ') + chalk.bold.cyan(`exam ${code}`));
1608
+ console.log();
1609
+ return;
1610
+ }
1585
1611
  // Migration path: pre-v2.19.55 sessions didn't persist the token on
1586
1612
  // state. Those sessions can't use `lang` or submit via the new token
1587
1613
  // path. If the user re-types the matching token, attach it so the
@@ -1624,6 +1650,9 @@ export function registerExamCommand(program) {
1624
1650
  console.log();
1625
1651
  console.log(chalk.yellow(' Note: you typed a different token. Each exam token is bound to'));
1626
1652
  console.log(chalk.yellow(' one device + one session. You cannot switch tokens mid-exam.'));
1653
+ console.log();
1654
+ console.log(chalk.gray(' If you need to abandon this session (e.g. expired / wrong paper),'));
1655
+ console.log(chalk.gray(' run ') + chalk.bold.cyan('exam reset') + chalk.gray(' to wipe local state, then re-enter the new token.'));
1627
1656
  }
1628
1657
  console.log();
1629
1658
  return;
@@ -1782,6 +1811,53 @@ export function registerExamCommand(program) {
1782
1811
  }
1783
1812
  });
1784
1813
  // ─── exam demo ───
1814
+ exam
1815
+ .command('reset', { hidden: true })
1816
+ .description('Abandon current exam session and wipe local state (recovery only)')
1817
+ .action(async () => {
1818
+ logCommand('exam reset');
1819
+ const { getRealExamState, clearExamState: clearState } = await import('../lib/exam-state.js');
1820
+ const existing = getRealExamState();
1821
+ if (!existing) {
1822
+ console.log();
1823
+ console.log(chalk.gray(' No active exam session to reset.'));
1824
+ console.log();
1825
+ return;
1826
+ }
1827
+ const tok = existing.session.token;
1828
+ console.log();
1829
+ console.log(chalk.yellow(' ⚠ Reset will abandon your current exam session on this device.'));
1830
+ console.log(chalk.gray(' Exam: ') + chalk.white(existing.session.examName));
1831
+ if (tok)
1832
+ console.log(chalk.gray(' Token: ') + chalk.white(tok));
1833
+ console.log(chalk.gray(' The token itself is NOT revoked server-side. If your session is'));
1834
+ console.log(chalk.gray(' still active and not submitted, re-entering the same token resumes it.'));
1835
+ console.log();
1836
+ console.log(chalk.gray(' Type ') + chalk.bold.cyan('reset') + chalk.gray(' to confirm, or anything else to cancel:'));
1837
+ // Typed-word confirmation — matches existing exam submit confirm pattern
1838
+ const confirmWord = await new Promise((resolve) => {
1839
+ process.stdout.write(' > ');
1840
+ const onData = (chunk) => {
1841
+ const s = chunk.toString().trim();
1842
+ if (s.includes('\n') || s.includes('\r') || s.length > 0) {
1843
+ process.stdin.removeListener('data', onData);
1844
+ resolve(s.replace(/[\r\n]/g, ''));
1845
+ }
1846
+ };
1847
+ process.stdin.on('data', onData);
1848
+ process.stdin.resume();
1849
+ });
1850
+ if (confirmWord.toLowerCase() !== 'reset') {
1851
+ console.log(chalk.gray(' Cancelled.'));
1852
+ console.log();
1853
+ return;
1854
+ }
1855
+ clearState();
1856
+ console.log();
1857
+ console.log(chalk.green(' ✓ Exam state cleared.'));
1858
+ console.log(chalk.gray(' You can now enter a new token with ') + chalk.bold.cyan('exam <token>'));
1859
+ console.log();
1860
+ });
1785
1861
  exam
1786
1862
  .command('demo')
1787
1863
  .description('Try a free practice exam (no account needed)')
@@ -133,7 +133,12 @@ export function registerLangCommand(program) {
133
133
  console.log(chalk.white(` Resume: exam q ${currentQ}`));
134
134
  }
135
135
  else {
136
- console.log(chalk.yellow(' Could not reload questions in new language. Try again later.'));
136
+ // Most common cause here: the stored session's token was already
137
+ // submitted on the server (409) or the exam window has closed.
138
+ // Surface the recovery path so the user isn't locked out.
139
+ console.log(chalk.yellow(' Could not reload questions in new language.'));
140
+ console.log(chalk.gray(' The saved session may be expired or already submitted server-side.'));
141
+ console.log(chalk.gray(' To abandon it and start fresh: ') + chalk.bold.cyan('back') + chalk.gray(' → ') + chalk.bold.cyan('exam reset') + chalk.gray(' → ') + chalk.bold.cyan('exam <new-token>'));
137
142
  }
138
143
  }
139
144
  catch {
package/dist/index.js CHANGED
@@ -181,4 +181,21 @@ program
181
181
  console.log();
182
182
  }
183
183
  });
184
+ // Proctor-only escape hatch: `ICOA_RESET_STATE=1 icoa` wipes any abandoned
185
+ // exam state *before* any subcommand or REPL runs. Fires for ALL invocations
186
+ // (including `icoa exam <token>`) so proctors have a one-liner recovery that
187
+ // works regardless of which subcommand the student would run next.
188
+ // The token itself is untouched server-side.
189
+ if (process.env.ICOA_RESET_STATE === '1') {
190
+ try {
191
+ const { clearExamState } = await import('./lib/exam-state.js');
192
+ clearExamState();
193
+ console.log(chalk.yellow('⚠ ICOA_RESET_STATE=1 — local exam state wiped.'));
194
+ console.log(chalk.gray(' (Token NOT revoked server-side. Re-enter a fresh token with `exam <token>`.)'));
195
+ console.log();
196
+ }
197
+ catch (e) {
198
+ console.log(chalk.red('⚠ ICOA_RESET_STATE: could not clear state — ') + chalk.gray(String(e)));
199
+ }
200
+ }
184
201
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.93",
3
+ "version": "2.19.95",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {