@deepv-code/safe-npm 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/README.zh-CN.md +121 -0
  4. package/dist/cli/args-parser.d.ts +11 -0
  5. package/dist/cli/args-parser.js +36 -0
  6. package/dist/cli/check.d.ts +5 -0
  7. package/dist/cli/check.js +126 -0
  8. package/dist/cli/proxy.d.ts +1 -0
  9. package/dist/cli/proxy.js +4 -0
  10. package/dist/data/popular-packages.d.ts +9 -0
  11. package/dist/data/popular-packages.js +83 -0
  12. package/dist/i18n/en.d.ts +43 -0
  13. package/dist/i18n/en.js +50 -0
  14. package/dist/i18n/index.d.ts +5 -0
  15. package/dist/i18n/index.js +11 -0
  16. package/dist/i18n/zh.d.ts +2 -0
  17. package/dist/i18n/zh.js +50 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +77 -0
  20. package/dist/scanner/code-analyzer.d.ts +2 -0
  21. package/dist/scanner/code-analyzer.js +130 -0
  22. package/dist/scanner/index.d.ts +3 -0
  23. package/dist/scanner/index.js +163 -0
  24. package/dist/scanner/patterns/exfiltration.d.ts +7 -0
  25. package/dist/scanner/patterns/exfiltration.js +49 -0
  26. package/dist/scanner/patterns/miner.d.ts +5 -0
  27. package/dist/scanner/patterns/miner.js +32 -0
  28. package/dist/scanner/patterns/obfuscation.d.ts +15 -0
  29. package/dist/scanner/patterns/obfuscation.js +110 -0
  30. package/dist/scanner/types.d.ts +26 -0
  31. package/dist/scanner/types.js +1 -0
  32. package/dist/scanner/typosquatting.d.ts +3 -0
  33. package/dist/scanner/typosquatting.js +126 -0
  34. package/dist/scanner/virustotal.d.ts +7 -0
  35. package/dist/scanner/virustotal.js +249 -0
  36. package/dist/scanner/vulnerability.d.ts +2 -0
  37. package/dist/scanner/vulnerability.js +42 -0
  38. package/dist/tui/App.d.ts +2 -0
  39. package/dist/tui/App.js +67 -0
  40. package/dist/tui/index.d.ts +1 -0
  41. package/dist/tui/index.js +6 -0
  42. package/dist/tui/screens/CheckScreen.d.ts +7 -0
  43. package/dist/tui/screens/CheckScreen.js +92 -0
  44. package/dist/tui/screens/PopularScreen.d.ts +7 -0
  45. package/dist/tui/screens/PopularScreen.js +39 -0
  46. package/dist/tui/screens/SettingsScreen.d.ts +6 -0
  47. package/dist/tui/screens/SettingsScreen.js +64 -0
  48. package/dist/utils/config.d.ts +16 -0
  49. package/dist/utils/config.js +69 -0
  50. package/dist/utils/npm-package.d.ts +38 -0
  51. package/dist/utils/npm-package.js +191 -0
  52. package/dist/utils/npm-runner.d.ts +7 -0
  53. package/dist/utils/npm-runner.js +56 -0
  54. package/package.json +48 -0
@@ -0,0 +1,26 @@
1
+ export type RiskLevel = 'fatal' | 'high' | 'warning' | 'safe';
2
+ export interface ScanResult {
3
+ packageName: string;
4
+ version?: string;
5
+ riskLevel: RiskLevel;
6
+ issues: ScanIssue[];
7
+ checks: ScanCheck[];
8
+ canBypass: boolean;
9
+ suggestedPackage?: string;
10
+ }
11
+ export interface ScanCheck {
12
+ name: string;
13
+ status: 'pass' | 'fail' | 'skipped' | 'error';
14
+ description?: string;
15
+ }
16
+ export interface ScanIssue {
17
+ type: 'virus' | 'typosquat' | 'suspicious_code' | 'cve' | 'miner';
18
+ severity: RiskLevel;
19
+ message: string;
20
+ details?: string;
21
+ }
22
+ export interface ScanOptions {
23
+ offline?: boolean;
24
+ skipVirustotal?: boolean;
25
+ }
26
+ export type ProgressCallback = (message: string, completed: number, total: number) => void;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { ScanIssue } from './types.js';
2
+ export declare function findClosestPopularPackage(packageName: string): string | null;
3
+ export declare function scanTyposquatting(packageName: string): Promise<ScanIssue[]>;
@@ -0,0 +1,126 @@
1
+ import { popularPackages } from '../data/popular-packages.js';
2
+ import { t } from '../i18n/index.js';
3
+ // Calculate Levenshtein distance
4
+ function levenshtein(a, b) {
5
+ const matrix = [];
6
+ for (let i = 0; i <= b.length; i++) {
7
+ matrix[i] = [i];
8
+ }
9
+ for (let j = 0; j <= a.length; j++) {
10
+ matrix[0][j] = j;
11
+ }
12
+ for (let i = 1; i <= b.length; i++) {
13
+ for (let j = 1; j <= a.length; j++) {
14
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
15
+ matrix[i][j] = matrix[i - 1][j - 1];
16
+ }
17
+ else {
18
+ matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
19
+ }
20
+ }
21
+ }
22
+ return matrix[b.length][a.length];
23
+ }
24
+ // Common typosquatting patterns
25
+ function checkCommonPatterns(name, target) {
26
+ // Same name with hyphens/underscores swapped
27
+ if (name.replace(/-/g, '_') === target.replace(/-/g, '_'))
28
+ return false;
29
+ if (name.replace(/_/g, '-') === target.replace(/_/g, '-'))
30
+ return false;
31
+ // Character substitution (0 for o, 1 for l, etc.)
32
+ const normalized = name
33
+ .replace(/0/g, 'o')
34
+ .replace(/1/g, 'l')
35
+ .replace(/3/g, 'e')
36
+ .replace(/4/g, 'a')
37
+ .replace(/5/g, 's');
38
+ if (normalized === target)
39
+ return true;
40
+ return false;
41
+ }
42
+ function parsePackageName(name) {
43
+ if (name.startsWith('@')) {
44
+ const parts = name.split('/');
45
+ return { scope: parts[0], name: parts[1] || '' };
46
+ }
47
+ return { scope: null, name: name };
48
+ }
49
+ export function findClosestPopularPackage(packageName) {
50
+ const lowerName = packageName.toLowerCase();
51
+ const targetParsed = parsePackageName(lowerName);
52
+ let bestMatch = null;
53
+ let minDistance = Infinity;
54
+ for (const popular of popularPackages) {
55
+ const distance = levenshtein(lowerName, popular);
56
+ // Exact match is not a suggestion
57
+ if (distance === 0)
58
+ return null;
59
+ if (distance <= 3 && distance < minDistance) { // Slightly looser threshold for "Did you mean?"
60
+ minDistance = distance;
61
+ bestMatch = popular;
62
+ }
63
+ // Check scoped similarity for suggestions
64
+ const popularParsed = parsePackageName(popular);
65
+ if (popularParsed.scope && targetParsed.name) {
66
+ const baseDist = levenshtein(targetParsed.name, popularParsed.name);
67
+ if (baseDist <= 1) {
68
+ return popular; // Strong signal: base name matches scoped package
69
+ }
70
+ }
71
+ }
72
+ return bestMatch;
73
+ }
74
+ export async function scanTyposquatting(packageName) {
75
+ const issues = [];
76
+ const lowerName = packageName.toLowerCase();
77
+ // Skip if it's an exact match of a popular package
78
+ if (popularPackages.includes(lowerName)) {
79
+ return issues;
80
+ }
81
+ const targetParsed = parsePackageName(lowerName);
82
+ for (const popular of popularPackages) {
83
+ // 1. Standard full-name check
84
+ const distance = levenshtein(lowerName, popular);
85
+ if (distance > 0 && distance <= 2) {
86
+ issues.push({
87
+ type: 'typosquat',
88
+ severity: 'fatal',
89
+ message: t('typosquatDetected'),
90
+ details: `Similar to popular package "${popular}" (distance: ${distance})`,
91
+ });
92
+ break;
93
+ }
94
+ if (checkCommonPatterns(lowerName, popular)) {
95
+ issues.push({
96
+ type: 'typosquat',
97
+ severity: 'fatal',
98
+ message: t('typosquatDetected'),
99
+ details: `Suspicious similarity to "${popular}"`,
100
+ });
101
+ break;
102
+ }
103
+ // 2. Scoped package protection logic
104
+ const popularParsed = parsePackageName(popular);
105
+ if (popularParsed.scope) {
106
+ // If the popular package IS scoped (e.g., @anthropic-ai/claude-code)
107
+ // Check A: The user is trying to install the "unscoped" version (e.g. claude-code)
108
+ // or a version in a different scope (e.g. @fake/claude-code)
109
+ const baseNameDistance = levenshtein(targetParsed.name, popularParsed.name);
110
+ // If the base name is identical or extremely similar
111
+ if (baseNameDistance <= 1) { // Very strict for scoped bases
112
+ // And the scopes are different (or target has no scope)
113
+ if (targetParsed.scope !== popularParsed.scope) {
114
+ issues.push({
115
+ type: 'typosquat',
116
+ severity: 'fatal',
117
+ message: t('typosquatDetected'),
118
+ details: `Scope Hijacking Detected: This package "${packageName}" mimics the official package "${popular}". Verify the scope carefully!`,
119
+ });
120
+ break;
121
+ }
122
+ }
123
+ }
124
+ }
125
+ return issues;
126
+ }
@@ -0,0 +1,7 @@
1
+ import type { ScanIssue, ScanOptions } from './types.js';
2
+ interface VTScanResult {
3
+ issues: ScanIssue[];
4
+ info?: string;
5
+ }
6
+ export declare function scanVirusTotal(packageName: string, options?: ScanOptions): Promise<VTScanResult>;
7
+ export {};
@@ -0,0 +1,249 @@
1
+ import { t } from '../i18n/index.js';
2
+ import { getConfig } from '../utils/config.js';
3
+ import { getPackageInfo, downloadPackageAndHash } from '../utils/npm-package.js';
4
+ import axios from 'axios';
5
+ import FormData from 'form-data';
6
+ // Local blacklist cache for offline mode
7
+ // Source: Known malicious npm packages from security advisories
8
+ const localBlacklist = new Set([
9
+ // Real known malicious packages
10
+ 'event-stream', // Compromised in 2018, version 3.3.6
11
+ 'flatmap-stream', // Malicious dependency of event-stream
12
+ 'eslint-scope', // Compromised in 2018
13
+ 'eslint-config-eslint', // Typosquat
14
+ 'crossenv', // Typosquat of cross-env
15
+ 'cross-env.js', // Typosquat of cross-env
16
+ 'mongose', // Typosquat of mongoose
17
+ 'mariadb', // Malicious package (not the real one)
18
+ 'discordi.js', // Typosquat of discord.js
19
+ 'discord.js-user', // Malicious
20
+ 'colors-js', // Typosquat
21
+ 'nodejs-base64', // Malicious
22
+ 'nodesass', // Typosquat of node-sass
23
+ 'nodefabric', // Malicious
24
+ 'node-fabric', // Malicious
25
+ 'fabric-js', // Typosquat
26
+ 'grpc-js', // Typosquat of @grpc/grpc-js
27
+ 'sqlite.js', // Typosquat
28
+ 'sqlite-js', // Typosquat
29
+ 'mssql.js', // Typosquat
30
+ 'mssql-node', // Typosquat
31
+ 'lodash-js', // Typosquat
32
+ 'loadsh', // Typosquat
33
+ 'lodashs', // Typosquat
34
+ 'underscore-js', // Typosquat
35
+ 'underscores', // Typosquat
36
+ 'babel-preset-es2016', // Typosquat
37
+ 'babelpreset-es2015', // Typosquat
38
+ 'rc-js', // Typosquat
39
+ 'rpc-websocket', // Malicious
40
+ 'jquey', // Typosquat of jquery
41
+ 'jquery.js', // Typosquat
42
+ 'jqeury', // Typosquat
43
+ 'boostrap', // Typosquat of bootstrap
44
+ 'bootstrap-css', // Typosquat
45
+ 'angularjs', // Typosquat
46
+ 'angular.js', // Typosquat
47
+ 'react.js', // Typosquat
48
+ 'react-js', // Typosquat
49
+ 'vue-js', // Typosquat
50
+ 'twilio-npm', // Malicious
51
+ 'discord-selfbot-v13', // Malicious
52
+ 'discord-lofy', // Malicious
53
+ ]);
54
+ // Known malicious package version ranges
55
+ const maliciousVersions = {
56
+ 'event-stream': ['3.3.6'],
57
+ 'ua-parser-js': ['0.7.29', '0.8.0', '1.0.0'],
58
+ 'coa': ['2.0.3', '2.0.4', '2.1.1', '2.1.3', '3.0.1', '3.1.3'],
59
+ 'rc': ['1.2.9', '1.3.9', '2.3.9'],
60
+ };
61
+ /**
62
+ * Get a special upload URL for files larger than 32MB
63
+ */
64
+ async function getLargeFileUploadUrl(apiKey) {
65
+ try {
66
+ const response = await axios.get('https://www.virustotal.com/api/v3/files/upload_url', {
67
+ headers: { 'x-apikey': apiKey },
68
+ timeout: 30000,
69
+ });
70
+ return response.data?.data || null;
71
+ }
72
+ catch (e) {
73
+ console.error('Failed to get VT upload URL', e);
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Upload file to VirusTotal for analysis
79
+ */
80
+ async function uploadFileToVirusTotal(buffer, filename, apiKey) {
81
+ try {
82
+ const formData = new FormData();
83
+ formData.append('file', buffer, { filename });
84
+ let uploadUrl = 'https://www.virustotal.com/api/v3/files';
85
+ // VirusTotal requires a special upload URL for files > 32MB
86
+ // Using 32MB limit to be safe (API doc says 32MB)
87
+ if (buffer.length > 32 * 1024 * 1024) {
88
+ const customUrl = await getLargeFileUploadUrl(apiKey);
89
+ if (customUrl) {
90
+ uploadUrl = customUrl;
91
+ }
92
+ }
93
+ const response = await axios.post(uploadUrl, formData, {
94
+ headers: {
95
+ 'x-apikey': apiKey,
96
+ ...formData.getHeaders(),
97
+ },
98
+ // Increase timeout significantly for uploads (10 minutes)
99
+ timeout: 600000,
100
+ maxBodyLength: Infinity,
101
+ maxContentLength: Infinity
102
+ });
103
+ // Returns the analysis ID (e.g., "ZmY5...")
104
+ return response.data?.data?.id || null;
105
+ }
106
+ catch (error) {
107
+ if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
108
+ console.error('VirusTotal Upload Timeout: File too large or network too slow');
109
+ }
110
+ else {
111
+ console.error('VirusTotal Upload Error:', error?.message || error);
112
+ }
113
+ return null;
114
+ }
115
+ }
116
+ /**
117
+ * Query VirusTotal API for file hash
118
+ */
119
+ async function queryVirusTotal(sha256, apiKey) {
120
+ try {
121
+ const response = await axios.get(`https://www.virustotal.com/api/v3/files/${sha256}`, {
122
+ headers: {
123
+ 'x-apikey': apiKey,
124
+ },
125
+ timeout: 30000,
126
+ validateStatus: (status) => status === 200 || status === 404,
127
+ });
128
+ if (response.status === 404) {
129
+ // File not found in VirusTotal - not necessarily bad
130
+ return null;
131
+ }
132
+ const stats = response.data?.data?.attributes?.last_analysis_stats;
133
+ const results = response.data?.data?.attributes?.last_analysis_results;
134
+ if (!stats) {
135
+ return null;
136
+ }
137
+ // Collect detection names
138
+ const detections = [];
139
+ if (results) {
140
+ for (const [engine, result] of Object.entries(results)) {
141
+ if (result.category === 'malicious' && result.result) {
142
+ detections.push(`${engine}: ${result.result}`);
143
+ }
144
+ }
145
+ }
146
+ return {
147
+ malicious: stats.malicious || 0,
148
+ suspicious: stats.suspicious || 0,
149
+ detections,
150
+ };
151
+ }
152
+ catch (error) {
153
+ console.error('VirusTotal API error:', error);
154
+ return null;
155
+ }
156
+ }
157
+ export async function scanVirusTotal(packageName, options = {}) {
158
+ const issues = [];
159
+ const config = getConfig();
160
+ // Parse package name without version
161
+ let baseName = packageName;
162
+ if (packageName.includes('@') && !packageName.startsWith('@')) {
163
+ baseName = packageName.split('@')[0];
164
+ }
165
+ else if (packageName.startsWith('@')) {
166
+ const lastAt = packageName.lastIndexOf('@');
167
+ if (lastAt > 0) {
168
+ baseName = packageName.substring(0, lastAt);
169
+ }
170
+ }
171
+ // Check local blacklist first
172
+ if (localBlacklist.has(baseName)) {
173
+ issues.push({
174
+ type: 'virus',
175
+ severity: 'fatal',
176
+ message: t('virusDetected'),
177
+ details: `Package "${baseName}" is in the known malicious packages blacklist`,
178
+ });
179
+ return { issues, info: 'Blocked by local blacklist' };
180
+ }
181
+ // Get package info to check version
182
+ const packageInfo = await getPackageInfo(packageName);
183
+ if (packageInfo) {
184
+ // Check for known malicious versions
185
+ const badVersions = maliciousVersions[packageInfo.name];
186
+ if (badVersions && badVersions.includes(packageInfo.version)) {
187
+ issues.push({
188
+ type: 'virus',
189
+ severity: 'fatal',
190
+ message: t('virusDetected'),
191
+ details: `Version ${packageInfo.version} of "${packageInfo.name}" is known to be compromised`,
192
+ });
193
+ return { issues, info: 'Blocked by malicious version list' };
194
+ }
195
+ }
196
+ // If offline mode or no API key, skip online check
197
+ if (options.offline || config.offline) {
198
+ return { issues, info: 'Skipped (Offline Mode)' };
199
+ }
200
+ if (!config.virustotal.apiKey || !config.virustotal.enabled) {
201
+ // No API key configured, skip VT check but don't warn
202
+ return { issues, info: 'Skipped (No API Key)' };
203
+ }
204
+ // Download package and calculate hash
205
+ if (packageInfo?.tarballUrl) {
206
+ const downloadResult = await downloadPackageAndHash(packageInfo.tarballUrl);
207
+ if (downloadResult) {
208
+ const vtResult = await queryVirusTotal(downloadResult.hash, config.virustotal.apiKey);
209
+ if (vtResult) {
210
+ const totalEngines = (vtResult.malicious || 0) + (vtResult.suspicious || 0) + 70; // Estimate
211
+ const info = `Scanned Hash: ${downloadResult.hash.substring(0, 8)}... | Detections: ${vtResult.malicious}/${totalEngines}`;
212
+ if (vtResult.malicious > 0) {
213
+ issues.push({
214
+ type: 'virus',
215
+ severity: 'fatal',
216
+ message: t('virusDetected'),
217
+ details: `VirusTotal: ${vtResult.malicious} security vendors flagged this package as malicious. Detections: ${vtResult.detections.slice(0, 3).join(', ')}${vtResult.detections.length > 3 ? '...' : ''}`,
218
+ });
219
+ }
220
+ else if (vtResult.suspicious > 2) {
221
+ issues.push({
222
+ type: 'virus',
223
+ severity: 'high',
224
+ message: t('virusDetected'),
225
+ details: `VirusTotal: ${vtResult.suspicious} security vendors flagged this package as suspicious`,
226
+ });
227
+ }
228
+ return { issues, info };
229
+ }
230
+ else {
231
+ // File not found in VT, upload it
232
+ const analysisId = await uploadFileToVirusTotal(downloadResult.buffer, `${packageInfo.name}-${packageInfo.version}.tgz`, config.virustotal.apiKey);
233
+ if (analysisId) {
234
+ return {
235
+ issues,
236
+ info: `File not found in VT. Uploaded for analysis. View: https://www.virustotal.com/gui/file-analysis/${analysisId}`
237
+ };
238
+ }
239
+ else {
240
+ return {
241
+ issues,
242
+ info: `Hash ${downloadResult.hash.substring(0, 8)}... not found in VirusTotal (Upload failed)`
243
+ };
244
+ }
245
+ }
246
+ }
247
+ }
248
+ return { issues, info: 'Could not download package for scanning' };
249
+ }
@@ -0,0 +1,2 @@
1
+ import type { ScanIssue } from './types.js';
2
+ export declare function scanVulnerabilities(packageName: string): Promise<ScanIssue[]>;
@@ -0,0 +1,42 @@
1
+ import { t } from '../i18n/index.js';
2
+ import { exec } from 'child_process';
3
+ import { promisify } from 'util';
4
+ const execAsync = promisify(exec);
5
+ export async function scanVulnerabilities(packageName) {
6
+ const issues = [];
7
+ try {
8
+ // Use npm audit to check for known vulnerabilities
9
+ // Note: This is a simplified implementation
10
+ // In reality, we'd need to create a temp package.json to audit specific packages
11
+ const { stdout } = await execAsync(`npm audit --json --package-lock-only 2>nul || echo {}`, {
12
+ timeout: 30000,
13
+ });
14
+ if (!stdout.trim() || stdout.trim() === '{}') {
15
+ return issues;
16
+ }
17
+ try {
18
+ const auditResult = JSON.parse(stdout);
19
+ // Check if our package is in the vulnerabilities
20
+ const vulns = auditResult.vulnerabilities || {};
21
+ if (vulns[packageName]) {
22
+ const vuln = vulns[packageName];
23
+ const severity = vuln.severity;
24
+ if (severity === 'critical' || severity === 'high') {
25
+ issues.push({
26
+ type: 'cve',
27
+ severity: 'warning', // CVEs are warnings, not blockers
28
+ message: t('cveFound'),
29
+ details: `${severity.toUpperCase()}: ${vuln.via?.[0]?.title || 'Known vulnerability'}`,
30
+ });
31
+ }
32
+ }
33
+ }
34
+ catch {
35
+ // JSON parse failed, skip
36
+ }
37
+ }
38
+ catch {
39
+ // Audit failed, skip vulnerability check
40
+ }
41
+ return issues;
42
+ }
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare function App(): React.ReactElement;
@@ -0,0 +1,67 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput, useApp } from 'ink';
3
+ import { t } from '../i18n/index.js';
4
+ import { PopularScreen } from './screens/PopularScreen.js';
5
+ import { CheckScreen } from './screens/CheckScreen.js';
6
+ import { SettingsScreen } from './screens/SettingsScreen.js';
7
+ export function App() {
8
+ const [screen, setScreen] = useState('menu');
9
+ const [selectedIndex, setSelectedIndex] = useState(0);
10
+ const [checkPackage, setCheckPackage] = useState(undefined);
11
+ const { exit } = useApp();
12
+ const menuItems = [
13
+ { key: 'popular', label: t('tuiPopular') },
14
+ { key: 'check', label: t('tuiCheck') },
15
+ { key: 'settings', label: t('tuiSettings') },
16
+ { key: 'quit', label: t('tuiQuit') },
17
+ ];
18
+ useInput((input, key) => {
19
+ if (screen === 'menu') {
20
+ if (key.upArrow) {
21
+ setSelectedIndex((i) => (i > 0 ? i - 1 : menuItems.length - 1));
22
+ }
23
+ if (key.downArrow) {
24
+ setSelectedIndex((i) => (i < menuItems.length - 1 ? i + 1 : 0));
25
+ }
26
+ if (key.return) {
27
+ const item = menuItems[selectedIndex];
28
+ if (item.key === 'quit') {
29
+ exit();
30
+ }
31
+ else {
32
+ setCheckPackage(undefined); // Reset check package when entering normally
33
+ setScreen(item.key);
34
+ }
35
+ }
36
+ if (input === 'q') {
37
+ exit();
38
+ }
39
+ }
40
+ });
41
+ if (screen === 'menu') {
42
+ return (React.createElement(Box, { flexDirection: "column", padding: 1 },
43
+ React.createElement(Text, { bold: true, color: "cyan" }, t('tuiWelcome')),
44
+ React.createElement(Text, null, " "),
45
+ menuItems.map((item, i) => (React.createElement(Text, { key: item.key, color: i === selectedIndex ? 'green' : undefined },
46
+ i === selectedIndex ? '❯ ' : ' ',
47
+ item.label))),
48
+ React.createElement(Text, null, " "),
49
+ React.createElement(Text, { dimColor: true }, "Use \u2191\u2193 to navigate, Enter to select, q to quit")));
50
+ }
51
+ if (screen === 'popular') {
52
+ return (React.createElement(Box, { padding: 1 },
53
+ React.createElement(PopularScreen, { onSelect: (pkg) => {
54
+ setCheckPackage(pkg);
55
+ setScreen('check');
56
+ }, onBack: () => setScreen('menu') })));
57
+ }
58
+ if (screen === 'check') {
59
+ return (React.createElement(Box, { padding: 1 },
60
+ React.createElement(CheckScreen, { initialPackage: checkPackage, onBack: () => setScreen('menu') })));
61
+ }
62
+ if (screen === 'settings') {
63
+ return (React.createElement(Box, { padding: 1 },
64
+ React.createElement(SettingsScreen, { onBack: () => setScreen('menu') })));
65
+ }
66
+ return React.createElement(Text, null, "Error: Unknown screen");
67
+ }
@@ -0,0 +1 @@
1
+ export declare function startTui(): void;
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { App } from './App.js';
4
+ export function startTui() {
5
+ render(React.createElement(App, null));
6
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ interface Props {
3
+ initialPackage?: string;
4
+ onBack: () => void;
5
+ }
6
+ export declare function CheckScreen({ initialPackage, onBack }: Props): React.ReactElement;
7
+ export {};
@@ -0,0 +1,92 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { scanPackage } from '../../scanner/index.js';
5
+ import { t } from '../../i18n/index.js';
6
+ export function CheckScreen({ initialPackage, onBack }) {
7
+ const [query, setQuery] = useState(initialPackage || '');
8
+ const [loading, setLoading] = useState(!!initialPackage);
9
+ const [progressMsg, setProgressMsg] = useState('');
10
+ const [result, setResult] = useState(null);
11
+ const [error, setError] = useState(null);
12
+ useInput((input, key) => {
13
+ if (loading)
14
+ return;
15
+ if (key.escape) {
16
+ onBack();
17
+ return;
18
+ }
19
+ if (key.return) {
20
+ if (query.trim()) {
21
+ startScan(query);
22
+ }
23
+ return;
24
+ }
25
+ });
26
+ useEffect(() => {
27
+ if (initialPackage) {
28
+ startScan(initialPackage);
29
+ }
30
+ }, []);
31
+ const startScan = async (pkg) => {
32
+ setLoading(true);
33
+ setProgressMsg('Initializing scan...');
34
+ setError(null);
35
+ setResult(null);
36
+ try {
37
+ const res = await scanPackage(pkg, {}, (msg, completed, total) => {
38
+ setProgressMsg(`${msg} (${completed}/${total})`);
39
+ });
40
+ setResult(res);
41
+ }
42
+ catch (err) {
43
+ setError(err.message || 'Scan failed');
44
+ }
45
+ finally {
46
+ setLoading(false);
47
+ }
48
+ };
49
+ const getRiskColor = (level) => {
50
+ switch (level) {
51
+ case 'fatal': return 'red';
52
+ case 'high': return 'red';
53
+ case 'warning': return 'yellow';
54
+ case 'safe': return 'green';
55
+ default: return 'white';
56
+ }
57
+ };
58
+ return (React.createElement(Box, { flexDirection: "column" },
59
+ React.createElement(Text, { bold: true, color: "cyan" }, t('tuiCheck')),
60
+ React.createElement(Box, { marginTop: 1 },
61
+ React.createElement(Text, null, "Package: "),
62
+ React.createElement(TextInput, { value: query, onChange: setQuery, onSubmit: () => {
63
+ if (query.trim())
64
+ startScan(query);
65
+ } }),
66
+ React.createElement(Text, { dimColor: true }, loading ? ` ${progressMsg}` : '')),
67
+ error && (React.createElement(Box, { marginTop: 1 },
68
+ React.createElement(Text, { color: "red" },
69
+ "Error: ",
70
+ error))),
71
+ result && (React.createElement(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: getRiskColor(result.riskLevel), padding: 1 },
72
+ React.createElement(Box, null,
73
+ React.createElement(Text, { bold: true }, "Risk Level: "),
74
+ React.createElement(Text, { color: getRiskColor(result.riskLevel), bold: true }, result.riskLevel.toUpperCase())),
75
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 }, result.checks.map((check, i) => (React.createElement(Box, { key: i },
76
+ React.createElement(Text, { color: check.status === 'pass' ? 'green' : check.status === 'fail' ? 'red' : 'gray' }, check.status === 'pass' ? '✓ ' : check.status === 'fail' ? '✗ ' : '○ '),
77
+ React.createElement(Text, null,
78
+ check.name,
79
+ ": "),
80
+ React.createElement(Text, { dimColor: true }, check.description))))),
81
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 }, result.issues.length === 0 ? (React.createElement(Text, { color: "green" }, "No issues found. Package appears safe.")) : (result.issues.map((issue, i) => (React.createElement(Box, { key: i, flexDirection: "column", marginBottom: 1 },
82
+ React.createElement(Text, { color: issue.severity === 'high' || issue.severity === 'fatal' ? 'red' : 'yellow' },
83
+ "\u26A0 ",
84
+ issue.message),
85
+ issue.details && React.createElement(Text, { dimColor: true },
86
+ " ",
87
+ issue.details)))))),
88
+ !result.canBypass && (React.createElement(Box, { marginTop: 1 },
89
+ React.createElement(Text, { color: "red", bold: true }, "BLOCK: Package installation blocked by policy."))))),
90
+ React.createElement(Box, { marginTop: 1 },
91
+ React.createElement(Text, { dimColor: true }, "Enter to scan, Esc to back"))));
92
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ interface Props {
3
+ onSelect: (pkg: string) => void;
4
+ onBack: () => void;
5
+ }
6
+ export declare function PopularScreen({ onSelect, onBack }: Props): React.ReactElement;
7
+ export {};