@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
234 lines • 10.4 kB
JavaScript
/**
* CLI Input Validation Utilities
*
* Validates multimodal file flags (--image/--csv/--pdf/--video/--file), the
* batch `<promptsFile>` positional, and --csv-max-rows before a
* generate/stream/batch run starts, so bad input fails fast with a clear
* message instead of surfacing deep inside provider calls. Exported as
* standalone functions (rather than private statics on CLICommandFactory)
* so callers — including tests — can import and invoke them directly,
* without reaching into the class via an `unknown` cast.
*/
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import chalk from "chalk";
import { logger } from "../../utils/logger.js";
import { redactUrlForError } from "../../utils/logSanitize.js";
import { resolveFilePath } from "./pathResolver.js";
/**
* Soft (warn-only) size thresholds for CLI multimodal flags. Intentionally
* defined here rather than reusing `lib/processors/config/sizeLimits.ts`'s
* hard processor caps (e.g. PDF's 100MB hard limit): reusing those names/
* values would couple the CLI layer to processor internals across the
* CLI/SDK boundary, and would blur the line between a soft "this may be
* slow" warning and the actual hard cap the processor enforces.
*/
export const CLI_SOFT_LIMITS_MB = {
IMAGE_MAX_MB: 10,
CSV_MAX_MB: 50,
PDF_MAX_MB: 100,
// Distinct from PDF_MAX_MB above (which mirrors the processor's true
// 100MB hard cap in lib/processors/config/sizeLimits.ts and is kept here
// for backward compatibility). Docs promise a 50MB *warning* threshold
// specifically for --pdf — wire warnAtMB to this value, not the hard cap.
PDF_SOFT_MAX_MB: 50,
VIDEO_MAX_MB: 500,
};
// `file://` is intentionally excluded here: it addresses a local path, so it
// must still go through directory/missing-path validation below (via
// tryResolveFileUrl in validateSingleLocalPath). Only genuine remote/inline
// references (http(s), data URIs) skip validation entirely (#319).
function isNonLocalFileReference(filePath) {
const lower = filePath.toLowerCase();
return (lower.startsWith("http://") ||
lower.startsWith("https://") ||
lower.startsWith("data:"));
}
// #291: a `file://` URL still points at something on disk, so validation
// needs the real filesystem path. Returns undefined (instead of throwing)
// for a malformed URL so the caller can report a friendly error.
function tryResolveFileUrl(filePath) {
if (!filePath.toLowerCase().startsWith("file://")) {
return filePath;
}
try {
return fileURLToPath(filePath);
}
catch {
return undefined;
}
}
/**
* Shared aggregated-error format for any invalid local file path (missing,
* unreadable, a directory, or a malformed `file://` URL). Used by both the
* flag-based loop in `validateCliInputFiles` and the batch `<promptsFile>`
* positional check in `validatePromptsFilePath`, so every caller gets the
* same clear error + troubleshooting hints instead of a raw exception.
*/
function buildInvalidFileError(errors) {
return new Error("❌ One or more input files are invalid:\n" +
`${errors.join("\n")}\n` +
"💡 Troubleshooting:\n" +
" • Check the path is correct and relative to your current directory, or pass an absolute path / URL.\n" +
` • Check your working directory (${process.cwd()}).\n` +
" • For remote resources, prefix the value with http:// or https://.\n" +
" • If you pointed at a folder, point at the file inside it instead.");
}
/**
* Validate a single raw path/URL value for `option`, pushing a friendly
* message onto `errors` instead of letting fs calls throw a raw exception
* (ENOENT, EACCES/EPERM TOCTOU, a directory, or a malformed `file://` URL).
* Returns the resolved local filesystem path on success (or the original
* value unchanged for a skipped http(s)/data: reference), and `undefined`
* when an error was pushed.
*
* `rawPath` is redacted via `redactUrlForError` before ever being
* interpolated into a message — a `file://` value can carry a sensitive
* query string, fragment, or embedded `user:pass@` credentials that must
* never be echoed back verbatim. This is a no-op for ordinary filesystem
* paths (they aren't parseable as a URL, so the redaction fallback simply
* returns them unchanged).
*/
function validateSingleLocalPath(option, rawPath, errors, warnAtMB) {
const resolvedPath = resolveFilePath(rawPath);
if (isNonLocalFileReference(resolvedPath)) {
// URLs / data: refs skip both existence and size checks (#319).
return resolvedPath;
}
const safeRawPath = redactUrlForError(rawPath);
// #291: `file://` URLs are local paths in disguise — validate the decoded
// filesystem path so a directory or missing path is still caught here
// instead of failing cryptically deep in processing.
const pathToValidate = tryResolveFileUrl(resolvedPath);
if (pathToValidate === undefined) {
errors.push(`${option} is not a valid file:// URL: ${safeRawPath}`);
return undefined;
}
const safeResolvedPath = redactUrlForError(pathToValidate);
// Single statSync covers existence, directory rejection, and the size
// warning below — avoids a second syscall and closes the TOCTOU gap a
// separate existsSync()+statSync() pair would leave.
let stat;
try {
stat = fs.statSync(pathToValidate);
}
catch (err) {
// Only ENOENT actually means "not found" — EACCES (permission denied),
// ELOOP (symlink loop), ENOTDIR, etc. are real failures that get masked
// (and made undebuggable) if we blanket-label every statSync error the
// same way.
const code = err?.code;
if (code === "ENOENT") {
errors.push(`${option} path not found: ${safeRawPath} (resolved to ${safeResolvedPath})`);
}
else {
const reason = err instanceof Error ? err.message : String(err);
errors.push(`${option} path could not be accessed: ${safeRawPath} (resolved to ${safeResolvedPath}) — ` +
`${code ? `${code}: ` : ""}${reason}`);
}
return undefined;
}
// #288: reject a directory before it fails cryptically deep in processing
// (readFileSync on a dir throws a raw EISDIR).
if (stat.isDirectory()) {
errors.push(`${option} path is a directory, not a file: ${safeRawPath} (resolved to ${safeResolvedPath})`);
return undefined;
}
// #319: warn (not error) when a local multimodal file is unusually large
// so the user isn't surprised by slow processing / token blowups. Safety-
// relevant, so always visible (not gated behind --quiet) and written to
// stderr — never stdout — so `--format json` output stays parseable.
if (warnAtMB !== undefined) {
const sizeMB = stat.size / (1024 * 1024);
if (sizeMB > warnAtMB) {
logger.alwaysStderr(chalk.yellow(`⚠️ ${option} file ${safeRawPath} is ${sizeMB.toFixed(1)}MB ` +
`(above the ${warnAtMB}MB soft limit) — processing may be slow ` +
`or exceed provider token/size limits.`));
}
}
return pathToValidate;
}
/**
* Validate --image/--csv/--pdf/--video/--file inputs before a CLI run
* starts: every local path (including `file://` URLs) must exist, must not
* be a directory, and — for the flags with a soft limit — triggers a size
* warning above the threshold. Genuine http(s)/data: URLs skip both
* existence and size checks (#319).
*/
export function validateCliInputFiles(argv) {
const fileArgs = [
{
option: "--image",
value: argv.image,
warnAtMB: CLI_SOFT_LIMITS_MB.IMAGE_MAX_MB,
},
{
option: "--csv",
value: argv.csv,
warnAtMB: CLI_SOFT_LIMITS_MB.CSV_MAX_MB,
},
{
option: "--pdf",
value: argv.pdf,
warnAtMB: CLI_SOFT_LIMITS_MB.PDF_SOFT_MAX_MB,
},
{
option: "--video",
value: argv.video,
warnAtMB: CLI_SOFT_LIMITS_MB.VIDEO_MAX_MB,
},
{ option: "--file", value: argv.file },
];
const errors = [];
for (const { option, value, warnAtMB } of fileArgs) {
if (!value) {
continue;
}
const rawPaths = Array.isArray(value) ? value : [value];
for (const rawPath of rawPaths) {
validateSingleLocalPath(option, rawPath, errors, warnAtMB);
}
}
if (errors.length > 0) {
throw buildInvalidFileError(errors);
}
}
/**
* #291: validate the batch `<promptsFile>` positional through the same
* aggregated-error-friendly path as the multimodal flags above (not found /
* unreadable / directory / malformed `file://` URL), instead of a raw
* existsSync+readFileSync that throws an unfriendly EISDIR when pointed at
* a directory. The `<file>` label (rather than `--file`) keeps the message
* from being confused with the `--file` flag, which batch doesn't register
* at all (see `createBatchCommand`). Returns the resolved local path for
* the caller to read.
*/
export function validatePromptsFilePath(rawPath) {
const errors = [];
const resolved = validateSingleLocalPath("<file>", rawPath, errors);
if (errors.length > 0) {
throw buildInvalidFileError(errors);
}
// `errors` is empty here, so `validateSingleLocalPath` did not return
// undefined.
return resolved;
}
/**
* #310: validate --csv-max-rows is a positive integer in the range
* 1-100000, matching the documented option range (the value previously
* flowed through silently, or — above 100000 — only warned instead of
* being rejected, contradicting the documented hard range).
*/
export function validateCsvMaxRows(argv) {
const raw = argv.csvMaxRows;
if (raw === undefined) {
return;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 100000) {
throw new Error(`Invalid --csv-max-rows (--csvMaxRows) value: ${String(raw)}. ` +
`Must be a positive integer in the range 1-100000. ` +
`Example: --csv-max-rows 500`);
}
}
//# sourceMappingURL=inputValidation.js.map