1import * as Clock from "effect/Clock";
2import * as Effect from "effect/Effect";
3import * as Duration from "effect/Duration";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";
7import * as LogLevel from "effect/LogLevel";
8import * as Option from "effect/Option";
9import { Path } from "effect/Path";
10import { MinimumLogLevel } from "effect/References";
11import { rootDir } from "../Auth/Paths.ts";
12import packageJson from "../../package.json" with { type: "json" };
13import { makePlainConsoleSink } from "../Util/ConsoleSink.ts";
14
15/** Logs older than this are pruned (by mtime) on CLI start. */
16const MAX_LOG_AGE = Duration.days(7);
17
18/** Same spellings `GlobalFlag.LogLevel` accepts, so the two never disagree. */
19const logLevelChoices: Record<string, LogLevel.LogLevel> = {
20 all: "All",
21 trace: "Trace",
22 debug: "Debug",
23 info: "Info",
24 warn: "Warn",
25 warning: "Warn",
26 error: "Error",
27 fatal: "Fatal",
28 none: "None",
29};
30
31/**
32 * The console keeps its usual Info+ noise floor by default; everything below
33 * it exists only for the run log, so support requests can start with "attach
34 * the file from ~/.alchemy/logs". An explicit `--log-level` lowers the floor
35 * for that run so `--log-level debug` actually puts debug records on the
36 * terminal (#1231) — the run log captures Debug regardless.
37 *
38 * Read from argv rather than `GlobalFlag.LogLevel` because the console logger
39 * is installed while the service layers are built, before the parser has
40 * produced flag values (same reason `--no-input` is an argv scan). Only the
41 * segment before a `--` separator is inspected; an unrecognised value falls
42 * back to Info and is left for the parser to reject.
43 */
44export const consoleLogFloor = (
45 argv: ReadonlyArray<string>,
46): LogLevel.LogLevel => {
47 const separator = argv.indexOf("--");
48 const flagArgs = separator === -1 ? argv : argv.slice(0, separator);
49 let floor: LogLevel.LogLevel = "Info";
50 for (let i = 0; i < flagArgs.length; i++) {
51 const arg = flagArgs[i]!;
52 const value =
53 arg === "--log-level"
54 ? flagArgs[i + 1]
55 : arg.startsWith("--log-level=")
56 ? arg.slice("--log-level=".length)
57 : undefined;
58 if (value === undefined) continue;
59 floor = logLevelChoices[value.toLowerCase()] ?? floor;
60 }
61 return floor;
62};
63
64/** Terminal sink: forwards records at or above `floor` to `sink`. */
65export const makeConsoleLogger = (
66 floor: LogLevel.LogLevel,
67 sink: Logger.Logger<unknown, void> = makePlainConsoleSink(),
68) =>
69 Logger.make<unknown, void>((options) => {
70 if (LogLevel.isGreaterThanOrEqualTo(options.logLevel, floor)) {
71 sink.log(options);
72 }
73 });
74
75/** Shared terminal logger for CLI entrypoints that do not need a run file. */
76export const ConsoleLogLive = Layer.mergeAll(
77 Logger.layer([makeConsoleLogger(consoleLogFloor(process.argv.slice(2)))]),
78 Layer.succeed(MinimumLogLevel, "Debug"),
79);
80
81/** Drops everything — the fallback when the log file cannot be opened. */
82const noopLogger = Logger.make<unknown, void>(() => {});
83
84/**
85 * Debug run log for the whole CLI under `~/.alchemy/logs` (relocated by
86 * `ALCHEMY_HOME` together with the rest of the auth state). Every run writes
87 * `{timestamp}-pid{pid}.log` in logfmt at Debug level — profile/auth flows
88 * and command failures record their full causes there even though the
89 * terminal only shows the friendly message. Console output stays at Info+
90 * unless `--log-level` lowers the floor (see {@link consoleLogFloor}).
91 *
92 * Commands that install their own loggers with `mergeWithExisting: true`
93 * compose with this one; a replacing `Logger.layer` scopes it out for that
94 * subtree only. Best-effort throughout: an unwritable `~/.alchemy` must
95 * never take the CLI down with it.
96 */
97export const GlobalLogLive = Layer.unwrap(
98 Effect.gen(function* () {
99 const fs = yield* FileSystem.FileSystem;
100 const path = yield* Path;
101 const dir = path.join(rootDir(), "logs");
102 yield* fs.makeDirectory(dir, { recursive: true });
103
104 // Prune stale run logs so the directory stays bounded.
105 const now = yield* Clock.currentTimeMillis;
106 const entries = yield* fs
107 .readDirectory(dir)
108 .pipe(Effect.catch(() => Effect.succeed([] as string[])));
109 yield* Effect.forEach(
110 entries,
111 (entry) =>
112 Effect.gen(function* () {
113 const file = path.join(dir, entry);
114 const info = yield* fs.stat(file);
115 const mtime = Option.getOrUndefined(info.mtime)?.getTime();
116 if (
117 mtime !== undefined &&
118 now - mtime > Duration.toMillis(MAX_LOG_AGE)
119 ) {
120 yield* fs.remove(file);
121 }
122 }).pipe(Effect.ignore),
123 { discard: true },
124 );
125
126 const stamp = new Date(yield* Clock.currentTimeMillis)
127 .toISOString()
128 .slice(0, 19)
129 .replaceAll(":", "-");
130 const file = path.join(dir, `${stamp}-pid${process.pid}.log`);
131 return Layer.mergeAll(
132 Logger.layer([
133 makeConsoleLogger(consoleLogFloor(process.argv.slice(2))),
134 Logger.formatLogFmt.pipe(
135 Logger.toFile(file, { flag: "a" }),
136 Effect.catch(() => Effect.succeed(noopLogger)),
137 ),
138 ]),
139 Layer.succeed(MinimumLogLevel, "Debug"),
140 );
141 }).pipe(Effect.catch(() => Effect.succeed(Layer.empty))),
142);
143
144/** First line of every run log: enough context to read it standalone. */
145export const logRunHeader: Effect.Effect<void> = Effect.logDebug(
146 `alchemy ${packageJson.version} pid=${process.pid} cwd=${process.cwd()} argv=${JSON.stringify(process.argv.slice(2))}`,
147);