diff --git a/src/action/actions/start.action.ts b/src/action/actions/start.action.ts index 3fec3d0..e6e87c4 100644 --- a/src/action/actions/start.action.ts +++ b/src/action/actions/start.action.ts @@ -12,6 +12,7 @@ import { import { PackageManagerFactory } from "@lib/package-manager"; import { Messages } from "@lib/ui"; +import { CLIError } from "@utils/errors"; import { getCwd, getModulePath } from "@utils/path"; import { runSafe } from "@utils/run-safe"; @@ -61,8 +62,8 @@ export class StartAction extends AbstractAction { if (!cert && !key) return undefined; - if (!cert) throw new Error("No cert entered for SSL. Please enter a key with --cert."); - if (!key) throw new Error("No key entered for SSL. Please enter a key with --key."); + if (!cert) throw new CLIError("No cert entered for SSL. Please enter a cert with --cert."); + if (!key) throw new CLIError("No key entered for SSL. Please enter a key with --key."); return { cert, diff --git a/src/lib/config/config-loader.spec.ts b/src/lib/config/config-loader.spec.ts index e58b837..d61c13b 100644 --- a/src/lib/config/config-loader.spec.ts +++ b/src/lib/config/config-loader.spec.ts @@ -62,9 +62,7 @@ describe("loadConfig", () => { const { loadConfig: freshLoad } = await import("./config-loader"); - await expect(freshLoad("/project")).rejects.toThrow( - "No config file found in directory: /project", - ); + await expect(freshLoad("/project")).rejects.toThrow("Configuration file not found at path:"); }); it("should throw when config file cannot be parsed", async () => { @@ -73,7 +71,7 @@ describe("loadConfig", () => { const { loadConfig: freshLoad } = await import("./config-loader"); - await expect(freshLoad("/project")).rejects.toThrow("Not able to read config file"); + await expect(freshLoad("/project")).rejects.toThrow("File System Error [read config file]"); }); it("should throw on validation errors", async () => { diff --git a/src/lib/config/config-loader.ts b/src/lib/config/config-loader.ts index 2291f83..86ae6d3 100644 --- a/src/lib/config/config-loader.ts +++ b/src/lib/config/config-loader.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { CONFIG_FILE_NAME } from "@lib/constants"; +import { CLIError, ConfigNotFoundError, FileSystemError } from "@utils/errors"; import { deepMerge } from "@utils/object"; import { CONFIG_DEFAULTS } from "./config-defaults"; @@ -20,7 +21,7 @@ const getConfigPath = (directory: string, name?: string) => { const path = join(directory, n); if (existsSync(path)) return path; } - throw new Error(`No config file found in directory: ${directory}`); + throw new ConfigNotFoundError(join(directory, CONFIG_FILE_NAME)); } }; @@ -39,14 +40,14 @@ export const loadConfig = async ( } catch { rawData = noThrow ? CONFIG_DEFAULTS : null; } - if (!rawData) throw new Error(`Not able to read config file : ${path}`); + if (!rawData) throw new FileSystemError("read config file", path); const data = plainToInstance(Config, rawData, { excludeExtraneousValues: true, }); const errors = await validate(data); if (errors.length > 0) - throw new Error(`Invalid config :\n${errors.toString().replace(/,/g, "\n")}`); + throw new CLIError(`Invalid config:\n${errors.toString().replace(/,/g, "\n")}`); config = data; return config; diff --git a/src/lib/http/client.ts b/src/lib/http/client.ts index afe48d9..e24575e 100644 --- a/src/lib/http/client.ts +++ b/src/lib/http/client.ts @@ -1,5 +1,7 @@ import { REGISTRY_URL } from "@lib/constants"; +import { RegistryAuthenticationError } from "@utils/errors"; + import { HttpClient } from "./http-client"; import { Repository } from "./repository"; @@ -15,8 +17,7 @@ export const withAuth = ( }, ) => { if (!apiKey && force) { - console.error("No registry key found. Please use `nf login` to login"); - throw new Error("No apikey found. Please use `nf login` to login"); + throw new RegistryAuthenticationError(); } return new Repository( new HttpClient(REGISTRY_URL ?? "", { diff --git a/src/lib/http/repository.ts b/src/lib/http/repository.ts index f3aeceb..85116e3 100644 --- a/src/lib/http/repository.ts +++ b/src/lib/http/repository.ts @@ -1,3 +1,5 @@ +import { ApiRequestError } from "@utils/errors"; + import type { HttpClient, RequestOptions } from "./http-client"; export class Repository { @@ -50,10 +52,7 @@ export class Repository { ): Promise { const res = await this._client[request](path, options); const data = (await res.json()) as R; - if (!res.ok) - throw new Error(`Request failed with status code ${res.status}`, { - cause: data["error" as keyof R], - }); + if (!res.ok) throw new ApiRequestError(res.status, data["error" as keyof R]); return data; } @@ -64,9 +63,7 @@ export class Repository { ): Promise { const res = await this._client[request](path, options); if (!res.ok) - throw new Error(`Request failed with status code ${res.status}`, { - cause: ((await res.json()) as { error: any })["error"], - }); + throw new ApiRequestError(res.status, ((await res.json()) as { error: any })["error"]); return await res.blob(); } @@ -82,10 +79,7 @@ export class Repository { options, ); const data = (await res.json()) as R; - if (!res.ok) - throw new Error(`Request failed with status code ${res.status}`, { - cause: data["error" as keyof R], - }); + if (!res.ok) throw new ApiRequestError(res.status, data["error" as keyof R]); return data; } } diff --git a/src/lib/input/ask-inputs.ts b/src/lib/input/ask-inputs.ts index 2b03e04..3940f2f 100644 --- a/src/lib/input/ask-inputs.ts +++ b/src/lib/input/ask-inputs.ts @@ -1,3 +1,5 @@ +import { CLIError } from "@utils/errors"; + export const getInputOrAsk = async ( baseInput: T | undefined, askCb: () => Promise, @@ -7,5 +9,5 @@ export const getInputOrAsk = async ( const res = await askCb(); if (res !== undefined) return res; if (defaultValue !== undefined) return defaultValue; - throw new Error("No input provided"); + throw new CLIError("No input provided. Please provide a valid value."); }; diff --git a/src/lib/input/base-inputs.spec.ts b/src/lib/input/base-inputs.spec.ts index 4b9b95b..161a53e 100644 --- a/src/lib/input/base-inputs.spec.ts +++ b/src/lib/input/base-inputs.spec.ts @@ -32,7 +32,9 @@ describe("getStringInput", () => { it("should throw on non-string value", () => { const input = createInput([["name", 42]]); - expect(() => getStringInput(input, "name")).toThrow("Invalid type for name"); + expect(() => getStringInput(input, "name")).toThrow( + "Invalid argument 'name'. Expected: string.", + ); }); }); @@ -61,7 +63,9 @@ describe("getBooleanInput", () => { it("should throw on non-boolean value", () => { const input = createInput([["strict", "yes"]]); - expect(() => getBooleanInput(input, "strict")).toThrow("Invalid type for strict"); + expect(() => getBooleanInput(input, "strict")).toThrow( + "Invalid argument 'strict'. Expected: boolean.", + ); }); }); @@ -90,7 +94,7 @@ describe("getArrayInput", () => { it("should throw on non-array value", () => { const input = createInput([["libs", "not-array"]]); - expect(() => getArrayInput(input, "libs")).toThrow("Invalid type for libs"); + expect(() => getArrayInput(input, "libs")).toThrow("Invalid argument 'libs'. Expected: array."); }); }); diff --git a/src/lib/input/base-inputs.ts b/src/lib/input/base-inputs.ts index aef34ad..e9588cd 100644 --- a/src/lib/input/base-inputs.ts +++ b/src/lib/input/base-inputs.ts @@ -1,10 +1,12 @@ +import { InvalidCommandArgumentError } from "@utils/errors"; + import { type Input } from "./input.type"; export const getStringInput = (input: Input, field: string): string | undefined => { const value = input.get(field)?.value; if (value === undefined) return undefined; if (typeof value === "string") return value; - throw new Error(`Invalid type for ${field}`); + throw new InvalidCommandArgumentError(field, "string"); }; export const getStringInputWithDefault = ( @@ -19,7 +21,7 @@ export const getBooleanInput = (input: Input, field: string): boolean | undefine const value = input.get(field)?.value; if (value === undefined) return undefined; if (typeof value === "boolean") return value; - throw new Error(`Invalid type for ${field}`); + throw new InvalidCommandArgumentError(field, "boolean"); }; export const getBooleanInputWithDefault = ( @@ -34,7 +36,7 @@ export const getArrayInput = (input: Input, field: string): string[] | undefined const value = input.get(field)?.value; if (value === undefined) return undefined; if (typeof value === "object" && Array.isArray(value)) return value; - throw new Error(`Invalid type for ${field}`); + throw new InvalidCommandArgumentError(field, "array"); }; export const getArrayInputWithDefault = ( diff --git a/src/lib/input/inputs/create/type.input.ts b/src/lib/input/inputs/create/type.input.ts index f277524..fda6daa 100644 --- a/src/lib/input/inputs/create/type.input.ts +++ b/src/lib/input/inputs/create/type.input.ts @@ -1,8 +1,10 @@ -import { getStringInput } from "../../base-inputs"; -import { type Input } from "../../input.type"; +import { getStringInput } from "@lib/input"; +import { type Input } from "@lib/input"; + +import { InvalidCommandArgumentError } from "@utils/errors"; export const getCreateTypeInput = (inputs: Input): "component" | "system" => { const res = getStringInput(inputs, "type"); if (res && ["component", "system"].includes(res)) return res as "component" | "system"; - throw new Error("Invalid type. Please enter 'component' or 'system'."); + throw new InvalidCommandArgumentError("type", "'component' or 'system'"); }; diff --git a/src/lib/manifest/manifest-loader.ts b/src/lib/manifest/manifest-loader.ts index 2eb6af7..06eb2e7 100644 --- a/src/lib/manifest/manifest-loader.ts +++ b/src/lib/manifest/manifest-loader.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { MANIFEST_FILE_NAME } from "@lib/constants"; +import { ManifestError } from "@utils/errors"; import { deepMerge } from "@utils/object"; import { Manifest } from "./manifest.type"; @@ -14,7 +15,7 @@ const getManifestPath = (directory: string) => { const path = join(directory, n); if (existsSync(path)) return path; } - throw new Error(`No manifest file found in directory: ${directory}`); + throw new ManifestError(`No manifest file found in directory: ${directory}`); }; export const loadManifest = async (directory: string): Promise => { @@ -26,14 +27,17 @@ export const loadManifest = async (directory: string): Promise => { } catch { rawData = null; } - if (!rawData) throw new Error(`Not able to read manifest file : ${path}`); + if (!rawData) { + throw new ManifestError(`Unable to read or parse file at ${path}`); + } const data = plainToInstance(Manifest, rawData, { excludeExtraneousValues: true, }); const errors = await validate(data); - if (errors.length > 0) - throw new Error(`Invalid manifest\n${errors.toString().replace(/,/g, "\n")}`); + if (errors.length > 0) { + throw new ManifestError(`Validation failed\n${errors.toString()}`); + } return data; }; diff --git a/src/lib/package-manager/package-manager.factory.spec.ts b/src/lib/package-manager/package-manager.factory.spec.ts index 5780cee..315b3e0 100644 --- a/src/lib/package-manager/package-manager.factory.spec.ts +++ b/src/lib/package-manager/package-manager.factory.spec.ts @@ -38,7 +38,7 @@ describe("PackageManagerFactory", () => { it("should throw for unsupported package manager", () => { expect(() => PackageManagerFactory.create("unknown")).toThrow( - "Package manager unknown is not managed.", + "Package manager 'unknown' is not managed/supported.", ); }); }); diff --git a/src/lib/package-manager/package-manager.factory.ts b/src/lib/package-manager/package-manager.factory.ts index b5224e1..e2c4325 100644 --- a/src/lib/package-manager/package-manager.factory.ts +++ b/src/lib/package-manager/package-manager.factory.ts @@ -3,6 +3,8 @@ import { resolve } from "path"; import { RunnerFactory } from "@lib/runner"; +import { CLIError } from "@utils/errors"; + import { PackageManager } from "./package-manager"; import { PM_CONFIGS } from "./package-manager-configs"; import { PackageManagerName } from "./package-manager-name"; @@ -18,7 +20,7 @@ export class PackageManagerFactory { public static create(name: PackageManagerName | string): PackageManager { const config = PM_CONFIGS[name as PackageManagerName]; if (!config) { - throw new Error(`Package manager ${name} is not managed.`); + throw new CLIError(`Package manager '${name}' is not managed/supported.`); } const runner = this.createRunner(name as PackageManagerName, config.binary); diff --git a/src/lib/package-manager/package-manager.ts b/src/lib/package-manager/package-manager.ts index 2c14e98..9b17b20 100644 --- a/src/lib/package-manager/package-manager.ts +++ b/src/lib/package-manager/package-manager.ts @@ -4,6 +4,7 @@ import { createStderrLogger, createStdoutLogger } from "@lib/runner/process-logg import { type RunOptions, type Runner } from "@lib/runner/runner"; import { Messages } from "@lib/ui"; +import { CLIError } from "@utils/errors"; import { getCwd } from "@utils/path"; import { withSpinner } from "@utils/spinner"; @@ -168,7 +169,7 @@ export class PackageManager { private assertSupports(feature: keyof PackageManagerCommands): void { if (!this.commands[feature]) { - throw new Error(`Package manager "${this.name}" does not support "${feature}"`); + throw new CLIError(`Package manager "${this.name}" does not support "${feature}"`); } } @@ -192,7 +193,7 @@ export class PackageManager { flags: string[], silent: boolean, ): string[] { - if (!this.commands.runFile) throw new Error("Package manager does not support runFile"); + if (!this.commands.runFile) throw new CLIError("Package manager does not support runFile"); const args = [...flags, this.commands.runFile]; if (silent) args.push(this.commands.silentFlag); args.push(script); diff --git a/src/lib/question/questions/number.question.ts b/src/lib/question/questions/number.question.ts index eb9a0bb..ea7f50e 100644 --- a/src/lib/question/questions/number.question.ts +++ b/src/lib/question/questions/number.question.ts @@ -1,6 +1,6 @@ import { number } from "@inquirer/prompts"; -import { promptError } from "@utils/errors"; +import { CLIError, promptError } from "@utils/errors"; interface NumberOptions { default?: number; @@ -27,6 +27,7 @@ export const askNumber = async ( required: options.required, }).catch(promptError); - if (res === undefined || isNaN(res) || !isFinite(res)) throw new Error("Invalid number"); + if (res === undefined || isNaN(res) || !isFinite(res)) + throw new CLIError("Invalid number provided."); return res; }; diff --git a/src/lib/registry/registry.ts b/src/lib/registry/registry.ts index bba945f..7b92ec2 100644 --- a/src/lib/registry/registry.ts +++ b/src/lib/registry/registry.ts @@ -5,6 +5,7 @@ import { GlobalConfigHandler } from "@lib/global-config"; import { type Repository, withAuth } from "@lib/http"; import { type FullManifest, type Manifest } from "@lib/manifest"; +import { CLIError } from "@utils/errors"; import { getCwd } from "@utils/path"; export class Registry { @@ -62,14 +63,14 @@ export class Registry { private static _getPackageFile(filename: string, dir?: string): Promise { const path = join(getCwd(dir ?? "."), filename); if (!fs.existsSync(path)) - throw new Error( + throw new CLIError( "Package not found, please specify path in the nanoforge.manifest.json : `publish.paths.package`!", ); try { fs.accessSync(path, fs.constants.R_OK); return fs.openAsBlob(path); } catch { - throw new Error("Cannot read package file, please verify your file permissions!"); + throw new CLIError("Cannot read package file, please verify your file permissions!"); } } } diff --git a/src/lib/runner/runner.factory.ts b/src/lib/runner/runner.factory.ts index 1f8f428..e9b3e38 100644 --- a/src/lib/runner/runner.factory.ts +++ b/src/lib/runner/runner.factory.ts @@ -1,3 +1,4 @@ +import { CLIError } from "@utils/errors"; import { getModulePath, resolveCLINodeBinaryPath } from "@utils/path"; import { Runner } from "./runner"; @@ -20,7 +21,7 @@ export class RunnerFactory { try { return getModulePath("@angular-devkit/schematics-cli/bin/schematics.js"); } catch { - throw new Error("'schematics' binary path could not be found!"); + throw new CLIError("'schematics' binary path could not be found!"); } } } diff --git a/src/lib/schematics/collection.factory.ts b/src/lib/schematics/collection.factory.ts index 8a6e0f5..e6664ff 100644 --- a/src/lib/schematics/collection.factory.ts +++ b/src/lib/schematics/collection.factory.ts @@ -1,5 +1,7 @@ import { RunnerFactory } from "@lib/runner"; +import { CLIError } from "@utils/errors"; + import { type AbstractCollection } from "./abstract.collection"; import { Collection } from "./collection"; import { NanoforgeCollection } from "./nanoforge.collection"; @@ -11,6 +13,6 @@ export class CollectionFactory { if (collection === Collection.NANOFORGE) { return new NanoforgeCollection(schematicRunner, directory); } - throw new Error(`Unknown collection: ${collection}`); + throw new CLIError(`Unknown collection: ${collection}`); } } diff --git a/src/lib/schematics/nanoforge.collection.ts b/src/lib/schematics/nanoforge.collection.ts index fe53389..4989cec 100644 --- a/src/lib/schematics/nanoforge.collection.ts +++ b/src/lib/schematics/nanoforge.collection.ts @@ -1,5 +1,7 @@ import { type Runner } from "@lib/runner/runner"; +import { CLIError } from "@utils/errors"; + import { AbstractCollection } from "./abstract.collection"; import { type SchematicOption } from "./schematic.option"; @@ -72,7 +74,7 @@ export class NanoforgeCollection extends AbstractCollection { ); if (schematic === undefined || schematic === null) { - throw new Error( + throw new CLIError( `Invalid schematic "${name}". Please, ensure that "${name}" exists in this collection.`, ); } diff --git a/src/lib/utils/errors.ts b/src/lib/utils/errors.ts index 0ba9e7b..8e4a8ec 100644 --- a/src/lib/utils/errors.ts +++ b/src/lib/utils/errors.ts @@ -1,10 +1,63 @@ import { red } from "ansis"; -export const getErrorMessage = (error: unknown): string | undefined => { - if (error instanceof Error) return getErrorString(error); - if (typeof error === "string") return error; - return undefined; -}; +export class CLIError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class ConfigNotFoundError extends CLIError { + constructor(configPath: string) { + super( + `Configuration file not found at path: ${configPath}. Please run 'nf new' or provide a valid --config path.`, + ); + } +} + +export class BuildError extends CLIError { + constructor(details: string) { + super(`Build failed: ${details}`); + } +} + +export class InvalidCommandArgumentError extends CLIError { + constructor(argName: string, expected: string) { + super(`Invalid argument '${argName}'. Expected: ${expected}.`); + } +} + +export class RegistryAuthenticationError extends CLIError { + constructor() { + super("You must be logged in to perform this action. Run 'nf login'."); + } +} + +export class ProjectInitializationError extends CLIError { + constructor(details: string) { + super(`Failed to create new project: ${details}`); + } +} + +export class ManifestError extends CLIError { + constructor(detail: string) { + super(`Manifest Error: ${detail}`); + } +} + +export class FileSystemError extends CLIError { + constructor(action: string, targetPath: string) { + super(`File System Error [${action}]: ${targetPath}`); + } +} + +export class ApiRequestError extends CLIError { + constructor(status: number, cause?: unknown) { + const causeStr = cause && typeof cause === "object" ? JSON.stringify(cause, null, 2) : cause; + super(`API Request failed (Status ${status})${causeStr ? `\nDetails: ${causeStr}` : ""}`); + } +} const getErrorString = (error: Error): string => { const stack = error.stack ? error.stack : error.message; @@ -15,9 +68,19 @@ const getErrorString = (error: Error): string => { return `${stack}${cause ? `\n${cause}` : ""}`; }; +export const getErrorMessage = (error: unknown): string | undefined => { + if (error instanceof Error) return getErrorString(error); + if (typeof error === "string") return error; + return undefined; +}; + export const handleActionError = (context: string, error: unknown): never => { console.error(); console.error(red(context)); + if (error instanceof CLIError) { + console.error(error.message); + process.exit(1); + } const msg = getErrorMessage(error); if (msg) console.error(msg); process.exit(1); diff --git a/src/lib/utils/files.ts b/src/lib/utils/files.ts index 5e5aad6..c01d66b 100644 --- a/src/lib/utils/files.ts +++ b/src/lib/utils/files.ts @@ -1,9 +1,11 @@ import fs from "fs"; import { join } from "path"; +import { FileSystemError } from "@utils/errors"; + export const copyFiles = (from: string, to: string) => { if (!fs.existsSync(from)) return; - if (!fs.existsSync(to)) throw new Error(`Directory ${to} does not exist`); + if (!fs.existsSync(to)) throw new FileSystemError("directory not found", to); fs.readdirSync(from, { recursive: true }).forEach((file) => { fs.copyFileSync(join(from, file.toString()), join(to, file.toString())); }); diff --git a/src/lib/utils/path.ts b/src/lib/utils/path.ts index d0e45e0..127ab6f 100644 --- a/src/lib/utils/path.ts +++ b/src/lib/utils/path.ts @@ -1,6 +1,8 @@ import fs from "fs"; import { join, resolve } from "path"; +import { FileSystemError } from "@utils/errors"; + export const getCwd = (directory: string) => { return resolve(directory); }; @@ -22,5 +24,5 @@ export const resolveCLINodeBinaryPath = (name: string) => { base = join(base, ".."); } } - throw new Error("Could not find module path"); + throw new FileSystemError("resolve binary", name); };