diff --git a/lib/src/commands/scan.ts b/lib/src/commands/scan.ts index 9ebc3ab..f6cb4e3 100644 --- a/lib/src/commands/scan.ts +++ b/lib/src/commands/scan.ts @@ -141,7 +141,7 @@ export const scan = async ( const { candidatesSignedS3Url, record: { _id: recordId }, - } = await initiateScan(path); + } = await initiateScan(resolvedInput); await uploadCandidatesToS3(candidates, candidatesSignedS3Url); await initiateClassify(recordId); logExtractSummary(extractSummary); diff --git a/lib/src/http/scan.ts b/lib/src/http/scan.ts index 3cbc7b6..004e4b4 100644 --- a/lib/src/http/scan.ts +++ b/lib/src/http/scan.ts @@ -1,9 +1,42 @@ import axios, { AxiosError } from "axios"; +import chalk from "chalk"; import getHttpClient from "./client"; import { IInitiateScanResponse, ZInitiateScanResponse } from "./types"; import { DittoScanCandidate } from "../scan/types"; +import DittoError, { ErrorType } from "../utils/DittoError"; import { Blob } from "buffer"; +// Deep-links to the home page with the billing/upgrade modal open. +const BILLING_URL = "https://app.dittowords.com/home?openBillingModal=true"; + +// OSC 8 hyperlink: renders `label` as a clickable link to `href` in terminals +// that support it. +function terminalLink(label: string, href: string): string { + const OSC = "\u001B]8;;"; + const BEL = "\u0007"; + return `${OSC}${href}${BEL}${chalk.blueBright.underline(label)}${OSC}${BEL}`; +} + +// Turns the server's plan-limit response into a message with concrete next +// steps: upgrade, or scan a smaller path. +function scanLimitError(serverMessage: string): DittoError { + const message = [ + serverMessage, + "", + "To scan more strings, you can either:", + ` • Upgrade your plan at ${terminalLink("app.dittowords.com", BILLING_URL)}`, + " • Scan a smaller subdirectory, e.g. `npx @dittowords/cli scan ./src`", + ].join("\n"); + + return new DittoError({ + type: ErrorType.ScanError, + message, + exitCode: 1, + expected: true, + data: { rawErrorMessage: serverMessage }, + }); +} + export async function initiateScan( path: string ): Promise { @@ -31,6 +64,12 @@ export async function initiateClassify(scanId: string): Promise { "Sorry! We're having trouble reaching the Ditto API. Please try again later." ); } + + const data = e.response?.data; + if (data?.code === "SCAN_CANDIDATE_LIMIT_EXCEEDED") { + throw scanLimitError(data.message); + } + if (data?.message) throw new Error(data.message); throw e; } } diff --git a/lib/src/index.ts b/lib/src/index.ts index db464d8..a3ea168 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -33,6 +33,10 @@ const handleCommandError = async (error: any) => { errorText = `${error.data.messagePrefix}\n\n${error.data.formattedError}`; } + if (error.expected) { + return await quit(logger.errorText(errorText), exitCode); + } + sentryOptions = { extra: { message: errorText, ...(error.data || {}) }, }; @@ -73,9 +77,9 @@ const appEntry = async () => { // ditto scan program - .command("scan ") + .command("scan [path]") .description( - "Run extract + classify + infer end-to-end; emits schema.json and stagings.ndjson to --out-dir" + "Scan a codebase for user-facing strings and send them to Ditto. Defaults to the current directory if no path is given." ) .option( "--local", @@ -86,11 +90,11 @@ const appEntry = async () => { .option("--prefix ", "prefix for output files", "") .action( async ( - inputPath: string, + inputPath: string | undefined, opts: { local: boolean; outDir: string; prefix?: string } ) => { try { - return await scan(inputPath, opts); + return await scan(inputPath ?? ".", opts); } catch (error) { handleCommandError(error); } diff --git a/lib/src/utils/DittoError.ts b/lib/src/utils/DittoError.ts index 2debeb1..616117b 100644 --- a/lib/src/utils/DittoError.ts +++ b/lib/src/utils/DittoError.ts @@ -9,6 +9,7 @@ import { z } from "zod"; export default class DittoError extends Error { exitCode: number | undefined; type: ErrorType; + expected: boolean; // Note: if you see the type error "Type 'T' cannot be used to index type 'ErrorDataMap'", // a value is missing from the ErrorDataMap defined below data: ErrorDataMap[T]; @@ -18,17 +19,20 @@ export default class DittoError extends Error { * @param type The type of error, from the ErrorType enum * @param message Optional: error message to display to the user * @param exitCode Optional: exit code to return to the shell. + * @param expected Optional: whether this is an expected, user-actionable error * @param data Optional: additional data to pass along with the error */ constructor({ type, message, exitCode, + expected = false, data, }: { type: T; message?: string; exitCode?: number; + expected?: boolean; data: ErrorDataMap[T]; }) { const errorMessage = @@ -39,6 +43,7 @@ export default class DittoError extends Error { this.exitCode = exitCode; this.type = type; + this.expected = expected; this.data = data; } }