| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Logger from "effect/Logger"; |
| 7 | import * as LogLevel from "effect/LogLevel"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import { Path } from "effect/Path"; |
| 10 | import { MinimumLogLevel } from "effect/References"; |
| 11 | import { rootDir } from "../Auth/Paths.ts"; |
| 12 | import packageJson from "../../package.json" with { type: "json" }; |
| 13 | import { makePlainConsoleSink } from "../Util/ConsoleSink.ts"; |
| 14 | |
| 15 | /** Logs older than this are pruned (by mtime) on CLI start. */ |
| 16 | const MAX_LOG_AGE = Duration.days(7); |
| 17 | |
| 18 | /** Same spellings `GlobalFlag.LogLevel` accepts, so the two never disagree. */ |
| 19 | const 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 | */ |
| 44 | export 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`. */ |
| 65 | export 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. */ |
| 76 | export 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. */ |
| 82 | const 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 | */ |
| 97 | export 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. */ |
| 145 | export 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 | ); |