icoa-cli 1.3.2 → 1.3.4

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/index.js CHANGED
@@ -13,6 +13,7 @@ import { registerLangCommand } from './commands/lang.js';
13
13
  import { registerSetupCommand } from './commands/setup.js';
14
14
  import { getConfig, saveConfig } from './lib/config.js';
15
15
  import { startRepl } from './repl.js';
16
+ import { checkTerminal } from './lib/terminal.js';
16
17
  // Banner: each line between ║ ║ = exactly 58 visible chars
17
18
  const B = chalk.cyan('║');
18
19
  const BANNER = `
@@ -35,7 +36,7 @@ ${B} ${B}
35
36
  ${B} ${chalk.white('Sydney, Australia')} ${chalk.gray('Jun 27 - Jul 2, 2026')} ${B}
36
37
  ${B} ${chalk.cyan.underline('https://icoa2026.au')} ${B}
37
38
  ${B} ${B}
38
- ${B} ${chalk.gray('CLI-Native Competition Terminal v1.3.2')} ${B}
39
+ ${B} ${chalk.gray('CLI-Native Competition Terminal v1.3.4')} ${B}
39
40
  ${B} ${B}
40
41
  ${chalk.cyan('╚══════════════════════════════════════════════════════════╝')}
41
42
  `;
@@ -61,6 +62,7 @@ program
61
62
  .option('--resume', 'Resume previous session')
62
63
  .action((opts) => {
63
64
  console.log(BANNER);
65
+ checkTerminal();
64
66
  // If running interactively (no extra args or --resume), start REPL
65
67
  if (process.argv.length <= 2 || opts.resume) {
66
68
  startRepl(program, !!opts.resume);
@@ -1,6 +1,8 @@
1
1
  export declare function validateToken(token: string): boolean;
2
2
  export declare function isActivated(): boolean;
3
- export declare function activateToken(token: string): boolean;
3
+ export type ActivateResult = 'ok' | 'invalid' | 'already_bound';
4
+ export declare function activateToken(token: string): ActivateResult;
5
+ export declare function isDeviceMatch(): boolean;
4
6
  export declare function isFreeCommand(cmd: string): boolean;
5
7
  interface SessionState {
6
8
  startedAt: string;
@@ -1,4 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { execSync } from 'node:child_process';
3
+ import { hostname, platform, arch, networkInterfaces } from 'node:os';
2
4
  import { getConfig, saveConfig, getIcoaDir } from './config.js';
3
5
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
6
  import { join } from 'node:path';
@@ -117,12 +119,76 @@ export function isActivated() {
117
119
  const config = getConfig();
118
120
  return !!(config.accessToken && TOKEN_HASHES.has(hashToken(config.accessToken)));
119
121
  }
122
+ function getDeviceFingerprint() {
123
+ const parts = [hostname(), platform(), arch()];
124
+ // Add hardware UUID where possible
125
+ try {
126
+ if (platform() === 'darwin') {
127
+ const out = execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', { encoding: 'utf-8' });
128
+ const match = out.match(/"([A-F0-9-]+)"/);
129
+ if (match)
130
+ parts.push(match[1]);
131
+ }
132
+ else if (platform() === 'linux') {
133
+ if (existsSync('/etc/machine-id')) {
134
+ parts.push(readFileSync('/etc/machine-id', 'utf-8').trim());
135
+ }
136
+ }
137
+ else if (platform() === 'win32') {
138
+ const out = execSync('wmic csproduct get uuid', { encoding: 'utf-8' });
139
+ const lines = out.trim().split('\n');
140
+ if (lines.length > 1)
141
+ parts.push(lines[1].trim());
142
+ }
143
+ }
144
+ catch {
145
+ // Fallback: use first non-internal MAC address
146
+ const nets = networkInterfaces();
147
+ for (const ifaces of Object.values(nets)) {
148
+ if (!ifaces)
149
+ continue;
150
+ for (const iface of ifaces) {
151
+ if (!iface.internal && iface.mac !== '00:00:00:00:00:00') {
152
+ parts.push(iface.mac);
153
+ break;
154
+ }
155
+ }
156
+ if (parts.length > 3)
157
+ break;
158
+ }
159
+ }
160
+ return createHash('sha256').update(parts.join('|')).digest('hex');
161
+ }
120
162
  export function activateToken(token) {
121
- if (validateToken(token)) {
122
- saveConfig({ accessToken: token.trim().toUpperCase() });
123
- return true;
163
+ if (!validateToken(token))
164
+ return 'invalid';
165
+ const fingerprint = getDeviceFingerprint();
166
+ const bindingFile = join(getIcoaDir(), 'token-bindings.json');
167
+ // Load existing bindings
168
+ let bindings = {};
169
+ if (existsSync(bindingFile)) {
170
+ try {
171
+ bindings = JSON.parse(readFileSync(bindingFile, 'utf-8'));
172
+ }
173
+ catch { /* ignore */ }
174
+ }
175
+ const tokenHash = hashToken(token);
176
+ const existingDevice = bindings[tokenHash];
177
+ if (existingDevice && existingDevice !== fingerprint) {
178
+ // Token already bound to a different device
179
+ return 'already_bound';
124
180
  }
125
- return false;
181
+ // Bind token to this device
182
+ bindings[tokenHash] = fingerprint;
183
+ writeFileSync(bindingFile, JSON.stringify(bindings, null, 2));
184
+ saveConfig({ accessToken: token.trim().toUpperCase(), deviceFingerprint: fingerprint });
185
+ return 'ok';
186
+ }
187
+ export function isDeviceMatch() {
188
+ const config = getConfig();
189
+ if (!config.deviceFingerprint)
190
+ return true; // Legacy: no fingerprint yet
191
+ return config.deviceFingerprint === getDeviceFingerprint();
126
192
  }
127
193
  export function isFreeCommand(cmd) {
128
194
  return FREE_COMMANDS.has(cmd.toLowerCase());
@@ -0,0 +1,7 @@
1
+ interface TerminalInfo {
2
+ name: string;
3
+ allowed: boolean;
4
+ }
5
+ export declare function detectTerminal(): TerminalInfo;
6
+ export declare function checkTerminal(): void;
7
+ export {};
@@ -0,0 +1,45 @@
1
+ import chalk from 'chalk';
2
+ export function detectTerminal() {
3
+ const termProgram = process.env.TERM_PROGRAM || '';
4
+ const platform = process.platform;
5
+ // macOS: only Apple Terminal
6
+ if (platform === 'darwin') {
7
+ if (termProgram === 'Apple_Terminal') {
8
+ return { name: 'macOS Terminal', allowed: true };
9
+ }
10
+ return { name: termProgram || 'Unknown macOS terminal', allowed: false };
11
+ }
12
+ // Linux: GNOME Terminal, or basic xterm/linux console
13
+ if (platform === 'linux') {
14
+ const isGnome = termProgram === 'gnome-terminal' ||
15
+ process.env.GNOME_TERMINAL_SERVICE !== undefined;
16
+ if (isGnome) {
17
+ return { name: 'GNOME Terminal', allowed: true };
18
+ }
19
+ // Also allow basic linux console (no TERM_PROGRAM, e.g. TTY)
20
+ if (!termProgram && (process.env.TERM === 'linux' || process.env.TERM === 'xterm')) {
21
+ return { name: 'Linux Console', allowed: true };
22
+ }
23
+ return { name: termProgram || 'Unknown Linux terminal', allowed: false };
24
+ }
25
+ // Windows: PowerShell
26
+ if (platform === 'win32') {
27
+ const isPowerShell = !!process.env.PSModulePath;
28
+ if (isPowerShell) {
29
+ return { name: 'PowerShell', allowed: true };
30
+ }
31
+ return { name: 'Windows CMD or other', allowed: false };
32
+ }
33
+ return { name: 'Unknown', allowed: false };
34
+ }
35
+ export function checkTerminal() {
36
+ const info = detectTerminal();
37
+ if (!info.allowed) {
38
+ console.log(chalk.yellow(` ⚠ Unsupported terminal: ${info.name}`));
39
+ console.log(chalk.gray(' Competition requires:'));
40
+ console.log(chalk.gray(' macOS → Terminal.app (system default)'));
41
+ console.log(chalk.gray(' Linux → GNOME Terminal (system default)'));
42
+ console.log(chalk.gray(' Windows → PowerShell (system default)'));
43
+ console.log();
44
+ }
45
+ }
package/dist/repl.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import chalk from 'chalk';
3
3
  import { isConnected, getConfig } from './lib/config.js';
4
- import { isActivated, activateToken, isFreeCommand, recordExit, recordResume } from './lib/access.js';
4
+ import { isActivated, activateToken, isFreeCommand, isDeviceMatch, recordExit, recordResume } from './lib/access.js';
5
5
  const INTERCEPT = '__REPL_NO_EXIT__';
6
6
  export function startRepl(program, resumeMode) {
7
7
  const config = getConfig();
@@ -21,7 +21,14 @@ export function startRepl(program, resumeMode) {
21
21
  }
22
22
  console.log();
23
23
  }
24
- if (connected) {
24
+ // Device mismatch check
25
+ if (activated && !isDeviceMatch()) {
26
+ console.log(chalk.red(' Token was activated on a different device. Access denied.'));
27
+ console.log(chalk.gray(' Contact organizer for assistance.'));
28
+ console.log();
29
+ // Fall through to restricted mode
30
+ }
31
+ else if (connected) {
25
32
  console.log(chalk.gray(' Connected to: ') + chalk.white(config.ctfdUrl));
26
33
  console.log(chalk.gray(' User: ') + chalk.white(config.userName));
27
34
  }
@@ -78,8 +85,12 @@ export function startRepl(program, resumeMode) {
78
85
  // Activate token
79
86
  if (input.startsWith('activate ')) {
80
87
  const token = input.slice(9).trim();
81
- if (activateToken(token)) {
82
- console.log(chalk.green(' Access granted! All commands unlocked.'));
88
+ const result = activateToken(token);
89
+ if (result === 'ok') {
90
+ console.log(chalk.green(' Access granted! Token bound to this device.'));
91
+ }
92
+ else if (result === 'already_bound') {
93
+ console.log(chalk.red(' Token already activated on another device.'));
83
94
  }
84
95
  else {
85
96
  console.log(chalk.red(' Invalid token.'));
@@ -94,9 +105,9 @@ export function startRepl(program, resumeMode) {
94
105
  rl.prompt();
95
106
  return;
96
107
  }
97
- // Token check — only ref allowed without activation
108
+ // Token check — only ref allowed without activation or device mismatch
98
109
  const cmd = input.split(/\s+/)[0].toLowerCase();
99
- if (!isActivated() && !isFreeCommand(cmd)) {
110
+ if ((!isActivated() || !isDeviceMatch()) && !isFreeCommand(cmd)) {
100
111
  console.log(chalk.yellow(' Restricted mode. ') + chalk.gray('Enter your access token:'));
101
112
  console.log(chalk.white(' activate <token>'));
102
113
  console.log();
@@ -105,6 +116,19 @@ export function startRepl(program, resumeMode) {
105
116
  rl.prompt();
106
117
  return;
107
118
  }
119
+ // Check if it's a known command before passing to Commander
120
+ const knownCommands = [
121
+ 'join', 'activate', 'challenges', 'ch', 'open', 'submit', 'flag',
122
+ 'scoreboard', 'sb', 'status', 'time', 'hint', 'hint-b', 'hint-c',
123
+ 'hint-budget', 'ref', 'shell', 'files', 'connect', 'note', 'log',
124
+ 'lang', 'setup', 'model', 'ctf',
125
+ ];
126
+ if (!knownCommands.includes(cmd)) {
127
+ console.log(chalk.yellow(` Unknown command: ${cmd}. Type 'help' for commands.`));
128
+ console.log();
129
+ rl.prompt();
130
+ return;
131
+ }
108
132
  processing = true;
109
133
  const args = mapCommand(input);
110
134
  process.exit = (() => {
@@ -99,6 +99,7 @@ export interface IcoaConfig {
99
99
  geminiApiKey: string;
100
100
  geminiModel: string;
101
101
  accessToken: string;
102
+ deviceFingerprint: string;
102
103
  }
103
104
  export type CompetitionState = 'pre_competition' | 'demo' | 'live' | 'finished' | 'unknown';
104
105
  export type HintLevel = 'A' | 'B' | 'C';
@@ -27,5 +27,6 @@ export const DEFAULT_CONFIG = {
27
27
  geminiApiKey: '',
28
28
  geminiModel: 'gemini-2.5-flash',
29
29
  accessToken: '',
30
+ deviceFingerprint: '',
30
31
  };
31
32
  export const SUPPORTED_LANGUAGES = ['en', 'zh', 'ja', 'ko', 'es'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {