| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Console from "effect/Console"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as Runtime from "effect/Runtime"; |
| 7 | import * as CliError from "effect/cli/CliError"; |
| 8 | import { isUserFacing, UserFacingError } from "../../UserFacingError.ts"; |
| 9 | import { |
| 10 | ANSI_DIM, |
| 11 | ANSI_RESET, |
| 12 | ansiFg, |
| 13 | colorsEnabled, |
| 14 | glyphsFor, |
| 15 | theme, |
| 16 | unicodeEnabled, |
| 17 | } from "../CliKit/index.ts"; |
| 18 | |
| 19 | const isTerminalCancelled = Schema.is( |
| 20 | Schema.Struct({ _tag: Schema.Literals(["TerminalCancelled"]) }), |
| 21 | ); |
| 22 | const isAuthErrorLike = Schema.is( |
| 23 | Schema.Struct({ |
| 24 | _tag: Schema.Literals(["AuthError"]), |
| 25 | cause: Schema.optional(Schema.Unknown), |
| 26 | }), |
| 27 | ); |
| 28 | |
| 29 | export const isPromptCancellation = (error: unknown): boolean => { |
| 30 | for (let current = error, depth = 0; current != null && depth < 16; depth++) { |
| 31 | if (isTerminalCancelled(current)) return true; |
| 32 | if (!isAuthErrorLike(current)) return false; |
| 33 | current = current.cause; |
| 34 | } |
| 35 | return false; |
| 36 | }; |
| 37 | |
| 38 | export const EXIT_CANCELLED = 130; |
| 39 | |
| 40 | export const setExitCode = (code: number) => |
| 41 | Effect.sync(() => { |
| 42 | process.exitCode = code; |
| 43 | }); |
| 44 | |
| 45 | export const exitDeclined = setExitCode(1); |
| 46 | |
| 47 | let interruptMessagesSuppressed = false; |
| 48 | export const suppressInterruptMessages = Effect.sync(() => { |
| 49 | interruptMessagesSuppressed = true; |
| 50 | }); |
| 51 | |
| 52 | const SHUTDOWN_FEEDBACK_DELAY_MS = 500; |
| 53 | |
| 54 | /** |
| 55 | * Keep quick shutdowns quiet, but acknowledge cleanup that takes long enough |
| 56 | * to be noticeable. The Effect runtime continues to own signal handling and |
| 57 | * teardown; this listener only writes delayed feedback. |
| 58 | */ |
| 59 | export const installShutdownFeedback = Effect.sync(() => { |
| 60 | let scheduled = false; |
| 61 | const onSignal = () => { |
| 62 | if (scheduled || interruptMessagesSuppressed) return; |
| 63 | scheduled = true; |
| 64 | const timer = setTimeout(() => { |
| 65 | process.stderr.write( |
| 66 | colorsEnabled() |
| 67 | ? `\n${ANSI_DIM}Shutting down${ANSI_RESET}\n` |
| 68 | : "\nShutting down\n", |
| 69 | ); |
| 70 | }, SHUTDOWN_FEEDBACK_DELAY_MS); |
| 71 | timer.unref(); |
| 72 | }; |
| 73 | process.on("SIGINT", onSignal); |
| 74 | process.on("SIGTERM", onSignal); |
| 75 | }); |
| 76 | |
| 77 | export const handleCancellation = <A, E, R>(self: Effect.Effect<A, E, R>) => |
| 78 | self.pipe( |
| 79 | Effect.catchCause((cause) => { |
| 80 | const cancelled = cause.reasons.some((reason) => { |
| 81 | if (Cause.isFailReason(reason)) |
| 82 | return isPromptCancellation(reason.error); |
| 83 | if (Cause.isDieReason(reason)) |
| 84 | return isPromptCancellation(reason.defect); |
| 85 | return false; |
| 86 | }); |
| 87 | return cancelled |
| 88 | ? Console.log( |
| 89 | colorsEnabled() |
| 90 | ? `\n${ANSI_DIM}Cancelled.${ANSI_RESET}` |
| 91 | : "\nCancelled.", |
| 92 | ).pipe(Effect.andThen(setExitCode(EXIT_CANCELLED))) |
| 93 | : (Effect.failCause(cause) as Effect.Effect<never, E, never>); |
| 94 | }), |
| 95 | Effect.onInterrupt(() => |
| 96 | interruptMessagesSuppressed |
| 97 | ? Effect.void |
| 98 | : Console.log( |
| 99 | colorsEnabled() ? `\n${ANSI_DIM}Exited.${ANSI_RESET}` : "\nExited.", |
| 100 | ), |
| 101 | ), |
| 102 | ); |
| 103 | |
| 104 | class ReportedCliError { |
| 105 | readonly [Runtime.errorReported] = false; |
| 106 | constructor(readonly cause: unknown) {} |
| 107 | } |
| 108 | |
| 109 | // `ConfigError` is effect's own class and cannot carry the `UserFacingError` |
| 110 | // marker. Everything else is matched by the marker alone — new user-facing |
| 111 | // errors opt in by assigning the symbol, not by growing this list. |
| 112 | const isUnmarkedUserFacingError = Schema.is( |
| 113 | Schema.Struct({ |
| 114 | _tag: Schema.Literals(["ConfigError"]), |
| 115 | message: Schema.String, |
| 116 | }), |
| 117 | ); |
| 118 | |
| 119 | const isUserFacingError = ( |
| 120 | error: unknown, |
| 121 | ): error is { readonly message: string } => |
| 122 | isUserFacing(error) || isUnmarkedUserFacingError(error); |
| 123 | |
| 124 | export class UserInputError extends Data.TaggedError("UserInputError")<{ |
| 125 | readonly message: string; |
| 126 | }> { |
| 127 | readonly [UserFacingError] = true; |
| 128 | } |
| 129 | |
| 130 | export const handleUserErrors = <A, E, R>(self: Effect.Effect<A, E, R>) => |
| 131 | self.pipe( |
| 132 | Effect.catchCause((cause) => { |
| 133 | for (const reason of cause.reasons) { |
| 134 | const error = Cause.isFailReason(reason) |
| 135 | ? reason.error |
| 136 | : Cause.isDieReason(reason) |
| 137 | ? reason.defect |
| 138 | : undefined; |
| 139 | if (isUserFacingError(error)) { |
| 140 | const glyphs = glyphsFor(unicodeEnabled()); |
| 141 | return Console.error( |
| 142 | `${colorsEnabled() ? `${ansiFg(theme.color.danger)}${glyphs.error} error:${ANSI_RESET}` : "error:"} ${error.message}`, |
| 143 | ).pipe( |
| 144 | Effect.flatMap(() => Effect.fail(new ReportedCliError(cause))), |
| 145 | ) as Effect.Effect<never, E | ReportedCliError, never>; |
| 146 | } |
| 147 | } |
| 148 | return Effect.failCause(cause) as Effect.Effect<never, E, never>; |
| 149 | }), |
| 150 | ); |
| 151 | |
| 152 | export const handleCliErrors = <A, E, R>(self: Effect.Effect<A, E, R>) => |
| 153 | self.pipe(handleCancellation, handleUserErrors); |
| 154 | |
| 155 | export const failWithHelp = (commandPath: ReadonlyArray<string>) => |
| 156 | setExitCode(1).pipe( |
| 157 | Effect.andThen( |
| 158 | Effect.fail( |
| 159 | new CliError.ShowHelp({ commandPath: [...commandPath], errors: [] }), |
| 160 | ), |
| 161 | ), |
| 162 | ); |