1import { stripVTControlCharacters } from "node:util";
2import type * as ConsoleService from "effect/Console";
3import * as Effect from "effect/Effect";
4import { makeLineBuffer } from "./LineBuffer.ts";
5import { ANSI_RESET, ansiFg, colorsEnabled } from "./Terminal.ts";
6import { theme } from "./Theme.ts";
7
8export type OutputChannel = "stdout" | "stderr";
9
10/** Sigil-themed resource attribution shared by every append-only log path. */
11export const formatResourceTag = (
12 id: string,
13 colors = colorsEnabled(),
14): string =>
15 colors ? `${ansiFg(theme.color.info)}[${id}]${ANSI_RESET}` : `[${id}]`;
16
17const EFFECT_LOG_PREFIX =
18 /^\[\d{2}:\d{2}:\d{2}\.\d{3}\] (?:ALL|TRACE|DEBUG|INFO|WARN|ERROR|FATAL|NONE) \(#\d+\)(?: [^:]+)?: /;
19
20/** Remove a pretty Effect prefix already rendered by a child process. */
21export const stripChildEffectPrefix = (line: string): string => {
22 const match = stripVTControlCharacters(line).match(EFFECT_LOG_PREFIX);
23 if (match === null) return line;
24
25 // The match is measured against visible text. Walk the original line so
26 // ANSI embedded in the child message survives while the prefix's ANSI is
27 // discarded with the prefix itself.
28 let visible = 0;
29 let offset = 0;
30 while (offset < line.length && visible < match[0].length) {
31 if (line[offset] === "\x1b") {
32 const ansi = line.slice(offset).match(/^\x1b\[[0-?]*[ -/]*[@-~]/);
33 if (ansi !== null) {
34 offset += ansi[0].length;
35 continue;
36 }
37 }
38 offset++;
39 visible++;
40 }
41 return line.slice(offset);
42};
43
44/**
45 * The single terminal-output pipeline for resource-owned processes. Dev
46 * servers, local workers, and deploy-time builders all use the same line
47 * splitting, resource prefix, color policy, and stdout/stderr severity.
48 */
49export const makeResourceOutput = (
50 id: string,
51 console: Pick<ConsoleService.Console, "log">,
52) => {
53 const prefix = formatResourceTag(id);
54 // stderr is a process transport, not a semantic failure: Vite and many
55 // other tools write warnings, progress, and ordinary diagnostics there.
56 // Sending it through Console.error makes CLIKit prepend an error glyph to
57 // every line. Both streams therefore enter the renderer as plain resource
58 // output; the child text retains its own ANSI severity styling.
59 const writeLine = (line: string) =>
60 console.log(`${prefix} ${stripChildEffectPrefix(line)}`);
61 return {
62 stdout: makeLineBuffer(writeLine),
63 stderr: makeLineBuffer(writeLine),
64 };
65};
66
67/**
68 * Effect-native output for resource-owned child processes whose streams are
69 * already consumed inside an Effect. Both process channels are transport
70 * details, not semantic severity, so they enter the configured logger at the
71 * info level. The logger remains responsible for terminal and file sinks.
72 */
73export const makeResourceLogger = (id: string) => {
74 const prefix = formatResourceTag(id);
75 return (_channel: OutputChannel, line: string) =>
76 Effect.logInfo(`${prefix} ${stripChildEffectPrefix(line)}`);
77};