fase(18): cli and cicd integration
This commit is contained in:
555
src/cli/abe.ts
Normal file
555
src/cli/abe.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* ABE CLI — command-line interface for autonomous bug exploration.
|
||||
*
|
||||
* Commands:
|
||||
* explore Run an exploration session
|
||||
* report Generate a report for a session
|
||||
* status Ping the ABE server and show active sessions
|
||||
*
|
||||
* Usage:
|
||||
* abe explore --url http://localhost:3000
|
||||
* abe explore --url http://localhost:3000 --server http://localhost:3001 --api-key <key>
|
||||
* abe report --session <id> --server http://localhost:3001
|
||||
* abe status --server http://localhost:3001
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { ExplorationEngine } from '../core/ExplorationEngine';
|
||||
import { StateGraph } from '../core/StateGraph';
|
||||
import { PlaywrightAgent } from '../plugins/agents/PlaywrightAgent';
|
||||
import { ScreenshotCollector } from '../plugins/collectors/ScreenshotCollector';
|
||||
import { NetworkCollector } from '../plugins/collectors/NetworkCollector';
|
||||
import { DOMSnapshotCollector } from '../plugins/collectors/DOMSnapshotCollector';
|
||||
import { MarkdownExporter } from '../plugins/exporters/MarkdownExporter';
|
||||
import { JSONExporter } from '../plugins/exporters/JSONExporter';
|
||||
import { PlaywrightReproducer } from '../plugins/reproducers/PlaywrightReproducer';
|
||||
import { ExplorationConfig, DEFAULT_EXPLORATION_CONFIG, AuthConfig } from '../core/ExplorationConfig';
|
||||
import { IAnomaly } from '../core/interfaces';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('abe')
|
||||
.description('Autonomous Bug Explorer — explore web apps and find bugs')
|
||||
.version('0.1.0');
|
||||
|
||||
// ─── explore ────────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('explore')
|
||||
.description('Run an autonomous exploration session against a target URL')
|
||||
.requiredOption('--url <url>', 'Target URL to explore')
|
||||
.option('--config <file>', 'Path to JSON config file (merged with flags)')
|
||||
.option('--seed <seed>', 'Deterministic seed', parseInt, 42)
|
||||
.option('--max-states <n>', 'Max states to explore', parseInt, 50)
|
||||
.option('--max-depth <n>', 'Max click depth', parseInt, 5)
|
||||
.option('--allowed-domains <domains>', 'Comma-separated allowed domains')
|
||||
.option('--excluded-paths <paths>', 'Comma-separated excluded paths')
|
||||
.option('--action-delay <ms>', 'Delay between actions in ms', parseInt, 500)
|
||||
.option('--session-timeout <ms>', 'Session timeout in ms', parseInt, 300000)
|
||||
// Auth options
|
||||
.option('--auth-type <type>', 'Auth type: cookies | headers | login_flow')
|
||||
.option('--login-url <url>', 'Login page URL (for login_flow)')
|
||||
.option('--username <user>', 'Username (for login_flow)')
|
||||
.option('--password <pass>', 'Password (for login_flow)')
|
||||
.option('--username-selector <sel>', 'Username field selector (for login_flow)')
|
||||
.option('--password-selector <sel>', 'Password field selector (for login_flow)')
|
||||
.option('--submit-selector <sel>', 'Submit button selector (for login_flow)')
|
||||
// Output
|
||||
.option('--output <format>', 'Output format: human | json | junit | markdown', 'human')
|
||||
.option('--reports-dir <dir>', 'Output directory for reports', './reports')
|
||||
// CI flags
|
||||
.option('--fail-on-anomaly', 'Exit 1 if any anomaly found')
|
||||
.option('--fail-on-severity <level>', 'Exit 1 if finding at or above severity (low|medium|high|critical)')
|
||||
// Remote server
|
||||
.option('--server <url>', 'Connect to remote ABE server instead of running inline')
|
||||
.option('--api-key <key>', 'API key for remote server')
|
||||
.action(async (opts: Record<string, unknown>) => {
|
||||
// Load config file if provided
|
||||
let fileConfig: Partial<ExplorationConfig & { seed?: number; maxStates?: number; maxDepth?: number }> = {};
|
||||
if (opts['config']) {
|
||||
try {
|
||||
const raw = fs.readFileSync(opts['config'] as string, 'utf8');
|
||||
fileConfig = JSON.parse(raw) as typeof fileConfig;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config file: ${(err as Error).message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge file config with CLI flags (CLI flags take precedence)
|
||||
const seed = (opts['seed'] as number) ?? fileConfig['seed'] ?? 42;
|
||||
const maxStates = (opts['maxStates'] as number) ?? fileConfig['maxStates'] ?? 50;
|
||||
const maxDepth = (opts['maxDepth'] as number) ?? fileConfig['maxDepth'] ?? 5;
|
||||
const reportsDir = (opts['reportsDir'] as string) ?? './reports';
|
||||
|
||||
// Remote server mode
|
||||
if (opts['server']) {
|
||||
await exploreRemote(opts);
|
||||
return;
|
||||
}
|
||||
|
||||
// Inline mode — build auth config
|
||||
let auth: AuthConfig | null = null;
|
||||
if (opts['authType'] === 'login_flow') {
|
||||
auth = {
|
||||
type: 'login_flow',
|
||||
loginUrl: (opts['loginUrl'] as string) ?? '',
|
||||
usernameSelector: (opts['usernameSelector'] as string) ?? 'input[type="email"]',
|
||||
passwordSelector: (opts['passwordSelector'] as string) ?? 'input[type="password"]',
|
||||
submitSelector: (opts['submitSelector'] as string) ?? 'button[type="submit"]',
|
||||
username: (opts['username'] as string) ?? '',
|
||||
password: (opts['password'] as string) ?? '',
|
||||
};
|
||||
} else if (opts['authType'] === 'headers') {
|
||||
auth = { type: 'headers', headers: {} };
|
||||
} else if (opts['authType'] === 'cookies') {
|
||||
auth = { type: 'cookies', cookies: [] };
|
||||
}
|
||||
|
||||
const config: ExplorationConfig = {
|
||||
...DEFAULT_EXPLORATION_CONFIG,
|
||||
...fileConfig,
|
||||
maxStates,
|
||||
maxDepth,
|
||||
actionDelayMs: (opts['actionDelay'] as number) ?? DEFAULT_EXPLORATION_CONFIG.actionDelayMs,
|
||||
sessionTimeoutMs: (opts['sessionTimeout'] as number) ?? DEFAULT_EXPLORATION_CONFIG.sessionTimeoutMs,
|
||||
allowedDomains: opts['allowedDomains']
|
||||
? (opts['allowedDomains'] as string).split(',').map((d: string) => d.trim())
|
||||
: [new URL(opts['url'] as string).hostname],
|
||||
excludedPaths: opts['excludedPaths']
|
||||
? (opts['excludedPaths'] as string).split(',').map((p: string) => p.trim())
|
||||
: [],
|
||||
auth,
|
||||
};
|
||||
|
||||
const anomalies: IAnomaly[] = [];
|
||||
const discoveredStates: Array<{ id: string; url: string; title: string }> = [];
|
||||
let statesVisited = 0;
|
||||
let exitCode = 0;
|
||||
let explorationError: string | undefined;
|
||||
const startMs = Date.now();
|
||||
|
||||
try {
|
||||
const graph = new StateGraph();
|
||||
const agent = new PlaywrightAgent({ seed, explorationConfig: config });
|
||||
|
||||
const engine = new ExplorationEngine({
|
||||
graph,
|
||||
agent,
|
||||
seed,
|
||||
url: opts['url'] as string,
|
||||
maxSteps: maxStates,
|
||||
outputDir: reportsDir,
|
||||
explorationConfig: config,
|
||||
collectors: [
|
||||
new ScreenshotCollector(reportsDir),
|
||||
new NetworkCollector(),
|
||||
new DOMSnapshotCollector(reportsDir),
|
||||
],
|
||||
exporters: [new MarkdownExporter(), new JSONExporter()],
|
||||
reproducer: new PlaywrightReproducer(),
|
||||
events: {
|
||||
onStateDiscovered: (_sessionId, stateId, stateUrl, title) => {
|
||||
discoveredStates.push({ id: stateId, url: stateUrl, title });
|
||||
},
|
||||
onAnomalyDetected: (_sessionId, anomaly) => {
|
||||
anomalies.push(anomaly);
|
||||
},
|
||||
onSessionCompleted: (_sessionId, visited) => {
|
||||
statesVisited = visited;
|
||||
},
|
||||
onSessionError: (_sessionId, error) => {
|
||||
explorationError = error;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await engine.run();
|
||||
statesVisited = result.statesVisited;
|
||||
} catch (err: unknown) {
|
||||
explorationError = err instanceof Error ? err.message : String(err);
|
||||
exitCode = 2;
|
||||
}
|
||||
|
||||
if (explorationError && exitCode === 0) exitCode = 2;
|
||||
|
||||
// Determine exit code from CI flags
|
||||
if (exitCode === 0 && opts['failOnAnomaly'] && anomalies.length > 0) {
|
||||
exitCode = 1;
|
||||
}
|
||||
if (exitCode === 0 && opts['failOnSeverity']) {
|
||||
const severityRank: Record<string, number> = { low: 0, medium: 1, high: 2, critical: 3 };
|
||||
const threshold = severityRank[opts['failOnSeverity'] as string] ?? 0;
|
||||
const failing = anomalies.filter((a) => (severityRank[a.severity] ?? 0) >= threshold);
|
||||
if (failing.length > 0) exitCode = 1;
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startMs;
|
||||
const output = opts['output'] as string;
|
||||
|
||||
if (output === 'json') {
|
||||
const summary = {
|
||||
url: opts['url'],
|
||||
seed,
|
||||
duration_ms: durationMs,
|
||||
states_visited: statesVisited,
|
||||
findings: anomalies.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
severity: a.severity,
|
||||
description: a.description,
|
||||
report_path: path.join(reportsDir, a.id, 'report.json'),
|
||||
})),
|
||||
exit_code: exitCode,
|
||||
error: explorationError,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
||||
|
||||
} else if (output === 'junit') {
|
||||
const xml = buildJunit({
|
||||
url: opts['url'] as string,
|
||||
statesVisited,
|
||||
discoveredStates,
|
||||
anomalies,
|
||||
durationMs,
|
||||
});
|
||||
const outPath = path.join(process.cwd(), 'abe-results.xml');
|
||||
fs.writeFileSync(outPath, xml, 'utf8');
|
||||
console.log(`JUnit results written to ${outPath}`);
|
||||
|
||||
} else if (output === 'markdown') {
|
||||
printMarkdownSummary({ url: opts['url'] as string, statesVisited, anomalies, durationMs, explorationError });
|
||||
|
||||
} else {
|
||||
// human-readable
|
||||
if (anomalies.length === 0 && !explorationError) {
|
||||
console.log(`✓ ABE finished. No findings. ${statesVisited} states explored. (${durationMs}ms)`);
|
||||
} else {
|
||||
if (explorationError) {
|
||||
console.error(`✗ ABE error: ${explorationError}`);
|
||||
}
|
||||
if (anomalies.length > 0) {
|
||||
console.log(`⚠ ABE finished. ${anomalies.length} finding(s) in ${statesVisited} states (${durationMs}ms):`);
|
||||
for (const a of anomalies) {
|
||||
console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
});
|
||||
|
||||
// ─── report ─────────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('report')
|
||||
.description('Generate a report for a completed exploration session')
|
||||
.requiredOption('--session <id>', 'Session ID to generate report for')
|
||||
.option('--server <url>', 'ABE server URL', 'http://localhost:3001')
|
||||
.option('--api-key <key>', 'API key for authentication')
|
||||
.option('--format <format>', 'Report format: pdf | html | json', 'pdf')
|
||||
.option('--output <file>', 'Output file path (default: ./abe-report-<session>.pdf)')
|
||||
.action(async (opts: Record<string, unknown>) => {
|
||||
const server = opts['server'] as string;
|
||||
const sessionId = opts['session'] as string;
|
||||
const apiKey = opts['apiKey'] as string | undefined;
|
||||
const format = opts['format'] as string;
|
||||
const outputFile = (opts['output'] as string | undefined) ?? `./abe-report-${sessionId}.${format}`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) headers['x-abe-api-key'] = apiKey;
|
||||
|
||||
console.log(`Generating ${format} report for session ${sessionId}...`);
|
||||
|
||||
try {
|
||||
// Request report generation
|
||||
const genRes = await fetch(`${server}/api/reports`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ sessionId, format }),
|
||||
});
|
||||
|
||||
if (!genRes.ok) {
|
||||
console.error(`Error generating report: ${genRes.status} ${await genRes.text()}`);
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
|
||||
const report = await genRes.json() as { id: string; status: string };
|
||||
console.log(`Report queued: ${report.id}`);
|
||||
|
||||
// Poll until ready
|
||||
let ready = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
|
||||
while (!ready && attempts < maxAttempts) {
|
||||
await sleep(2000);
|
||||
attempts++;
|
||||
|
||||
const statusRes = await fetch(`${server}/api/reports/${report.id}`, { headers });
|
||||
if (!statusRes.ok) break;
|
||||
|
||||
const status = await statusRes.json() as { status: string };
|
||||
if (status.status === 'completed') {
|
||||
ready = true;
|
||||
} else if (status.status === 'failed') {
|
||||
console.error('Report generation failed');
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
console.error('\nTimeout waiting for report');
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\nDownloading...');
|
||||
|
||||
// Download the report
|
||||
const dlRes = await fetch(`${server}/api/reports/${report.id}/download`, { headers });
|
||||
if (!dlRes.ok) {
|
||||
console.error(`Download failed: ${dlRes.status}`);
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await dlRes.arrayBuffer());
|
||||
fs.writeFileSync(outputFile, buffer);
|
||||
console.log(`Report saved to ${outputFile}`);
|
||||
|
||||
} catch (err: unknown) {
|
||||
console.error(`Error: ${(err as Error).message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── status ─────────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Ping the ABE server and show active sessions')
|
||||
.option('--server <url>', 'ABE server URL', 'http://localhost:3001')
|
||||
.option('--api-key <key>', 'API key for authentication')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (opts: Record<string, unknown>) => {
|
||||
const server = opts['server'] as string;
|
||||
const apiKey = opts['apiKey'] as string | undefined;
|
||||
const asJson = opts['json'] as boolean | undefined;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers['x-abe-api-key'] = apiKey;
|
||||
|
||||
try {
|
||||
// Health check
|
||||
const healthRes = await fetch(`${server}/health/ready`, { headers });
|
||||
const healthy = healthRes.ok;
|
||||
|
||||
if (!healthy) {
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ status: 'down', server }));
|
||||
} else {
|
||||
console.error(`✗ Server at ${server} is not ready (${healthRes.status})`);
|
||||
}
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch active sessions
|
||||
const sessionsRes = await fetch(`${server}/api/sessions`, { headers });
|
||||
const sessions = sessionsRes.ok
|
||||
? (await sessionsRes.json() as Array<{ id: string; url: string; status: string; statesVisited: number }>)
|
||||
: [];
|
||||
|
||||
const active = sessions.filter((s) => s.status === 'running');
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ status: 'up', server, activeSessions: active.length, sessions: active }));
|
||||
} else {
|
||||
console.log(`✓ ABE server is ready at ${server}`);
|
||||
if (active.length === 0) {
|
||||
console.log(' No active sessions');
|
||||
} else {
|
||||
console.log(` ${active.length} active session(s):`);
|
||||
for (const s of active) {
|
||||
console.log(` [${s.id}] ${s.url} — ${s.statesVisited} states explored`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err: unknown) {
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ status: 'down', server, error: (err as Error).message }));
|
||||
} else {
|
||||
console.error(`✗ Cannot reach ABE server at ${server}: ${(err as Error).message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function exploreRemote(opts: Record<string, unknown>): Promise<void> {
|
||||
const serverUrl = opts['server'] as string;
|
||||
const apiKey = opts['apiKey'] as string | undefined;
|
||||
const url = opts['url'] as string;
|
||||
const failOnSeverity = opts['failOnSeverity'] as string | undefined;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) headers['x-abe-api-key'] = apiKey;
|
||||
|
||||
console.log(`Starting remote exploration of ${url} via ${serverUrl}...`);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
seed: opts['seed'],
|
||||
maxStates: opts['maxStates'],
|
||||
maxDepth: opts['maxDepth'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`Server error: ${res.status} ${await res.text()}`);
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await res.json() as { sessionId: string; id?: string };
|
||||
const sessionId = session.sessionId ?? session.id ?? '';
|
||||
console.log(`Session started: ${sessionId}`);
|
||||
|
||||
// Poll for completion
|
||||
let done = false;
|
||||
let anomalyCount = 0;
|
||||
while (!done) {
|
||||
await sleep(3000);
|
||||
|
||||
const statusRes = await fetch(`${serverUrl}/api/sessions/${sessionId}`, { headers });
|
||||
if (!statusRes.ok) break;
|
||||
|
||||
const status = await statusRes.json() as { status: string; statesVisited?: number; findingsCount?: number };
|
||||
|
||||
if (status.status === 'completed' || status.status === 'failed' || status.status === 'stopped') {
|
||||
done = true;
|
||||
anomalyCount = status.findingsCount ?? 0;
|
||||
console.log(`Session ${status.status}. States: ${status.statesVisited ?? 0}, Findings: ${anomalyCount}`);
|
||||
} else {
|
||||
process.stdout.write('.');
|
||||
}
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
if (opts['failOnAnomaly'] && anomalyCount > 0) exitCode = 1;
|
||||
if (failOnSeverity) {
|
||||
// We can't filter by severity without fetching findings — conservative: exit 1 if any
|
||||
if (anomalyCount > 0) exitCode = 1;
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
} catch (err: unknown) {
|
||||
console.error(`Error: ${(err as Error).message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
interface JunitInput {
|
||||
url: string;
|
||||
statesVisited: number;
|
||||
discoveredStates: Array<{ id: string; url: string; title: string }>;
|
||||
anomalies: IAnomaly[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function buildJunit(input: JunitInput): string {
|
||||
const { url, statesVisited, discoveredStates, anomalies, durationMs } = input;
|
||||
|
||||
// One passing test case per discovered state (states without findings pass)
|
||||
const stateCases = discoveredStates.map(
|
||||
(s) => ` <testcase name="${escapeXml(s.title || s.url)}" classname="abe.state.${escapeXml(s.id)}" />`
|
||||
);
|
||||
|
||||
// One failing test case per anomaly
|
||||
const anomalyCases = anomalies.map(
|
||||
(a) =>
|
||||
` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
|
||||
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
|
||||
` </testcase>`
|
||||
);
|
||||
|
||||
const totalTests = Math.max(statesVisited, discoveredStates.length) + anomalies.length;
|
||||
const totalFailures = anomalies.length;
|
||||
const durationSec = (durationMs / 1000).toFixed(3);
|
||||
|
||||
return (
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<testsuite name="ABE: ${escapeXml(url)}" tests="${totalTests}" failures="${totalFailures}" time="${durationSec}">\n` +
|
||||
[...stateCases, ...anomalyCases].join('\n') +
|
||||
'\n</testsuite>\n'
|
||||
);
|
||||
}
|
||||
|
||||
function printMarkdownSummary(input: {
|
||||
url: string;
|
||||
statesVisited: number;
|
||||
anomalies: IAnomaly[];
|
||||
durationMs: number;
|
||||
explorationError: string | undefined;
|
||||
}): void {
|
||||
const { url, statesVisited, anomalies, durationMs, explorationError } = input;
|
||||
const lines: string[] = [
|
||||
`# ABE Exploration Report`,
|
||||
``,
|
||||
`**Target:** ${url}`,
|
||||
`**States explored:** ${statesVisited}`,
|
||||
`**Duration:** ${(durationMs / 1000).toFixed(1)}s`,
|
||||
`**Findings:** ${anomalies.length}`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (explorationError) {
|
||||
lines.push(`> ⚠ **Error:** ${explorationError}`, ``);
|
||||
}
|
||||
|
||||
if (anomalies.length === 0) {
|
||||
lines.push(`✅ No findings detected.`);
|
||||
} else {
|
||||
lines.push(`## Findings`, ``);
|
||||
for (const a of anomalies) {
|
||||
lines.push(
|
||||
`### [${a.severity.toUpperCase()}] ${a.type}`,
|
||||
``,
|
||||
`**ID:** ${a.id}`,
|
||||
`**Description:** ${a.description}`,
|
||||
``
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
program.parse(process.argv);
|
||||
Reference in New Issue
Block a user