env.ts

 1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
 2//
 3// SPDX-License-Identifier: GPL-3.0-or-later
 4
 5import { execSync } from "node:child_process";
 6
 7/**
 8 * Resolve a configuration value (API key, header value, etc.) to a concrete string.
 9 *
10 * Resolution order, following pi-coding-agent's convention:
11 * - `"!command"` — executes the rest as a shell command, uses trimmed stdout
12 * - `"$VAR"` or `"${VAR}"` — treats as an env var reference (with sigil)
13 * - Otherwise checks `process.env[value]` — bare name is tried as env var
14 * - If no env var matches, the string is used as a literal value
15 *
16 * Returns `undefined` only when a shell command fails or produces empty output.
17 */
18export function resolveConfigValue(value: string): string | undefined {
19  if (value.startsWith("!")) {
20    return executeShellCommand(value.slice(1));
21  }
22
23  // Explicit $VAR or ${VAR} reference
24  const envRef = value.match(/^\$\{(.+)\}$|^\$([A-Za-z_][A-Za-z0-9_]*)$/);
25  if (envRef) {
26    const name = envRef[1] ?? envRef[2]!;
27    return process.env[name] ?? undefined;
28  }
29
30  // Bare name — check as env var first, then use as literal
31  const envValue = process.env[value];
32  return envValue || value;
33}
34
35/**
36 * Expand `$VAR` and `${VAR}` references embedded within a larger string.
37 * Unlike `resolveConfigValue`, this handles mixed literal + env-var strings
38 * like `"Bearer $API_KEY"`.
39 */
40export function expandEnvVars(value: string): string {
41  return value.replace(
42    /\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
43    (_, braced, bare) => {
44      const name = braced ?? bare;
45      return process.env[name] ?? "";
46    },
47  );
48}
49
50/**
51 * Resolve all values in a headers record using `resolveConfigValue`.
52 * Drops entries whose values resolve to `undefined`.
53 */
54export function resolveHeaders(
55  headers: Record<string, string> | undefined,
56): Record<string, string> | undefined {
57  if (!headers) return undefined;
58  const resolved: Record<string, string> = {};
59  for (const [key, value] of Object.entries(headers)) {
60    const resolvedValue = resolveConfigValue(value);
61    if (resolvedValue) {
62      resolved[key] = resolvedValue;
63    }
64  }
65  return Object.keys(resolved).length > 0 ? resolved : undefined;
66}
67
68function executeShellCommand(command: string): string | undefined {
69  try {
70    const output = execSync(command, {
71      encoding: "utf-8",
72      timeout: 10_000,
73      stdio: ["ignore", "pipe", "ignore"],
74    });
75    return output.trim() || undefined;
76  } catch {
77    return undefined;
78  }
79}