diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index e79efca..3909e7b 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -2,7 +2,12 @@ import { readFile } from "node:fs/promises"; -import { InternalError, Result } from "@outfitter/contracts"; +import { + type AnyKitError, + InternalError, + Result, + ValidationError, +} from "@outfitter/contracts"; import { type InsertionResult, type InsertionSpec, @@ -128,16 +133,22 @@ function assertAddOptionConflicts( contentArg?: string ): void { if (options.position && (options.before || options.after)) { - throw new Error("Use --position or --before/--after (not both)."); + throw ValidationError.fromMessage( + "Use --position or --before/--after (not both)." + ); } if (options.before && options.after) { - throw new Error("Cannot combine --before and --after."); + throw ValidationError.fromMessage("Cannot combine --before and --after."); } if (options.type && typeArg) { - throw new Error("Cannot combine --type with positional ."); + throw ValidationError.fromMessage( + "Cannot combine --type with positional ." + ); } if (options.content && contentArg) { - throw new Error("Cannot combine --content with positional ."); + throw ValidationError.fromMessage( + "Cannot combine --content with positional ." + ); } } @@ -192,7 +203,7 @@ function assertValidPosition( value: string ): asserts value is "before" | "after" { if (value !== "before" && value !== "after") { - throw new Error("--position must be 'before' or 'after'"); + throw ValidationError.create("position", "must be 'before' or 'after'"); } } @@ -230,7 +241,7 @@ function applyOrderAndId( if (options.order !== undefined && options.order !== null) { const parsedOrder = Number.parseInt(String(options.order), 10); if (!Number.isFinite(parsedOrder)) { - throw new Error("--order expects an integer"); + throw ValidationError.create("order", "expects an integer"); } state.order = parsedOrder; } @@ -263,7 +274,7 @@ function validateFromMode(state: InsertParseState): void { state.id !== undefined; if (hasInlineData) { - throw new Error( + throw ValidationError.fromMessage( "Positional and inline flags cannot be combined with --from" ); } @@ -275,13 +286,18 @@ function ensureRequiredFields(state: InsertParseState): { content: string; } { if (!state.fileLine) { - throw new Error("add requires a FILE:LINE positional argument"); + throw ValidationError.fromMessage( + "add requires a FILE:LINE positional argument" + ); } if (!state.type) { - throw new Error("--type is required when not using --from"); + throw ValidationError.create("type", "is required when not using --from"); } if (!state.content) { - throw new Error("--content is required when not using --from"); + throw ValidationError.create( + "content", + "is required when not using --from" + ); } return { fileLine: state.fileLine, @@ -358,13 +374,17 @@ export type AddCommandResult = { export function runAddCommand( parsed: ParsedAddArgs, context: CommandContext -): Promise> { +): Promise> { return Result.tryPromise({ try: () => runAddCommandInner(parsed, context), - catch: (cause) => - new InternalError({ - message: `Add failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Add failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } @@ -377,7 +397,7 @@ async function runAddCommandInner( : parsed.specs; if (specs.length === 0) { - throw new Error("No insertions provided"); + throw ValidationError.fromMessage("No insertions provided"); } const idManager = parsed.options.write @@ -423,7 +443,7 @@ async function loadSpecsFromSource(path: string): Promise { const issuePath = issue.path.length > 0 ? issue.path.join(".") : "root"; logger.error(` - ${issuePath}: ${issue.message}`); } - throw new Error("JSON validation failed"); + throw ValidationError.fromMessage("JSON validation failed"); } throw error; } @@ -442,7 +462,7 @@ function normalizeInsertionSpecs(parsed: unknown): unknown { return [parsed]; } - throw new Error( + throw ValidationError.fromMessage( "Invalid JSON: expected InsertionSpec, InsertionSpec[], or { insertions: InsertionSpec[] }" ); } diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 2236127..0c6ce80 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -1,7 +1,7 @@ // tldr ::: cross-file content integrity validation for waymarks import { ANSI } from "@outfitter/cli/colors"; -import { InternalError, Result } from "@outfitter/contracts"; +import { type AnyKitError, InternalError, Result } from "@outfitter/contracts"; import { parse, type WaymarkRecord } from "@waymarks/core"; import type { CommandContext } from "../types.ts"; import { expandInputPaths } from "../utils/fs.ts"; @@ -332,13 +332,17 @@ async function parseFiles( export function runCheckCommand( context: CommandContext, options: CheckCommandOptions -): Promise> { +): Promise> { return Result.tryPromise({ try: () => runCheckCommandInner(context, options), - catch: (cause) => - new InternalError({ - message: `Check failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Check failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 80b42bb..9e53ac2 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -5,7 +5,7 @@ import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { ANSI } from "@outfitter/cli/colors"; -import { InternalError, Result } from "@outfitter/contracts"; +import { type AnyKitError, InternalError, Result } from "@outfitter/contracts"; import type { CommandContext } from "../types"; import { logger } from "../utils/logger"; import { wrap } from "../utils/theme"; @@ -63,13 +63,17 @@ export type DoctorCommandOptions = { export function runDoctorCommand( context: CommandContext, options: DoctorCommandOptions -): Promise> { +): Promise> { return Result.tryPromise({ try: () => runDoctorCommandInner(context, options), - catch: (cause) => - new InternalError({ - message: `Doctor diagnostics failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Doctor diagnostics failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } diff --git a/packages/cli/src/commands/fmt.ts b/packages/cli/src/commands/fmt.ts index 58b9662..e1ccae4 100644 --- a/packages/cli/src/commands/fmt.ts +++ b/packages/cli/src/commands/fmt.ts @@ -2,7 +2,12 @@ import { readFile, writeFile } from "node:fs/promises"; -import { InternalError, Result } from "@outfitter/contracts"; +import { + type AnyKitError, + InternalError, + Result, + ValidationError, +} from "@outfitter/contracts"; import type { FormatResult, WaymarkConfig } from "@waymarks/core"; import { formatText } from "@waymarks/core"; @@ -26,13 +31,17 @@ const IGNORE_FILE_MARKER_PATTERN = export function formatFile( options: { filePath: string; write: boolean }, context: CommandContext -): Promise> { +): Promise> { return Result.tryPromise({ try: () => formatFileInner(options, context), - catch: (cause) => - new InternalError({ - message: `Format failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Format failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } @@ -102,16 +111,20 @@ async function filterFilesWithWaymarks(paths: string[]): Promise { export function expandFormatPaths( inputs: string[], config: WaymarkConfig -): Promise> { +): Promise> { return Result.tryPromise({ try: async () => { const expanded = await expandInputPaths(inputs, config); return await filterFilesWithWaymarks(expanded); }, - catch: (cause) => - new InternalError({ - message: `Path expansion failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Path expansion failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } @@ -122,7 +135,7 @@ export function expandFormatPaths( */ export function parseFormatArgs(argv: string[]): FormatCommandOptions { if (argv.length === 0) { - throw new Error("fmt requires at least one file path"); + throw ValidationError.fromMessage("fmt requires at least one file path"); } const yes = argv.includes("--yes") || argv.includes("-y"); @@ -130,7 +143,7 @@ export function parseFormatArgs(argv: string[]): FormatCommandOptions { const filePaths = remaining.filter((path) => path.length > 0); if (filePaths.length === 0) { - throw new Error("fmt requires at least one file path"); + throw ValidationError.fromMessage("fmt requires at least one file path"); } return { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9930913..5531839 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -4,7 +4,13 @@ import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { CancelledError, InternalError, Result } from "@outfitter/contracts"; +import { + type AnyKitError, + CancelledError, + InternalError, + Result, + ValidationError, +} from "@outfitter/contracts"; import { promptSelect } from "../utils/clack-prompts.ts"; import { logger } from "../utils/logger.ts"; import { assertPromptAllowed } from "../utils/prompts.ts"; @@ -31,13 +37,17 @@ const CONFIG_SCOPES: ConfigScope[] = ["project", "user"]; */ export function runInitCommand( options: InitCommandOptions = {} -): Promise> { +): Promise> { return Result.tryPromise({ try: () => runInitCommandInner(options), - catch: (cause) => - new InternalError({ - message: `Init failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Init failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } @@ -101,7 +111,7 @@ async function runInitCommandInner( // Check if config already exists if (existsSync(configPath) && !force) { - throw new Error( + throw ValidationError.fromMessage( `Config already exists at ${configPath}\nUse --force to overwrite` ); } @@ -136,8 +146,9 @@ async function runInitCommandInner( function validateFormat(format: string): ConfigFormat { if (!CONFIG_FORMATS.includes(format as ConfigFormat)) { - throw new Error( - `Invalid format "${format}". Use one of: ${CONFIG_FORMATS.join(", ")}` + throw ValidationError.create( + "format", + `"${format}" is invalid. Use one of: ${CONFIG_FORMATS.join(", ")}` ); } return format as ConfigFormat; @@ -145,8 +156,9 @@ function validateFormat(format: string): ConfigFormat { function validatePreset(preset: string): ConfigPreset { if (!CONFIG_PRESETS.includes(preset as ConfigPreset)) { - throw new Error( - `Invalid preset "${preset}". Use one of: ${CONFIG_PRESETS.join(", ")}` + throw ValidationError.create( + "preset", + `"${preset}" is invalid. Use one of: ${CONFIG_PRESETS.join(", ")}` ); } return preset as ConfigPreset; @@ -154,8 +166,9 @@ function validatePreset(preset: string): ConfigPreset { function validateScope(scope: string): ConfigScope { if (!CONFIG_SCOPES.includes(scope as ConfigScope)) { - throw new Error( - `Invalid scope "${scope}". Use one of: ${CONFIG_SCOPES.join(", ")}` + throw ValidationError.create( + "scope", + `"${scope}" is invalid. Use one of: ${CONFIG_SCOPES.join(", ")}` ); } return scope as ConfigScope; diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 812f0ee..8720845 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,7 +1,12 @@ // tldr ::: lint command helpers for waymark CLI import { readFile } from "node:fs/promises"; -import { InternalError, Result } from "@outfitter/contracts"; +import { + type AnyKitError, + InternalError, + Result, + ValidationError, +} from "@outfitter/contracts"; import { isValidType, parse, @@ -402,7 +407,7 @@ export function parseLintArgs(argv: string[]): LintCommandOptions { const json = argv.includes("--json"); const filePaths = argv.filter((arg) => !arg.startsWith("-")); if (filePaths.length === 0) { - throw new Error("lint requires at least one file path"); + throw ValidationError.fromMessage("lint requires at least one file path"); } return { filePaths, json }; } @@ -418,13 +423,17 @@ export function lintFiles( filePaths: string[], allowTypes: string[], config: WaymarkConfig -): Promise> { +): Promise> { return Result.tryPromise({ try: () => lintFilesInner(filePaths, allowTypes, config), - catch: (cause) => - new InternalError({ - message: `Lint failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Lint failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } diff --git a/packages/cli/src/commands/modify.ts b/packages/cli/src/commands/modify.ts index b43cb72..4a873a8 100644 --- a/packages/cli/src/commands/modify.ts +++ b/packages/cli/src/commands/modify.ts @@ -1,7 +1,13 @@ // tldr ::: edit command implementation for wm CLI import { readFile, writeFile } from "node:fs/promises"; -import { InternalError, Result } from "@outfitter/contracts"; +import { + type AnyKitError, + InternalError, + NotFoundError, + Result, + ValidationError, +} from "@outfitter/contracts"; import { fingerprintContent, fingerprintContext, @@ -119,13 +125,17 @@ export function runModifyCommand( targetArg: string | undefined, options: ModifyOptions, io: ModifyIo = DEFAULT_IO -): Promise> { +): Promise> { return Result.tryPromise({ try: () => runModifyCommandInner(context, targetArg, options, io), - catch: (cause) => - new InternalError({ - message: `Modify failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Modify failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } @@ -139,7 +149,9 @@ async function runModifyCommandInner( const snapshot = await loadWaymarkSnapshot(target); const originalFirstLine = snapshot.lines[snapshot.lineIndex]; if (!originalFirstLine) { - throw new Error(`Line ${snapshot.lineIndex + 1} not found in file`); + throw NotFoundError.create("line", String(snapshot.lineIndex + 1), { + file: target.file, + }); } const originalContent = extractFirstLineContent(originalFirstLine); const existingId = extractTrailingId(originalContent); @@ -271,7 +283,7 @@ async function loadWaymarkSnapshot(target: ModifyTarget): Promise { const records = parse(fileContent, { file: target.file }); const record = records.find((entry) => entry.startLine === target.line); if (!record) { - throw new Error(`No waymark found at ${target.file}:${target.line}`); + throw NotFoundError.create("waymark", `${target.file}:${target.line}`); } const lineIndex = record.startLine - 1; return { @@ -346,7 +358,7 @@ function determineType(current: string, requested?: string): string { } const trimmed = requested.trim(); if (!trimmed) { - throw new Error("Waymark type cannot be empty"); + throw ValidationError.create("type", "cannot be empty"); } return trimmed; } @@ -458,10 +470,12 @@ async function resolveTarget( idOption?: string ): Promise { if (targetArg && idOption) { - throw new Error("Cannot specify both file:line and --id"); + throw ValidationError.fromMessage("Cannot specify both file:line and --id"); } if (!(targetArg || idOption)) { - throw new Error("Must provide a target (file:line) or --id"); + throw ValidationError.fromMessage( + "Must provide a target (file:line) or --id" + ); } if (idOption) { @@ -469,7 +483,9 @@ async function resolveTarget( } if (!targetArg) { - throw new Error("Target argument is required when --id is not provided"); + throw ValidationError.fromMessage( + "Target argument is required when --id is not provided" + ); } return parseFileLineTarget(targetArg, { @@ -486,7 +502,7 @@ async function resolveTargetFromId( const index = new JsonIdIndex({ workspaceRoot }); const entry = await index.get(normalized); if (!entry) { - throw new Error(`Waymark ID ${normalized} not found in index`); + throw NotFoundError.create("waymark ID", normalized); } return { file: entry.file, @@ -678,7 +694,7 @@ function ensureModificationsSpecified(options: ModifyOptions): void { const hasContent = options.content !== undefined; if (!(hasType || hasSignals || hasContent)) { - throw new Error( + throw ValidationError.fromMessage( "No modifications specified. Use --type, --flagged, --starred, --clear-signals, --content, or run without arguments for interactive prompts." ); } diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index fbb3e0e..72bdfa1 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -2,7 +2,12 @@ import { readFile } from "node:fs/promises"; -import { InternalError, Result } from "@outfitter/contracts"; +import { + type AnyKitError, + InternalError, + Result, + ValidationError, +} from "@outfitter/contracts"; import { type RemovalResult, type RemovalSpec, @@ -179,7 +184,7 @@ function finalizeRemoveState(state: RemoveParseState): ParsedRemoveArgs { if (state.from) { if (state.positional.length > 0 || state.ids.length > 0) { - throw new Error( + throw ValidationError.fromMessage( "Cannot combine --from with positional arguments or --id" ); } @@ -201,7 +206,7 @@ function finalizeRemoveState(state: RemoveParseState): ParsedRemoveArgs { } if (specs.length === 0) { - throw new Error("No removal targets provided"); + throw ValidationError.fromMessage("No removal targets provided"); } return { specs, options }; @@ -276,13 +281,17 @@ export function runRemoveCommand( parsed: ParsedRemoveArgs, context: CommandContext, execution: { writeOverride?: boolean } = {} -): Promise> { +): Promise> { return Result.tryPromise({ try: () => runRemoveCommandInner(parsed, context, execution), - catch: (cause) => - new InternalError({ - message: `Remove failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Remove failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } @@ -449,7 +458,7 @@ function parseRemovalPayload(parsed: unknown): LoadedRemovePayload { return { specs: [RemovalSpecSchema.parse(parsed)] }; } - throw new Error( + throw ValidationError.fromMessage( "Invalid JSON: expected RemovalSpec, RemovalSpec[], or { removals: RemovalSpec[] }" ); } @@ -464,7 +473,7 @@ async function loadSpecsFromSource(path: string): Promise { } catch (error) { if (error instanceof ZodError) { logZodValidationErrors(error); - throw new Error("JSON validation failed"); + throw ValidationError.fromMessage("JSON validation failed"); } throw error; } diff --git a/packages/cli/src/commands/scan.ts b/packages/cli/src/commands/scan.ts index 58fb5da..cd2b57d 100644 --- a/packages/cli/src/commands/scan.ts +++ b/packages/cli/src/commands/scan.ts @@ -2,7 +2,12 @@ import { readFile, stat } from "node:fs/promises"; import { performance } from "node:perf_hooks"; -import { InternalError, Result } from "@outfitter/contracts"; +import { + type AnyKitError, + InternalError, + Result, + ValidationError, +} from "@outfitter/contracts"; import { canHaveWaymarks, parse, @@ -119,13 +124,17 @@ export function scanRecords( filePaths: string[], config: WaymarkConfig, options: ScanRuntimeOptions = {} -): Promise> { +): Promise> { return Result.tryPromise({ try: () => scanRecordsInner(filePaths, config, options), - catch: (cause) => - new InternalError({ - message: `Scan failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Scan failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } @@ -241,7 +250,7 @@ function createCache(options: ScanRuntimeOptions): WaymarkCache | undefined { */ export function parseScanArgs(argv: string[]): ParsedScanArgs { if (argv.length === 0) { - throw new Error("scan requires a file path"); + throw ValidationError.fromMessage("scan requires a file path"); } let format: ScanOutputFormat = "text"; @@ -259,10 +268,10 @@ export function parseScanArgs(argv: string[]): ParsedScanArgs { if (arg.startsWith("--")) { const nextFormat = formatFlags[arg]; if (!nextFormat) { - throw new Error(`Unknown flag for scan: ${arg}`); + throw ValidationError.fromMessage(`Unknown flag for scan: ${arg}`); } if (formatSet) { - throw new Error("scan accepts only one format flag"); + throw ValidationError.fromMessage("scan accepts only one format flag"); } format = nextFormat; formatSet = true; @@ -272,7 +281,7 @@ export function parseScanArgs(argv: string[]): ParsedScanArgs { } if (positional.length === 0) { - throw new Error("scan requires a file path"); + throw ValidationError.fromMessage("scan requires a file path"); } return { filePaths: positional, format }; diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 57c7ea8..e5552a1 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -4,7 +4,7 @@ import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { sep } from "node:path"; -import { InternalError, Result } from "@outfitter/contracts"; +import { type AnyKitError, InternalError, Result } from "@outfitter/contracts"; import { logger } from "../utils/logger.ts"; import { confirm } from "../utils/prompts.ts"; @@ -205,13 +205,17 @@ async function confirmUpdateCommand( */ export function runUpdateCommand( options: UpdateCommandOptions = {} -): Promise> { +): Promise> { return Result.tryPromise({ try: () => runUpdateCommandInner(options), - catch: (cause) => - new InternalError({ - message: `Update failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + catch: (cause) => { + if (cause instanceof Error && "category" in cause) { + return cause as AnyKitError; + } + return InternalError.create( + `Update failed: ${cause instanceof Error ? cause.message : String(cause)}` + ); + }, }); } @@ -266,7 +270,7 @@ async function runUpdateCommandInner( const childExitCode = await runChild(command, NPM_UPDATE_ARGS); if (childExitCode !== 0) { - throw new Error(`npm install exited with code ${childExitCode}`); + throw InternalError.create(`npm install exited with code ${childExitCode}`); } return {