1import * as Cause from "effect/Cause";
2import * as Console from "effect/Console";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";
6import * as Runtime from "effect/Runtime";
7import * as CliError from "effect/cli/CliError";
8import { isUserFacing, UserFacingError } from "../../UserFacingError.ts";
9import {
10 ANSI_DIM,
11 ANSI_RESET,
12 ansiFg,
13 colorsEnabled,
14 glyphsFor,
15 theme,
16 unicodeEnabled,
17} from "../CliKit/index.ts";
18
19const isTerminalCancelled = Schema.is(
20 Schema.Struct({ _tag: Schema.Literals(["TerminalCancelled"]) }),
21);
22const isAuthErrorLike = Schema.is(
23 Schema.Struct({
24 _tag: Schema.Literals(["AuthError"]),
25 cause: Schema.optional(Schema.Unknown),
26 }),
27);
28
29export 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
38export const EXIT_CANCELLED = 130;
39
40export const setExitCode = (code: number) =>
41 Effect.sync(() => {
42 process.exitCode = code;
43 });
44
45export const exitDeclined = setExitCode(1);
46
47let interruptMessagesSuppressed = false;
48export const suppressInterruptMessages = Effect.sync(() => {
49 interruptMessagesSuppressed = true;
50});
51
52const 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 */
59export 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
77export 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
104class 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.
112const isUnmarkedUserFacingError = Schema.is(
113 Schema.Struct({
114 _tag: Schema.Literals(["ConfigError"]),
115 message: Schema.String,
116 }),
117);
118
119const isUserFacingError = (
120 error: unknown,
121): error is { readonly message: string } =>
122 isUserFacing(error) || isUnmarkedUserFacingError(error);
123
124export class UserInputError extends Data.TaggedError("UserInputError")<{
125 readonly message: string;
126}> {
127 readonly [UserFacingError] = true;
128}
129
130export 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
152export const handleCliErrors = <A, E, R>(self: Effect.Effect<A, E, R>) =>
153 self.pipe(handleCancellation, handleUserErrors);
154
155export 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 );