@vainplex/shieldapi-cli 1.3.1 → 2.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vainplex/shieldapi-cli",
3
- "version": "1.3.1",
3
+ "version": "2.0.0",
4
4
  "description": "Security intelligence from your terminal. Pay-per-request with USDC.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,9 @@
12
12
  "README.md",
13
13
  "LICENSE"
14
14
  ],
15
+ "scripts": {
16
+ "test": "node --test tests/*.test.js"
17
+ },
15
18
  "engines": {
16
19
  "node": ">=18"
17
20
  },
@@ -0,0 +1,101 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { apiRequestPost } from '../lib/api.js';
4
+ import { resolveWallet } from '../lib/wallet.js';
5
+ import { riskBadge, sectionHeader } from '../lib/formatter.js';
6
+ import { EXIT } from '../lib/exit.js';
7
+
8
+ export async function checkPromptCommand(prompt, opts) {
9
+ const spinner = opts.quiet ? null : ora({ text: 'Analyzing prompt for injection patterns...', stream: process.stderr }).start();
10
+
11
+ try {
12
+ let inputPrompt = prompt;
13
+
14
+ // Read from stdin if --stdin flag
15
+ if (opts.stdin || !prompt) {
16
+ const chunks = [];
17
+ for await (const chunk of process.stdin) {
18
+ chunks.push(chunk);
19
+ }
20
+ inputPrompt = Buffer.concat(chunks).toString('utf-8').trim();
21
+ }
22
+
23
+ if (!inputPrompt) {
24
+ if (spinner) spinner.fail('No prompt provided');
25
+ process.exitCode = EXIT.USAGE;
26
+ return;
27
+ }
28
+
29
+ const body = {
30
+ prompt: inputPrompt,
31
+ ...(opts.context && { context: opts.context }),
32
+ };
33
+
34
+ const wallet = opts.demo ? null : resolveWallet(opts);
35
+ const data = await apiRequestPost('check-prompt', body, {
36
+ demo: opts.demo,
37
+ wallet,
38
+ });
39
+
40
+ if (spinner) spinner.stop();
41
+
42
+ if (opts.json) {
43
+ console.log(JSON.stringify(data, null, 2));
44
+ } else {
45
+ formatCheckPrompt(data, inputPrompt);
46
+ }
47
+
48
+ // Exit code: 0=clean, 1=injection detected
49
+ process.exitCode = data.isInjection ? 1 : 0;
50
+ } catch (err) {
51
+ if (spinner) spinner.fail(err.message);
52
+ process.exitCode = EXIT.API_ERROR;
53
+ }
54
+ }
55
+
56
+ function formatCheckPrompt(data, prompt) {
57
+ console.log();
58
+
59
+ if (data.isInjection) {
60
+ console.log(chalk.red.bold('⚠️ PROMPT INJECTION DETECTED'));
61
+ console.log();
62
+ console.log(` Confidence: ${chalk.red.bold(Math.round(data.confidence * 100) + '%')}`);
63
+ console.log(` Category: ${chalk.yellow(data.category)}`);
64
+ console.log(` Patterns Checked: ${data.patternsChecked}`);
65
+ console.log(` Scan Duration: ${data.scanDuration}ms`);
66
+
67
+ if (data.patterns?.length > 0) {
68
+ sectionHeader('Detected Patterns');
69
+ console.log();
70
+
71
+ for (const p of data.patterns) {
72
+ console.log(` 🔴 ${chalk.bold(p.type)}`);
73
+ console.log(` ${p.description}`);
74
+ if (p.evidence) {
75
+ const truncated = p.evidence.length > 100
76
+ ? p.evidence.slice(0, 100) + '...'
77
+ : p.evidence;
78
+ console.log(` ${chalk.gray('Evidence:')} ${chalk.dim(truncated)}`);
79
+ }
80
+ }
81
+ }
82
+
83
+ if (data.decodedContent) {
84
+ sectionHeader('Decoded Content');
85
+ console.log(` ${chalk.dim(data.decodedContent.slice(0, 200))}`);
86
+ }
87
+ } else {
88
+ console.log(chalk.green.bold('✅ No injection detected'));
89
+ console.log();
90
+ console.log(` Confidence: ${chalk.green('0%')}`);
91
+ console.log(` Patterns Checked: ${data.patternsChecked}`);
92
+ console.log(` Scan Duration: ${data.scanDuration}ms`);
93
+ }
94
+
95
+ if (data.demo) {
96
+ console.log();
97
+ console.log(chalk.yellow(' ⚠️ Demo mode — results limited. Pay with x402 for full analysis.'));
98
+ }
99
+
100
+ console.log();
101
+ }
@@ -1,35 +1,18 @@
1
- import ora from 'ora';
2
- import chalk from 'chalk';
3
- import { apiRequest } from '../lib/api.js';
4
- import { resolveWallet } from '../lib/wallet.js';
1
+ import { runCommand } from '../lib/command.js';
5
2
  import { formatDomain } from '../lib/formatter.js';
6
- import { exitCodeFromResult, exitCodeFromError } from '../lib/exit.js';
3
+ import { isValidDomain } from '../lib/validator.js';
7
4
 
8
5
  /**
9
6
  * Check domain reputation.
10
7
  */
11
8
  export async function domainCommand(domain, opts) {
12
- const spinner = opts.quiet ? null : ora({ text: `Checking domain: ${domain}`, stream: process.stderr }).start();
13
-
14
- try {
15
- const wallet = opts.demo ? null : resolveWallet(opts);
16
-
17
- const data = await apiRequest('check-domain', { domain }, {
18
- demo: opts.demo,
19
- wallet,
20
- });
21
-
22
- spinner?.stop();
23
-
24
- if (opts.json) {
25
- console.log(JSON.stringify(data, null, 2));
26
- } else {
27
- formatDomain(data);
28
- }
29
-
30
- process.exitCode = exitCodeFromResult(data);
31
- } catch (err) {
32
- spinner?.fail(err.message);
33
- process.exitCode = exitCodeFromError(err);
34
- }
9
+ await runCommand({
10
+ endpoint: 'check-domain',
11
+ paramName: 'domain',
12
+ paramValue: domain,
13
+ formatFn: formatDomain,
14
+ spinnerText: `Checking domain: ${domain}`,
15
+ validateFn: isValidDomain,
16
+ validateMsg: `Invalid domain: "${domain}". Provide a domain without protocol (e.g. example.com).`,
17
+ }, opts);
35
18
  }
@@ -1,35 +1,18 @@
1
- import ora from 'ora';
2
- import chalk from 'chalk';
3
- import { apiRequest } from '../lib/api.js';
4
- import { resolveWallet } from '../lib/wallet.js';
1
+ import { runCommand } from '../lib/command.js';
5
2
  import { formatEmail } from '../lib/formatter.js';
6
- import { exitCodeFromResult, exitCodeFromError } from '../lib/exit.js';
3
+ import { isValidEmail } from '../lib/validator.js';
7
4
 
8
5
  /**
9
6
  * Check an email address for breaches.
10
7
  */
11
8
  export async function emailCommand(email, opts) {
12
- const spinner = opts.quiet ? null : ora({ text: `Checking email: ${email}`, stream: process.stderr }).start();
13
-
14
- try {
15
- const wallet = opts.demo ? null : resolveWallet(opts);
16
-
17
- const data = await apiRequest('check-email', { email }, {
18
- demo: opts.demo,
19
- wallet,
20
- });
21
-
22
- spinner?.stop();
23
-
24
- if (opts.json) {
25
- console.log(JSON.stringify(data, null, 2));
26
- } else {
27
- formatEmail(data, email);
28
- }
29
-
30
- process.exitCode = exitCodeFromResult(data);
31
- } catch (err) {
32
- spinner?.fail(err.message);
33
- process.exitCode = exitCodeFromError(err);
34
- }
9
+ await runCommand({
10
+ endpoint: 'check-email',
11
+ paramName: 'email',
12
+ paramValue: email,
13
+ formatFn: formatEmail,
14
+ spinnerText: `Checking email: ${email}`,
15
+ validateFn: isValidEmail,
16
+ validateMsg: `Invalid email: "${email}". Expected format: user@example.com.`,
17
+ }, opts);
35
18
  }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import chalk from 'chalk';
3
+ import { EXIT } from '../lib/exit.js';
3
4
 
4
5
  /**
5
6
  * Compute SHA-1 hash locally (offline, no API call).
@@ -16,4 +17,6 @@ export async function hashCommand(password, opts) {
16
17
  console.log(` Length: ${password.length} characters`);
17
18
  console.log();
18
19
  }
20
+
21
+ process.exitCode = EXIT.SAFE;
19
22
  }
@@ -1,5 +1,4 @@
1
1
  import ora from 'ora';
2
- import chalk from 'chalk';
3
2
  import { apiRequest } from '../lib/api.js';
4
3
  import { formatHealth } from '../lib/formatter.js';
5
4
  import { EXIT } from '../lib/exit.js';
@@ -1,35 +1,18 @@
1
- import ora from 'ora';
2
- import chalk from 'chalk';
3
- import { apiRequest } from '../lib/api.js';
4
- import { resolveWallet } from '../lib/wallet.js';
1
+ import { runCommand } from '../lib/command.js';
5
2
  import { formatIp } from '../lib/formatter.js';
6
- import { exitCodeFromResult, exitCodeFromError } from '../lib/exit.js';
3
+ import { isValidIPv4 } from '../lib/validator.js';
7
4
 
8
5
  /**
9
6
  * Check IP reputation.
10
7
  */
11
8
  export async function ipCommand(ip, opts) {
12
- const spinner = opts.quiet ? null : ora({ text: `Checking IP: ${ip}`, stream: process.stderr }).start();
13
-
14
- try {
15
- const wallet = opts.demo ? null : resolveWallet(opts);
16
-
17
- const data = await apiRequest('check-ip', { ip }, {
18
- demo: opts.demo,
19
- wallet,
20
- });
21
-
22
- spinner?.stop();
23
-
24
- if (opts.json) {
25
- console.log(JSON.stringify(data, null, 2));
26
- } else {
27
- formatIp(data);
28
- }
29
-
30
- process.exitCode = exitCodeFromResult(data);
31
- } catch (err) {
32
- spinner?.fail(err.message);
33
- process.exitCode = exitCodeFromError(err);
34
- }
9
+ await runCommand({
10
+ endpoint: 'check-ip',
11
+ paramName: 'ip',
12
+ paramValue: ip,
13
+ formatFn: formatIp,
14
+ spinnerText: `Checking IP: ${ip}`,
15
+ validateFn: isValidIPv4,
16
+ validateMsg: `Invalid IPv4 address: "${ip}". Expected format: 1.2.3.4.`,
17
+ }, opts);
35
18
  }
@@ -51,14 +51,28 @@ function readStdin() {
51
51
  } else {
52
52
  // Piped mode: read all data from stdin
53
53
  let data = '';
54
+ let resolved = false;
54
55
  process.stdin.setEncoding('utf8');
55
56
  process.stdin.on('data', (chunk) => { data += chunk; });
56
- process.stdin.on('end', () => resolve(data.trim()));
57
- process.stdin.on('error', reject);
57
+ process.stdin.on('end', () => {
58
+ if (!resolved) {
59
+ resolved = true;
60
+ resolve(data.trim());
61
+ }
62
+ });
63
+ process.stdin.on('error', (err) => {
64
+ if (!resolved) {
65
+ resolved = true;
66
+ reject(err);
67
+ }
68
+ });
58
69
  setTimeout(() => {
59
- process.stdin.destroy();
60
- if (data) resolve(data.trim());
61
- else reject(new Error('Stdin timeout — no input received'));
70
+ if (!resolved) {
71
+ resolved = true;
72
+ process.stdin.destroy();
73
+ if (data) resolve(data.trim());
74
+ else reject(new Error('Stdin timeout — no input received'));
75
+ }
62
76
  }, 10000);
63
77
  }
64
78
  });
@@ -13,6 +13,12 @@ export async function scanCommand(opts) {
13
13
  const params = {};
14
14
 
15
15
  if (opts.password) {
16
+ // Shell history warning (C2: scan --password leak)
17
+ if (!opts.quiet && process.stderr.isTTY) {
18
+ process.stderr.write(
19
+ chalk.yellow('⚠ Password may appear in shell history. Use `shieldapi password --stdin` for sensitive passwords.\n')
20
+ );
21
+ }
16
22
  params.password_hash = createHash('sha1').update(opts.password).digest('hex').toUpperCase();
17
23
  }
18
24
  if (opts.email) params.email = opts.email;
@@ -0,0 +1,157 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, extname, basename } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import { apiRequestPost } from '../lib/api.js';
6
+ import { resolveWallet } from '../lib/wallet.js';
7
+ import { riskBadge, sectionHeader, kvLine, formatNumber } from '../lib/formatter.js';
8
+ import { EXIT } from '../lib/exit.js';
9
+
10
+ const SCANNABLE_EXTENSIONS = new Set([
11
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.sh', '.bash',
12
+ '.md', '.markdown', '.json', '.yml', '.yaml', '.toml', '.env',
13
+ '.bat', '.ps1', '.rb', '.go', '.rs', '.java',
14
+ ]);
15
+
16
+ function loadSkillFiles(path) {
17
+ const files = [];
18
+ const stat = statSync(path);
19
+
20
+ if (stat.isFile()) {
21
+ const content = readFileSync(path, 'utf-8');
22
+ return [{ name: basename(path), content }];
23
+ }
24
+
25
+ if (stat.isDirectory()) {
26
+ const entries = readdirSync(path, { withFileTypes: true });
27
+ for (const entry of entries) {
28
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
29
+ const fullPath = join(path, entry.name);
30
+ if (entry.isFile()) {
31
+ const ext = extname(entry.name).toLowerCase();
32
+ if (SCANNABLE_EXTENSIONS.has(ext) || entry.name === 'Dockerfile' || entry.name === 'Makefile') {
33
+ try {
34
+ const content = readFileSync(fullPath, 'utf-8');
35
+ if (content.length <= 100 * 1024) { // 100KB per file max
36
+ files.push({ name: entry.name, content });
37
+ }
38
+ } catch { /* skip unreadable files */ }
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ return files;
45
+ }
46
+
47
+ export async function scanSkillCommand(target, opts) {
48
+ const spinner = opts.quiet ? null : ora({ text: `Scanning skill: ${target || 'stdin'}`, stream: process.stderr }).start();
49
+
50
+ try {
51
+ let body = {};
52
+
53
+ if (target && existsSync(target)) {
54
+ // Local file or directory
55
+ const files = loadSkillFiles(target);
56
+ if (files.length === 0) {
57
+ if (spinner) spinner.fail('No scannable files found');
58
+ process.exitCode = EXIT.USAGE;
59
+ return;
60
+ }
61
+
62
+ // If there's a SKILL.md, use it as the skill field
63
+ const skillMd = files.find(f => f.name.toLowerCase() === 'skill.md');
64
+ if (skillMd) {
65
+ body.skill = skillMd.content;
66
+ }
67
+ body.files = files;
68
+ if (spinner) spinner.text = `Scanning ${files.length} files from ${target}`;
69
+ } else if (target) {
70
+ // Assume it's raw skill content or a skill name
71
+ body.skill = target;
72
+ } else {
73
+ if (spinner) spinner.fail('No target specified');
74
+ process.exitCode = EXIT.USAGE;
75
+ return;
76
+ }
77
+
78
+ const wallet = opts.demo ? null : resolveWallet(opts);
79
+ const data = await apiRequestPost('scan-skill', body, {
80
+ demo: opts.demo,
81
+ wallet,
82
+ });
83
+
84
+ if (spinner) spinner.stop();
85
+
86
+ if (opts.json) {
87
+ console.log(JSON.stringify(data, null, 2));
88
+ } else {
89
+ formatScanSkill(data);
90
+ }
91
+
92
+ // Exit code: 0=clean, 1=findings, 2=critical
93
+ if (data.riskLevel === 'CLEAN') {
94
+ process.exitCode = 0;
95
+ } else if (data.riskLevel === 'CRITICAL') {
96
+ process.exitCode = 2;
97
+ } else {
98
+ process.exitCode = 1;
99
+ }
100
+ } catch (err) {
101
+ if (spinner) spinner.fail(err.message);
102
+ process.exitCode = EXIT.API_ERROR;
103
+ }
104
+ }
105
+
106
+ function formatScanSkill(data) {
107
+ console.log();
108
+ console.log(chalk.bold.underline('🛡️ Skill Safety Scan Results'));
109
+ console.log();
110
+
111
+ // Risk badge
112
+ const badge = riskBadge(data.riskLevel?.toLowerCase(), data.riskScore);
113
+ console.log(` Risk Level: ${badge}`);
114
+ console.log(` Files Analyzed: ${data.filesAnalyzed || '?'}`);
115
+ console.log(` Patterns Checked: ${formatNumber(data.totalPatterns || 0)}`);
116
+ console.log(` Scan Duration: ${data.scanDuration}ms`);
117
+
118
+ if (data.findings?.length > 0) {
119
+ sectionHeader(`Findings (${data.findings.length})`);
120
+ console.log();
121
+
122
+ const bySeverity = { CRITICAL: [], HIGH: [], MEDIUM: [], LOW: [] };
123
+ for (const f of data.findings) {
124
+ (bySeverity[f.severity] || bySeverity.LOW).push(f);
125
+ }
126
+
127
+ for (const [sev, findings] of Object.entries(bySeverity)) {
128
+ if (findings.length === 0) continue;
129
+ const color = sev === 'CRITICAL' ? chalk.red.bold
130
+ : sev === 'HIGH' ? chalk.red
131
+ : sev === 'MEDIUM' ? chalk.yellow
132
+ : chalk.gray;
133
+
134
+ for (const f of findings) {
135
+ console.log(` ${color(`[${sev}]`)} ${f.description}`);
136
+ if (f.location) console.log(` 📍 ${chalk.gray(f.location)}`);
137
+ if (f.category) console.log(` 📂 ${chalk.gray(f.category)}`);
138
+ }
139
+ }
140
+ } else {
141
+ console.log();
142
+ console.log(chalk.green.bold(' ✅ No security issues detected'));
143
+ }
144
+
145
+ // Summary
146
+ if (data.summary) {
147
+ console.log();
148
+ console.log(` ${chalk.gray(data.summary)}`);
149
+ }
150
+
151
+ if (data.demo) {
152
+ console.log();
153
+ console.log(chalk.yellow(' ⚠️ Demo mode — results limited. Pay with x402 for full scan.'));
154
+ }
155
+
156
+ console.log();
157
+ }
@@ -1,35 +1,18 @@
1
- import ora from 'ora';
2
- import chalk from 'chalk';
3
- import { apiRequest } from '../lib/api.js';
4
- import { resolveWallet } from '../lib/wallet.js';
1
+ import { runCommand } from '../lib/command.js';
5
2
  import { formatUrl } from '../lib/formatter.js';
6
- import { exitCodeFromResult, exitCodeFromError } from '../lib/exit.js';
3
+ import { isValidUrl } from '../lib/validator.js';
7
4
 
8
5
  /**
9
6
  * Check URL safety.
10
7
  */
11
8
  export async function urlCommand(url, opts) {
12
- const spinner = opts.quiet ? null : ora({ text: `Checking URL: ${url}`, stream: process.stderr }).start();
13
-
14
- try {
15
- const wallet = opts.demo ? null : resolveWallet(opts);
16
-
17
- const data = await apiRequest('check-url', { url }, {
18
- demo: opts.demo,
19
- wallet,
20
- });
21
-
22
- spinner?.stop();
23
-
24
- if (opts.json) {
25
- console.log(JSON.stringify(data, null, 2));
26
- } else {
27
- formatUrl(data);
28
- }
29
-
30
- process.exitCode = exitCodeFromResult(data);
31
- } catch (err) {
32
- spinner?.fail(err.message);
33
- process.exitCode = exitCodeFromError(err);
34
- }
9
+ await runCommand({
10
+ endpoint: 'check-url',
11
+ paramName: 'url',
12
+ paramValue: url,
13
+ formatFn: formatUrl,
14
+ spinnerText: `Checking URL: ${url}`,
15
+ validateFn: isValidUrl,
16
+ validateMsg: `Invalid URL: "${url}". Expected format: https://example.com.`,
17
+ }, opts);
35
18
  }
package/src/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
1
4
  import { Command } from 'commander';
2
5
  import { passwordCommand } from './commands/password.js';
3
6
  import { emailCommand } from './commands/email.js';
@@ -7,6 +10,11 @@ import { urlCommand } from './commands/url.js';
7
10
  import { scanCommand } from './commands/scan.js';
8
11
  import { healthCommand } from './commands/health.js';
9
12
  import { hashCommand } from './commands/hash.js';
13
+ import { scanSkillCommand } from './commands/scanSkill.js';
14
+ import { checkPromptCommand } from './commands/checkPrompt.js';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
10
18
 
11
19
  export function run(argv) {
12
20
  const program = new Command();
@@ -14,11 +22,10 @@ export function run(argv) {
14
22
  program
15
23
  .name('shieldapi')
16
24
  .description('🛡️ ShieldAPI CLI — Security intelligence from your terminal. Pay-per-request with USDC.')
17
- .version('1.3.1')
25
+ .version(pkg.version)
18
26
  .option('--wallet <key>', 'Private key for x402 payments (or set SHIELDAPI_WALLET_KEY)')
19
27
  .option('--json', 'Output raw JSON instead of formatted output')
20
28
  .option('--no-color', 'Disable colors')
21
- .option('-y, --yes', 'Skip payment confirmation prompts')
22
29
  .option('-q, --quiet', 'Suppress non-essential output (spinners, warnings)');
23
30
 
24
31
  program
@@ -105,5 +112,28 @@ export function run(argv) {
105
112
  hashCommand(password, { ...globalOpts, ...cmdOpts });
106
113
  });
107
114
 
115
+ program
116
+ .command('scan-skill')
117
+ .description('Scan an AI skill or plugin for security issues (8 risk categories)')
118
+ .argument('[target]', 'Path to SKILL.md, directory, or raw skill content')
119
+ .option('--demo', 'Use demo mode (free, no wallet needed)')
120
+ .action((target, cmdOpts, cmd) => {
121
+ const globalOpts = cmd.parent.opts();
122
+ scanSkillCommand(target, { ...globalOpts, ...cmdOpts });
123
+ });
124
+
125
+ program
126
+ .command('check-prompt')
127
+ .description('Check text for prompt injection patterns (200+ detectors)')
128
+ .argument('[prompt]', 'Text to analyze (or use --stdin)')
129
+ .option('--demo', 'Use demo mode (free, no wallet needed)')
130
+ .option('--stdin', 'Read prompt from stdin')
131
+ .option('--context <context>', 'Context hint: user-input, skill-prompt, system-prompt', 'user-input')
132
+ .action((prompt, cmdOpts, cmd) => {
133
+ const globalOpts = cmd.parent.opts();
134
+ checkPromptCommand(prompt, { ...globalOpts, ...cmdOpts });
135
+ });
136
+
108
137
  program.parse(argv);
138
+
109
139
  }
package/src/lib/api.js CHANGED
@@ -73,3 +73,50 @@ export class ApiError extends Error {
73
73
  this.status = status;
74
74
  }
75
75
  }
76
+
77
+ /**
78
+ * POST request for Phase 2 endpoints (scan-skill, check-prompt).
79
+ * Uses @x402/fetch for paid requests, plain fetch for demo.
80
+ */
81
+ export async function apiRequestPost(endpoint, body = {}, { demo = false, wallet = null } = {}) {
82
+ const query = demo ? '?demo=true' : '';
83
+ const url = `${BASE_URL}/${endpoint}${query}`;
84
+
85
+ const fetchOptions = {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify(body),
89
+ };
90
+
91
+ if (demo || endpoint === 'health') {
92
+ const res = await fetch(url, fetchOptions);
93
+ if (!res.ok) {
94
+ const body = await res.text().catch(() => '');
95
+ throw new ApiError(res.status, body || res.statusText);
96
+ }
97
+ return res.json();
98
+ }
99
+
100
+ if (!wallet?.signer) {
101
+ throw new Error(
102
+ 'No wallet configured. Use --wallet <key> or set SHIELDAPI_WALLET_KEY environment variable.'
103
+ );
104
+ }
105
+
106
+ const { x402Client } = await import('@x402/core/client');
107
+ const { registerExactEvmScheme } = await import('@x402/evm/exact/client');
108
+ const { wrapFetchWithPayment } = await import('@x402/fetch');
109
+
110
+ const client = new x402Client();
111
+ registerExactEvmScheme(client, { signer: wallet.signer });
112
+
113
+ const paidFetch = wrapFetchWithPayment(fetch, client);
114
+ const res = await paidFetch(url, fetchOptions);
115
+
116
+ if (!res.ok) {
117
+ const body = await res.text().catch(() => '');
118
+ throw new ApiError(res.status, body || res.statusText);
119
+ }
120
+
121
+ return res.json();
122
+ }
@@ -0,0 +1,54 @@
1
+ import ora from 'ora';
2
+ import chalk from 'chalk';
3
+ import { apiRequest } from './api.js';
4
+ import { resolveWallet } from './wallet.js';
5
+ import { exitCodeFromResult, exitCodeFromError, EXIT } from './exit.js';
6
+
7
+ /**
8
+ * Generic command runner that extracts common boilerplate from
9
+ * domain, email, ip, and url commands.
10
+ *
11
+ * @param {object} config
12
+ * @param {string} config.endpoint - API endpoint (e.g. 'check-domain')
13
+ * @param {string} config.paramName - Query parameter name (e.g. 'domain')
14
+ * @param {string} config.paramValue - The user-supplied value
15
+ * @param {function} config.formatFn - Formatter function (data, paramValue) => void
16
+ * @param {string} config.spinnerText - Text for the spinner
17
+ * @param {function} [config.validateFn] - Optional validator (value) => boolean
18
+ * @param {string} [config.validateMsg] - Error message if validation fails
19
+ * @param {object} opts - Merged global + command options
20
+ */
21
+ export async function runCommand(config, opts) {
22
+ const { endpoint, paramName, paramValue, formatFn, spinnerText, validateFn, validateMsg } = config;
23
+
24
+ // Input validation (S2)
25
+ if (validateFn && !validateFn(paramValue)) {
26
+ process.stderr.write(chalk.red(`Error: ${validateMsg || 'Invalid input.'}\n`));
27
+ process.exitCode = EXIT.USAGE;
28
+ return;
29
+ }
30
+
31
+ const spinner = opts.quiet ? null : ora({ text: spinnerText, stream: process.stderr }).start();
32
+
33
+ try {
34
+ const wallet = opts.demo ? null : resolveWallet(opts);
35
+
36
+ const data = await apiRequest(endpoint, { [paramName]: paramValue }, {
37
+ demo: opts.demo,
38
+ wallet,
39
+ });
40
+
41
+ spinner?.stop();
42
+
43
+ if (opts.json) {
44
+ console.log(JSON.stringify(data, null, 2));
45
+ } else {
46
+ formatFn(data, paramValue);
47
+ }
48
+
49
+ process.exitCode = exitCodeFromResult(data);
50
+ } catch (err) {
51
+ spinner?.fail(err.message);
52
+ process.exitCode = exitCodeFromError(err);
53
+ }
54
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Input validators for fast-fail before API calls.
3
+ */
4
+
5
+ /**
6
+ * Check if string is a plausible email address.
7
+ * @param {string} s
8
+ * @returns {boolean}
9
+ */
10
+ export function isValidEmail(s) {
11
+ if (!s || typeof s !== 'string') return false;
12
+ const parts = s.split('@');
13
+ if (parts.length !== 2) return false;
14
+ const [local, domain] = parts;
15
+ return local.length > 0 && domain.includes('.') && domain.length > 2;
16
+ }
17
+
18
+ /**
19
+ * Check if string is a valid IPv4 address.
20
+ * @param {string} s
21
+ * @returns {boolean}
22
+ */
23
+ export function isValidIPv4(s) {
24
+ if (!s || typeof s !== 'string') return false;
25
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(s);
26
+ }
27
+
28
+ /**
29
+ * Check if string is a valid domain (no protocol prefix, contains a dot).
30
+ * @param {string} s
31
+ * @returns {boolean}
32
+ */
33
+ export function isValidDomain(s) {
34
+ if (!s || typeof s !== 'string') return false;
35
+ if (s.startsWith('http://') || s.startsWith('https://')) return false;
36
+ return s.includes('.') && s.length > 3 && !s.includes(' ');
37
+ }
38
+
39
+ /**
40
+ * Check if string is a valid URL (starts with http:// or https://).
41
+ * @param {string} s
42
+ * @returns {boolean}
43
+ */
44
+ export function isValidUrl(s) {
45
+ if (!s || typeof s !== 'string') return false;
46
+ return s.startsWith('http://') || s.startsWith('https://');
47
+ }
package/src/lib/wallet.js CHANGED
@@ -31,7 +31,7 @@ export function createWallet(privateKey) {
31
31
 
32
32
  return { signer, address: account.address };
33
33
  } catch (err) {
34
- throw new Error(`Invalid private key: ${err.message}`);
34
+ throw new Error('Invalid private key. Expected 64 hex characters (with or without 0x prefix).');
35
35
  }
36
36
  }
37
37