1import * as NetService from "@t3tools/shared/Net";
2import {
3 OtlpHeadersFromString,
4 OtlpProtocol,
5 type SignalExport,
6} from "@t3tools/shared/observability";
7import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
8import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
9import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
10import * as Config from "effect/Config";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as LogLevel from "effect/LogLevel";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as Redacted from "effect/Redacted";
18import * as Schema from "effect/Schema";
19import * as SchemaIssue from "effect/SchemaIssue";
20import * as SchemaTransformation from "effect/SchemaTransformation";
21import { Argument, Flag } from "effect/cli";
22import * as CliError from "effect/cli/CliError";
23
24import { readBootstrapEnvelope } from "../bootstrap.ts";
25import * as ServerConfig from "../config.ts";
26import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
27import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
28
29const modeFlag = Flag.Literals("mode", ServerConfig.RuntimeMode.literals).pipe(
30 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
31 Flag.optional,
32);
33const portFlag = Flag.Int("port").pipe(
34 Flag.withSchema(PortSchema),
35 Flag.withDescription("Port for the HTTP/WebSocket server."),
36 Flag.optional,
37);
38const hostFlag = Flag.String("host").pipe(
39 Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
40 Flag.optional,
41);
42export const baseDirFlag = Flag.String("base-dir").pipe(
43 Flag.withDescription(
44 "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).",
45 ),
46 Flag.optional,
47);
48const devUrlFlag = Flag.String("dev-url").pipe(
49 Flag.withSchema(Schema.URLFromString),
50 Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
51 Flag.optional,
52);
53const noBrowserFlag = Flag.Boolean("no-browser").pipe(
54 Flag.withDescription("Disable automatic browser opening."),
55 Flag.optional,
56);
57const bootstrapFdFlag = Flag.Int("bootstrap-fd").pipe(
58 Flag.withSchema(Schema.Int),
59 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
60 Flag.optional,
61);
62const autoBootstrapProjectFromCwdFlag = Flag.Boolean("auto-bootstrap-project-from-cwd").pipe(
63 Flag.withDescription(
64 "Create a project for the current working directory on startup when missing.",
65 ),
66 Flag.optional,
67);
68const logWebSocketEventsFlag = Flag.Boolean("log-websocket-events").pipe(
69 Flag.withDescription(
70 "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
71 ),
72 Flag.withAlias("log-ws-events"),
73 Flag.optional,
74);
75const tailscaleServeFlag = Flag.Boolean("tailscale-serve").pipe(
76 Flag.withDescription(
77 "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.",
78 ),
79 Flag.optional,
80);
81const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe(
82 Flag.withSchema(PortSchema),
83 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
84 Flag.optional,
85);
86
87// Trace file location, shared by the server and `t3 trace summary`.
88export const traceFileConfig = Config.String("T3CODE_TRACE_FILE").pipe(
89 Config.option,
90 Config.map(Option.getOrUndefined),
91);
92export const traceMaxFilesConfig = Config.Int("T3CODE_TRACE_MAX_FILES").pipe(
93 Config.withDefault(10),
94);
95
96const EnvServerConfig = Config.all({
97 logLevel: Config.LogLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
98 traceMinLevel: Config.LogLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")),
99 traceTimingEnabled: Config.Boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)),
100 traceFile: traceFileConfig,
101 traceMaxBytes: Config.Int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)),
102 traceMaxFiles: traceMaxFilesConfig,
103 traceBatchWindowMs: Config.Int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)),
104 otlpTracesUrl: Config.String("T3CODE_OTLP_TRACES_URL").pipe(
105 Config.option,
106 Config.map(Option.getOrUndefined),
107 ),
108 otlpMetricsUrl: Config.String("T3CODE_OTLP_METRICS_URL").pipe(
109 Config.option,
110 Config.map(Option.getOrUndefined),
111 ),
112 otlpLogsUrl: Config.String("T3CODE_OTLP_LOGS_URL").pipe(
113 Config.option,
114 Config.map(Option.getOrUndefined),
115 ),
116 otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
117 Config.withDefault(10_000),
118 ),
119 otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(
120 Config.option,
121 Config.map(Option.getOrUndefined),
122 ),
123 otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe(
124 Config.withDefault("http/json"),
125 ),
126 mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(
127 Config.option,
128 Config.map(Option.getOrUndefined),
129 ),
130 port: Config.Port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
131 host: Config.String("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
132 t3Home: Config.String("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
133 devUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
134 devAllowedOrigins: Config.String("T3CODE_DEV_ALLOWED_ORIGINS").pipe(
135 Config.withDefault(""),
136 Config.map((value) =>
137 value
138 .split(",")
139 .map((entry) => entry.trim())
140 .filter((entry) => entry.length > 0),
141 ),
142 ),
143 noBrowser: Config.Boolean("T3CODE_NO_BROWSER").pipe(
144 Config.option,
145 Config.map(Option.getOrUndefined),
146 ),
147 bootstrapFd: Config.Int("T3CODE_BOOTSTRAP_FD").pipe(
148 Config.option,
149 Config.map(Option.getOrUndefined),
150 ),
151 autoBootstrapProjectFromCwd: Config.Boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
152 Config.option,
153 Config.map(Option.getOrUndefined),
154 ),
155 logWebSocketEvents: Config.Boolean("T3CODE_LOG_WS_EVENTS").pipe(
156 Config.option,
157 Config.map(Option.getOrUndefined),
158 ),
159 tailscaleServeEnabled: Config.Boolean("T3CODE_TAILSCALE_SERVE").pipe(
160 Config.option,
161 Config.map(Option.getOrUndefined),
162 ),
163 tailscaleServePort: Config.Port("T3CODE_TAILSCALE_SERVE_PORT").pipe(
164 Config.option,
165 Config.map(Option.getOrUndefined),
166 ),
167});
168
169const DevAuthTokenConfig = Config.Redacted("T3CODE_DEV_AUTH_TOKEN").pipe(
170 Config.map((token) => Redacted.make(Redacted.value(token).trim())),
171 Config.mapEffect((token) =>
172 Redacted.value(token).length === 0 || Redacted.value(token).length >= 32
173 ? Effect.succeed(token)
174 : Effect.fail(
175 new Config.ConfigError(
176 new Schema.SchemaError(
177 new SchemaIssue.InvalidValue({
178 message: "T3CODE_DEV_AUTH_TOKEN must contain at least 32 characters.",
179 }),
180 ),
181 ),
182 ),
183 ),
184 Config.option,
185 Config.map(Option.filter((token) => Redacted.value(token).length > 0)),
186 Config.map(Option.getOrUndefined),
187);
188
189export interface CliServerFlags {
190 readonly mode: Option.Option<ServerConfig.RuntimeMode>;
191 readonly port: Option.Option<number>;
192 readonly host: Option.Option<string>;
193 readonly baseDir: Option.Option<string>;
194 readonly cwd: Option.Option<string>;
195 readonly devUrl: Option.Option<URL>;
196 readonly noBrowser: Option.Option<boolean>;
197 readonly bootstrapFd: Option.Option<number>;
198 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
199 readonly logWebSocketEvents: Option.Option<boolean>;
200 readonly tailscaleServeEnabled: Option.Option<boolean>;
201 readonly tailscaleServePort: Option.Option<number>;
202}
203
204export interface CliAuthLocationFlags {
205 readonly baseDir: Option.Option<string>;
206 readonly devUrl?: Option.Option<URL>;
207}
208
209export const authLocationFlags = {
210 baseDir: baseDirFlag,
211 devUrl: devUrlFlag,
212} as const;
213
214export const projectLocationFlags = {
215 baseDir: baseDirFlag,
216} as const;
217
218export const sharedServerCommandFlags = {
219 mode: modeFlag,
220 port: portFlag,
221 host: hostFlag,
222 baseDir: baseDirFlag,
223 cwd: Argument.String("cwd").pipe(
224 Argument.withDescription(
225 "Working directory for provider sessions (defaults to the current directory).",
226 ),
227 Argument.optional,
228 ),
229 devUrl: devUrlFlag,
230 noBrowser: noBrowserFlag,
231 bootstrapFd: bootstrapFdFlag,
232 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
233 logWebSocketEvents: logWebSocketEventsFlag,
234 tailscaleServeEnabled: tailscaleServeFlag,
235 tailscaleServePort: tailscaleServePortFlag,
236} as const;
237
238const resolveOptionPrecedence = <Value>(
239 ...values: ReadonlyArray<Option.Option<Value>>
240): Option.Option<Value> => Option.firstSomeOf(values);
241
242const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) {
243 const fs = yield* FileSystem.FileSystem;
244 const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
245 if (!exists) {
246 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined };
247 }
248
249 const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));
250 return parsePersistedServerObservabilitySettings(raw);
251});
252
253export const resolveServerConfig = (
254 flags: CliServerFlags,
255 cliLogLevel: Option.Option<LogLevel.LogLevel>,
256 options?: {
257 readonly startupPresentation?: ServerConfig.StartupPresentation;
258 readonly forceAutoBootstrapProjectFromCwd?: boolean;
259 readonly rejectRunningServer?: boolean;
260 },
261) =>
262 Effect.gen(function* () {
263 const { findAvailablePort } = yield* NetService.NetService;
264 const path = yield* Path.Path;
265 const fs = yield* FileSystem.FileSystem;
266 const env = yield* EnvServerConfig;
267 const normalizedFlags = {
268 mode: flags.mode ?? Option.none(),
269 port: flags.port ?? Option.none(),
270 host: flags.host ?? Option.none(),
271 baseDir: flags.baseDir ?? Option.none(),
272 cwd: flags.cwd ?? Option.none(),
273 devUrl: flags.devUrl ?? Option.none(),
274 noBrowser: flags.noBrowser ?? Option.none(),
275 bootstrapFd: flags.bootstrapFd ?? Option.none(),
276 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
277 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
278 tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(),
279 tailscaleServePort: flags.tailscaleServePort ?? Option.none(),
280 } satisfies CliServerFlags;
281 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
282 const bootstrapEnvelope =
283 bootstrapFd !== undefined
284 ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd)
285 : Option.none();
286 const bootstrap = Option.getOrUndefined(bootstrapEnvelope);
287
288 const mode: ServerConfig.RuntimeMode = Option.getOrElse(
289 resolveOptionPrecedence(
290 normalizedFlags.mode,
291 Option.fromUndefinedOr(env.mode),
292 Option.fromUndefinedOr(bootstrap?.mode),
293 ),
294 () => "web",
295 );
296
297 const port = yield* Option.match(
298 resolveOptionPrecedence(
299 normalizedFlags.port,
300 Option.fromUndefinedOr(env.port),
301 Option.fromUndefinedOr(bootstrap?.port),
302 ),
303 {
304 onSome: (value) => Effect.succeed(value),
305 onNone: () => {
306 if (mode === "desktop") {
307 return Effect.succeed(ServerConfig.DEFAULT_PORT);
308 }
309 return findAvailablePort(ServerConfig.DEFAULT_PORT);
310 },
311 },
312 );
313 const devUrl = Option.getOrElse(
314 resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),
315 () => undefined,
316 );
317 const devAuthToken =
318 mode === "web" && devUrl !== undefined ? yield* DevAuthTokenConfig : undefined;
319 const explicitBaseDir = resolveOptionPrecedence(
320 normalizedFlags.baseDir,
321 Option.fromUndefinedOr(env.t3Home),
322 ).pipe(Option.filter((value) => value.trim().length > 0));
323 const baseDir = yield* resolveBaseDir(
324 Option.getOrUndefined(
325 resolveOptionPrecedence(explicitBaseDir, Option.fromUndefinedOr(bootstrap?.t3Home)),
326 ),
327 );
328 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
329 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
330 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, {
331 baseDirIsExplicit: Option.isSome(explicitBaseDir),
332 });
333 // An interactive CLI must not start over a discovered server. Lifetime locking
334 // and supervisor handoff are separate; this preflight cannot arbitrate two starts.
335 if (options?.rejectRunningServer && mode === "web") {
336 const runtime = yield* readPersistedServerRuntimeState(derivedPaths.serverRuntimeStatePath);
337 if (Option.isSome(runtime) && runtime.value.pid > 0 && isProcessAlive(runtime.value.pid)) {
338 return yield* new CliError.UserError({
339 cause: `A T3 Code server is already running for ${baseDir} (pid ${runtime.value.pid}, ${runtime.value.origin}). Connect to that server, stop it before starting another, or use a different --base-dir.`,
340 });
341 }
342 }
343 yield* fs.makeDirectory(cwd, { recursive: true });
344 yield* ServerConfig.ensureServerDirectories(derivedPaths);
345 const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
346 derivedPaths.settingsPath,
347 );
348 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
349 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
350 const startupPresentation = options?.startupPresentation ?? "browser";
351 const isHeadlessStartup = startupPresentation === "headless";
352 const noBrowser = Option.getOrElse(
353 resolveOptionPrecedence(
354 isHeadlessStartup ? Option.some(true) : Option.none(),
355 normalizedFlags.noBrowser,
356 Option.fromUndefinedOr(env.noBrowser),
357 Option.fromUndefinedOr(bootstrap?.noBrowser),
358 ),
359 () => mode === "desktop",
360 );
361 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
362 const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret;
363 const shellEnvironmentPrepared = bootstrap?.shellEnvironmentPrepared;
364 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
365 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;
366 const desktopBrowserFd = bootstrap?.desktopBrowserFd;
367 const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd;
368 const resourceMonitorPath = bootstrap?.resourceMonitorPath;
369 const autoBootstrapProjectFromCwd = Option.getOrElse(
370 resolveOptionPrecedence(
371 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
372 isHeadlessStartup ? Option.some(false) : Option.none(),
373 normalizedFlags.autoBootstrapProjectFromCwd,
374 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
375 ),
376 () => mode === "web",
377 );
378 const logWebSocketEvents = Option.getOrElse(
379 resolveOptionPrecedence(
380 normalizedFlags.logWebSocketEvents,
381 Option.fromUndefinedOr(env.logWebSocketEvents),
382 ),
383 () => Boolean(devUrl),
384 );
385 const tailscaleServeEnabled = Option.getOrElse(
386 resolveOptionPrecedence(
387 normalizedFlags.tailscaleServeEnabled,
388 Option.fromUndefinedOr(env.tailscaleServeEnabled),
389 Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled),
390 ),
391 () => false,
392 );
393 const tailscaleServePort = Option.getOrElse(
394 resolveOptionPrecedence(
395 normalizedFlags.tailscaleServePort,
396 Option.fromUndefinedOr(env.tailscaleServePort),
397 Option.fromUndefinedOr(bootstrap?.tailscaleServePort),
398 ),
399 () => 443,
400 );
401 const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir();
402 const host = Option.getOrElse(
403 resolveOptionPrecedence(
404 normalizedFlags.host,
405 Option.fromUndefinedOr(env.host),
406 Option.fromUndefinedOr(bootstrap?.host),
407 ),
408 () => (mode === "desktop" ? "127.0.0.1" : undefined),
409 );
410 const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
411
412 const otel = yield* OtelEnvironment.load;
413
414 // T3 Code's own OTLP variables name no signal, so the one answer they give
415 // is the answer for all three.
416 const signalExport: SignalExport = {
417 protocol: env.otlpProtocol,
418 headers: env.otlpHeaders,
419 exportIntervalMs: env.otlpExportIntervalMs,
420 };
421 const traces = OtelEnvironment.resolveSignalEndpoint(
422 otel,
423 "traces",
424 { url: env.otlpTracesUrl, export: signalExport },
425 bootstrap?.otlpTracesUrl,
426 persistedObservabilitySettings.otlpTracesUrl,
427 );
428 const metrics = OtelEnvironment.resolveSignalEndpoint(
429 otel,
430 "metrics",
431 { url: env.otlpMetricsUrl, export: signalExport },
432 bootstrap?.otlpMetricsUrl,
433 persistedObservabilitySettings.otlpMetricsUrl,
434 );
435 const logs = OtelEnvironment.resolveSignalEndpoint(
436 otel,
437 "logs",
438 { url: env.otlpLogsUrl, export: signalExport },
439 bootstrap?.otlpLogsUrl,
440 persistedObservabilitySettings.otlpLogsUrl,
441 );
442
443 const config: ServerConfig.ServerConfig["Service"] = {
444 logLevel,
445 traceMinLevel: env.traceMinLevel,
446 traceTimingEnabled: env.traceTimingEnabled,
447 traceBatchWindowMs: env.traceBatchWindowMs,
448 traceMaxBytes: env.traceMaxBytes,
449 traceMaxFiles: env.traceMaxFiles,
450 otlpTracesUrl: traces?.url,
451 otlpMetricsUrl: metrics?.url,
452 otlpLogsUrl: logs?.url,
453 otlpTracesExport: traces?.export ?? signalExport,
454 otlpMetricsExport: metrics?.export ?? signalExport,
455 otlpLogsExport: logs?.export ?? signalExport,
456 otelEnvironment: otel,
457 mode,
458 port,
459 cwd,
460 baseDir,
461 ...derivedPaths,
462 serverTracePath,
463 host,
464 staticDir,
465 devUrl,
466 ...(devAuthToken === undefined ? {} : { devAuthToken }),
467 devAllowedOrigins: env.devAllowedOrigins,
468 noBrowser,
469 startupPresentation,
470 desktopBootstrapToken,
471 ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }),
472 ...(shellEnvironmentPrepared === undefined ? {} : { shellEnvironmentPrepared }),
473 desktopTelemetryFd,
474 desktopTelemetryControlFd,
475 desktopTerminalFd: bootstrap?.desktopTerminalFd,
476 desktopTerminalControlFd: bootstrap?.desktopTerminalControlFd,
477 desktopBrowserFd,
478 desktopBrowserControlFd,
479 resourceMonitorPath,
480 autoBootstrapProjectFromCwd,
481 logWebSocketEvents,
482 tailscaleServeEnabled,
483 tailscaleServePort,
484 };
485
486 return config;
487 });
488
489export const resolveCliAuthConfig = (
490 flags: CliAuthLocationFlags,
491 cliLogLevel: Option.Option<LogLevel.LogLevel>,
492) =>
493 resolveServerConfig(
494 {
495 mode: Option.none(),
496 port: Option.none(),
497 host: Option.none(),
498 baseDir: flags.baseDir,
499 cwd: Option.none(),
500 devUrl: flags.devUrl ?? Option.none(),
501 noBrowser: Option.none(),
502 bootstrapFd: Option.none(),
503 autoBootstrapProjectFromCwd: Option.none(),
504 logWebSocketEvents: Option.none(),
505 tailscaleServeEnabled: Option.none(),
506 tailscaleServePort: Option.none(),
507 },
508 cliLogLevel,
509 );
510
511const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i;
512
513const parseDurationInput = (value: string): Duration.Duration | null => {
514 const trimmed = value.trim();
515 if (trimmed.length === 0) return null;
516
517 const shorthand = DurationShorthandPattern.exec(trimmed);
518 const normalizedInput = shorthand?.groups
519 ? (() => {
520 const amountText = shorthand.groups.value;
521 const unitText = shorthand.groups.unit;
522 if (typeof amountText !== "string" || typeof unitText !== "string") {
523 return null;
524 }
525
526 const amount = Number.parseInt(amountText, 10);
527 if (!Number.isFinite(amount)) return null;
528
529 switch (unitText.toLowerCase()) {
530 case "ms":
531 return `${amount} millis`;
532 case "s":
533 return `${amount} seconds`;
534 case "m":
535 return `${amount} minutes`;
536 case "h":
537 return `${amount} hours`;
538 case "d":
539 return `${amount} days`;
540 case "w":
541 return `${amount} weeks`;
542 default:
543 return null;
544 }
545 })()
546 : (trimmed as Duration.Input);
547
548 if (normalizedInput === null) return null;
549
550 const decoded = Duration.fromInput(normalizedInput as Duration.Input);
551 return Option.isSome(decoded) ? decoded.value : null;
552};
553
554export const DurationFromString = Schema.String.pipe(
555 Schema.decodeTo(
556 Schema.Duration,
557 SchemaTransformation.transformEffect({
558 decode: (value) => {
559 const duration = parseDurationInput(value);
560 if (duration !== null) {
561 return Effect.succeed(duration);
562 }
563 return Effect.fail(
564 new SchemaIssue.InvalidValue({
565 message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.",
566 }),
567 );
568 },
569 encode: (duration) => Effect.succeed(Duration.format(duration)),
570 }),
571 ),
572);