@andreas-timm/config 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,11 +9,12 @@ Bun-first utilities for loading layered TOML config files and validating the mer
9
9
  - Deep-merge files with later files overriding earlier values.
10
10
  - Inject `root_dir` before schema validation.
11
11
  - Report Zod validation issues with the resolved source file paths.
12
+ - Post-process known string values such as `secret = "!pass show secret"` as command-backed secrets.
12
13
 
13
14
  ## Install
14
15
 
15
16
  ```sh
16
- bun add @andreas-timm/config zod
17
+ pnpm add @andreas-timm/config zod
17
18
  ```
18
19
 
19
20
  ## Usage
@@ -49,5 +50,34 @@ export function resetConfig(): void {
49
50
 
50
51
  `loadConfig()` caches the promise in module scope, so files are read and parsed only on the first call and concurrent callers share the in-flight load. Call `resetConfig()` to force a reload in tests or after editing a TOML file.
51
52
 
53
+ ## Command-backed secrets
54
+
55
+ Use command-backed values when an application should keep the secret in a password manager or external secret store, but still describe how to fetch it in TOML:
56
+
57
+ ```toml
58
+ secret = "!pass show secret"
59
+ literal = "!!starts-with-bang"
60
+ ```
61
+
62
+ Post-process only the specific field that may contain a command:
63
+
64
+ ```ts
65
+ import { load, resolveShellCommand } from "@andreas-timm/config";
66
+
67
+ const config = await load(ConfigSchema, rootDir, configFiles);
68
+ const secret = await resolveShellCommand(config.secret, { cwd: rootDir });
69
+ ```
70
+
71
+ This keeps loading as plain TOML/Zod validation and makes command execution an application-level decision. The config schema should validate the placeholder as a string, and the application should validate or use the resolved secret at the point where it is needed.
72
+
73
+ You can also write the resolved value back into your application config object:
74
+
75
+ ```ts
76
+ const config = await load(ConfigSchema, rootDir, configFiles);
77
+ config.secret = await resolveShellCommand(config.secret, { cwd: rootDir });
78
+ ```
79
+
80
+ By default, command resolution uses `!` as the prefix, `!!` as a literal escape, strips one final newline from command output, applies a 10 second timeout, and rejects stdout or stderr above 16 KiB. Treat any value passed to `resolveShellCommand()` as trusted executable input.
81
+
52
82
  For the full setup pattern and usage guidance, see [`skills/bun-config/SKILL.md`](https://github.com/andreas-timm/config-ts/blob/main/skills/bun-config/SKILL.md).
53
83
 
@@ -0,0 +1 @@
1
+ export declare function resolveConfigFilePath(rootDir: string, fileName: string): string;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- import { z } from "zod";
2
- export declare function load<Config>(configSchema: z.ZodType<Config>, rootDir: string, configFiles?: string[]): Promise<Config>;
1
+ export { load } from "./load";
2
+ export type { ResolveShellCommandOptions } from "./shell";
3
+ export { resolveShellCommand } from "./shell";
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  // @bun
2
- // src/index.ts
3
- import { homedir } from "os";
4
- import { join } from "path";
2
+ // src/load.ts
5
3
  import merge from "lodash.merge";
6
4
  import { concatMap, from, lastValueFrom } from "rxjs";
7
5
  import { reduce } from "rxjs/operators";
8
6
  import { z } from "zod";
7
+
8
+ // src/config.ts
9
+ import { homedir } from "os";
10
+ import { join } from "path";
9
11
  function resolveConfigFilePath(rootDir, fileName) {
10
12
  if (fileName.startsWith("/")) {
11
13
  return fileName;
@@ -22,6 +24,8 @@ function resolveConfigFilePath(rootDir, fileName) {
22
24
  }
23
25
  return join(rootDir, fileName);
24
26
  }
27
+
28
+ // src/load.ts
25
29
  async function load(configSchema, rootDir, configFiles = ["global.toml", "local.toml"]) {
26
30
  const config$ = from(configFiles).pipe(concatMap(async (fileName) => {
27
31
  const file = Bun.file(resolveConfigFilePath(rootDir, fileName));
@@ -57,6 +61,113 @@ ${details}`;
57
61
  throw err;
58
62
  }
59
63
  }
64
+ // src/shell.ts
65
+ var DEFAULT_SHELL_COMMAND_PREFIX = "!";
66
+ var DEFAULT_SHELL_COMMAND_TIMEOUT_MS = 1e4;
67
+ var DEFAULT_SHELL_COMMAND_MAX_OUTPUT_BYTES = 16 * 1024;
68
+ function defaultShell() {
69
+ if (process.platform === "win32") {
70
+ return ["cmd.exe", "/d", "/s", "/c"];
71
+ }
72
+ return ["/bin/sh", "-c"];
73
+ }
74
+ function stripFinalNewline(value) {
75
+ return value.replace(/\r?\n$/u, "");
76
+ }
77
+ function shellCommandValueError(message) {
78
+ return new Error(`Shell command config value ${message}`);
79
+ }
80
+ function getCommand(value, prefix) {
81
+ if (!value.startsWith(prefix) || value.startsWith(prefix + prefix)) {
82
+ return;
83
+ }
84
+ const command = value.slice(prefix.length).trimStart();
85
+ if (command.length === 0) {
86
+ throw shellCommandValueError("is empty");
87
+ }
88
+ return command;
89
+ }
90
+ function getLiteralValue(value, prefix) {
91
+ if (value.startsWith(prefix + prefix)) {
92
+ return value.slice(prefix.length);
93
+ }
94
+ return value;
95
+ }
96
+ async function readLimitedText(stream, maxBytes) {
97
+ const reader = stream.getReader();
98
+ const chunks = [];
99
+ let size = 0;
100
+ while (true) {
101
+ const { done, value } = await reader.read();
102
+ if (done) {
103
+ break;
104
+ }
105
+ size += value.byteLength;
106
+ if (size > maxBytes) {
107
+ throw shellCommandValueError(`produced more than ${maxBytes} bytes`);
108
+ }
109
+ chunks.push(value);
110
+ }
111
+ const output = new Uint8Array(size);
112
+ let offset = 0;
113
+ for (const chunk of chunks) {
114
+ output.set(chunk, offset);
115
+ offset += chunk.byteLength;
116
+ }
117
+ return new TextDecoder().decode(output);
118
+ }
119
+ async function runShellCommand(command, options = {}) {
120
+ const proc = Bun.spawn({
121
+ cmd: [...options.shell ?? defaultShell(), command],
122
+ cwd: options.cwd,
123
+ env: options.env,
124
+ stderr: "pipe",
125
+ stdin: null,
126
+ stdout: "pipe",
127
+ timeout: options.timeoutMs ?? DEFAULT_SHELL_COMMAND_TIMEOUT_MS
128
+ });
129
+ const stdout = readLimitedText(proc.stdout, options.maxOutputBytes ?? DEFAULT_SHELL_COMMAND_MAX_OUTPUT_BYTES);
130
+ const stderr = readLimitedText(proc.stderr, options.maxOutputBytes ?? DEFAULT_SHELL_COMMAND_MAX_OUTPUT_BYTES);
131
+ try {
132
+ const [output, errorOutput, exitCode] = await Promise.all([
133
+ stdout,
134
+ stderr,
135
+ proc.exited
136
+ ]);
137
+ return {
138
+ errorOutput,
139
+ exitCode,
140
+ output,
141
+ signalCode: proc.signalCode ?? undefined
142
+ };
143
+ } catch (err) {
144
+ proc.kill();
145
+ await Promise.allSettled([stdout, stderr, proc.exited]);
146
+ throw err;
147
+ }
148
+ }
149
+ function assertSuccessfulCommand(result, options) {
150
+ if (result.exitCode === 0) {
151
+ return;
152
+ }
153
+ const status = result.exitCode === null && result.signalCode ? `terminated by signal ${result.signalCode}` : `failed with exit code ${result.exitCode}`;
154
+ const stderrDetails = options.includeStderrInErrors && result.errorOutput.trim().length > 0 ? `: ${result.errorOutput.trim()}` : "";
155
+ throw shellCommandValueError(`${status}${stderrDetails}`);
156
+ }
157
+ async function resolveShellCommand(value, options = {}) {
158
+ const prefix = options.prefix ?? DEFAULT_SHELL_COMMAND_PREFIX;
159
+ if (prefix.length === 0) {
160
+ throw new Error("Shell command prefix must not be empty");
161
+ }
162
+ const command = getCommand(value, prefix);
163
+ if (!command) {
164
+ return getLiteralValue(value, prefix);
165
+ }
166
+ const result = await runShellCommand(command, options);
167
+ assertSuccessfulCommand(result, options);
168
+ return options.stripFinalNewline ?? true ? stripFinalNewline(result.output) : result.output;
169
+ }
60
170
  export {
171
+ resolveShellCommand,
61
172
  load
62
173
  };
package/dist/load.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { z } from "zod";
2
+ export declare function load<Config>(configSchema: z.ZodType<Config>, rootDir: string, configFiles?: string[]): Promise<Config>;
@@ -0,0 +1,13 @@
1
+ type ShellCommandEnv = Record<string, string | undefined>;
2
+ export type ResolveShellCommandOptions = {
3
+ cwd?: string;
4
+ env?: ShellCommandEnv;
5
+ includeStderrInErrors?: boolean;
6
+ maxOutputBytes?: number;
7
+ prefix?: string;
8
+ shell?: readonly [string, ...string[]];
9
+ stripFinalNewline?: boolean;
10
+ timeoutMs?: number;
11
+ };
12
+ export declare function resolveShellCommand(value: string, options?: ResolveShellCommandOptions): Promise<string>;
13
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andreas-timm/config",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bun-first TOML config loading with Zod validation",
5
5
  "license": "BSD-2-Clause",
6
6
  "author": {
@@ -19,6 +19,7 @@
19
19
  "zod"
20
20
  ],
21
21
  "type": "module",
22
+ "packageManager": "pnpm@11.9.0",
22
23
  "files": [
23
24
  "dist",
24
25
  "src",
@@ -48,6 +49,8 @@
48
49
  "prepack": "bun run build",
49
50
  "prepublishOnly": "bun lint && bun test && bun audit --audit-level=moderate",
50
51
  "postpack": "bun scripts/postpack.ts",
52
+ "release": "semantic-release",
53
+ "release:preview": "node scripts/release-preview.cjs",
51
54
  "release:publish": "bun scripts/publish.ts"
52
55
  },
53
56
  "dependencies": {
@@ -60,14 +63,23 @@
60
63
  "devDependencies": {
61
64
  "@biomejs/biome": "^2.4.13",
62
65
  "@eslint/js": "^10.0.1",
66
+ "@semantic-release/changelog": "^6.0.3",
67
+ "@semantic-release/git": "^10.0.1",
68
+ "@semantic-release/release-notes-generator": "^14.1.0",
63
69
  "@types/bun": "^1.3.13",
64
70
  "@types/lodash.merge": "^4.6.9",
65
71
  "@types/node": "^25.6.0",
66
72
  "@typescript-eslint/eslint-plugin": "^8.59.0",
67
73
  "@typescript-eslint/parser": "^8.59.0",
74
+ "eslint": "^10.4.1",
68
75
  "globals": "^17.5.0",
69
76
  "prettier-plugin-organize-imports": "^4.3.0",
77
+ "semantic-release": "^25.0.3",
70
78
  "typescript": "^5.9.3",
71
79
  "zod": "^4.3.6"
80
+ },
81
+ "overrides": {
82
+ "undici@6": "^6.27.0",
83
+ "undici@7": "^7.28.0"
72
84
  }
73
85
  }
@@ -69,10 +69,30 @@ import { loadConfig } from "@config";
69
69
  const config = await loadConfig();
70
70
  ```
71
71
 
72
+ ## Optional Command-Backed Secrets
73
+
74
+ For TOML values such as:
75
+
76
+ ```toml
77
+ secret = "!pass show secret"
78
+ ```
79
+
80
+ keep `loadConfig()` unchanged and post-process only the known secret field in application code:
81
+
82
+ ```ts
83
+ import { resolveShellCommand } from "@andreas-timm/config";
84
+
85
+ const config = await loadConfig();
86
+ const secret = await resolveShellCommand(config.secret);
87
+ ```
88
+
89
+ This keeps config loading as plain TOML/Zod validation. Treat values passed to `resolveShellCommand()` as trusted executable input.
90
+
72
91
  ## Behavior
73
92
 
74
93
  - Files are read in order and deep-merged with `lodash.merge`; later files override earlier ones.
75
94
  - Missing files are skipped silently.
76
95
  - `root_dir` is injected automatically before schema parsing.
96
+ - Command-backed values such as `secret = "!pass show secret"` stay literal during config loading. Applications can post-process known fields with `resolveShellCommand()`.
77
97
  - Validation errors include the resolved file paths and per-issue details.
78
98
  - `loadConfig()` caches the promise in module scope, so files are read and parsed only on the first call and concurrent callers share the in-flight load. Call `resetConfig()` to force a reload in tests or after editing a TOML file.
package/src/config.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export function resolveConfigFilePath(
5
+ rootDir: string,
6
+ fileName: string,
7
+ ): string {
8
+ if (fileName.startsWith("/")) {
9
+ return fileName;
10
+ }
11
+ if (fileName.startsWith("~")) {
12
+ const home = homedir();
13
+ if (fileName === "~") {
14
+ return home;
15
+ }
16
+ if (fileName.startsWith("~/") || fileName.startsWith("~\\")) {
17
+ return join(home, fileName.slice(2));
18
+ }
19
+ return join(home, fileName.slice(1));
20
+ }
21
+ return join(rootDir, fileName);
22
+ }
package/src/index.ts CHANGED
@@ -1,76 +1,3 @@
1
- import { homedir } from "node:os";
2
- import { join } from "node:path";
3
- import merge from "lodash.merge";
4
- import { concatMap, from, lastValueFrom } from "rxjs";
5
- import { reduce } from "rxjs/operators";
6
- import { z } from "zod";
7
-
8
- type ConfigData = Record<string, unknown>;
9
-
10
- function resolveConfigFilePath(rootDir: string, fileName: string): string {
11
- if (fileName.startsWith("/")) {
12
- return fileName;
13
- }
14
- if (fileName.startsWith("~")) {
15
- const home = homedir();
16
- if (fileName === "~") {
17
- return home;
18
- }
19
- if (fileName.startsWith("~/") || fileName.startsWith("~\\")) {
20
- return join(home, fileName.slice(2));
21
- }
22
- return join(home, fileName.slice(1));
23
- }
24
- return join(rootDir, fileName);
25
- }
26
-
27
- export async function load<Config>(
28
- configSchema: z.ZodType<Config>,
29
- rootDir: string,
30
- configFiles: string[] = ["global.toml", "local.toml"],
31
- ): Promise<Config> {
32
- const config$ = from(configFiles).pipe(
33
- concatMap(async (fileName) => {
34
- const file = Bun.file(resolveConfigFilePath(rootDir, fileName));
35
- if (await file.exists()) {
36
- const text = await file.text();
37
- return Bun.TOML.parse(text) as ConfigData;
38
- }
39
- return {};
40
- }),
41
- reduce((acc, cur) => merge(acc, cur), {} as ConfigData),
42
- );
43
-
44
- const merged = await lastValueFrom(config$);
45
-
46
- try {
47
- return configSchema.parse({ root_dir: rootDir, ...merged });
48
- } catch (err) {
49
- if (err instanceof z.ZodError) {
50
- const files = configFiles
51
- .map((f) => resolveConfigFilePath(rootDir, f))
52
- .join(", ");
53
- const details = err.issues
54
- .map((i) => {
55
- const path = i.path?.length ? i.path.join(".") : "<root>";
56
- const expected =
57
- "expected" in i && i.expected !== undefined
58
- ? `\n expected: ${i.expected}`
59
- : "";
60
- const received =
61
- "received" in i && i.received !== undefined
62
- ? `\n received: ${i.received}`
63
- : "";
64
- return `- path: ${path}\n code: ${i.code}\n message: ${i.message}${expected}${received}`;
65
- })
66
- .join("\n");
67
- // prettier-ignore
68
- const helpful =
69
- `From: ${files}\n` +
70
- `Computed root_dir: ${rootDir}\n` +
71
- `Schema validation issues:\n${details}`;
72
- throw new Error(helpful, { cause: err });
73
- }
74
- throw err;
75
- }
76
- }
1
+ export { load } from "./load";
2
+ export type { ResolveShellCommandOptions } from "./shell";
3
+ export { resolveShellCommand } from "./shell";
package/src/load.ts ADDED
@@ -0,0 +1,59 @@
1
+ import merge from "lodash.merge";
2
+ import { concatMap, from, lastValueFrom } from "rxjs";
3
+ import { reduce } from "rxjs/operators";
4
+ import { z } from "zod";
5
+
6
+ import { resolveConfigFilePath } from "./config";
7
+
8
+ type ConfigData = Record<string, unknown>;
9
+
10
+ export async function load<Config>(
11
+ configSchema: z.ZodType<Config>,
12
+ rootDir: string,
13
+ configFiles: string[] = ["global.toml", "local.toml"],
14
+ ): Promise<Config> {
15
+ const config$ = from(configFiles).pipe(
16
+ concatMap(async (fileName) => {
17
+ const file = Bun.file(resolveConfigFilePath(rootDir, fileName));
18
+ if (await file.exists()) {
19
+ const text = await file.text();
20
+ return Bun.TOML.parse(text) as ConfigData;
21
+ }
22
+ return {};
23
+ }),
24
+ reduce((acc, cur) => merge(acc, cur), {} as ConfigData),
25
+ );
26
+
27
+ const merged = await lastValueFrom(config$);
28
+
29
+ try {
30
+ return configSchema.parse({ root_dir: rootDir, ...merged });
31
+ } catch (err) {
32
+ if (err instanceof z.ZodError) {
33
+ const files = configFiles
34
+ .map((f) => resolveConfigFilePath(rootDir, f))
35
+ .join(", ");
36
+ const details = err.issues
37
+ .map((i) => {
38
+ const path = i.path?.length ? i.path.join(".") : "<root>";
39
+ const expected =
40
+ "expected" in i && i.expected !== undefined
41
+ ? `\n expected: ${i.expected}`
42
+ : "";
43
+ const received =
44
+ "received" in i && i.received !== undefined
45
+ ? `\n received: ${i.received}`
46
+ : "";
47
+ return `- path: ${path}\n code: ${i.code}\n message: ${i.message}${expected}${received}`;
48
+ })
49
+ .join("\n");
50
+ // prettier-ignore
51
+ const helpful =
52
+ `From: ${files}\n` +
53
+ `Computed root_dir: ${rootDir}\n` +
54
+ `Schema validation issues:\n${details}`;
55
+ throw new Error(helpful, { cause: err });
56
+ }
57
+ throw err;
58
+ }
59
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,168 @@
1
+ type ShellCommandEnv = Record<string, string | undefined>;
2
+ type ShellCommandOutput = {
3
+ errorOutput: string;
4
+ exitCode: number | null;
5
+ output: string;
6
+ signalCode?: string;
7
+ };
8
+
9
+ export type ResolveShellCommandOptions = {
10
+ cwd?: string;
11
+ env?: ShellCommandEnv;
12
+ includeStderrInErrors?: boolean;
13
+ maxOutputBytes?: number;
14
+ prefix?: string;
15
+ shell?: readonly [string, ...string[]];
16
+ stripFinalNewline?: boolean;
17
+ timeoutMs?: number;
18
+ };
19
+
20
+ const DEFAULT_SHELL_COMMAND_PREFIX = "!";
21
+ const DEFAULT_SHELL_COMMAND_TIMEOUT_MS = 10_000;
22
+ const DEFAULT_SHELL_COMMAND_MAX_OUTPUT_BYTES = 16 * 1024;
23
+
24
+ function defaultShell(): readonly [string, ...string[]] {
25
+ if (process.platform === "win32") {
26
+ return ["cmd.exe", "/d", "/s", "/c"];
27
+ }
28
+ return ["/bin/sh", "-c"];
29
+ }
30
+
31
+ function stripFinalNewline(value: string): string {
32
+ return value.replace(/\r?\n$/u, "");
33
+ }
34
+
35
+ function shellCommandValueError(message: string): Error {
36
+ return new Error(`Shell command config value ${message}`);
37
+ }
38
+
39
+ function getCommand(value: string, prefix: string): string | undefined {
40
+ if (!value.startsWith(prefix) || value.startsWith(prefix + prefix)) {
41
+ return undefined;
42
+ }
43
+
44
+ const command = value.slice(prefix.length).trimStart();
45
+ if (command.length === 0) {
46
+ throw shellCommandValueError("is empty");
47
+ }
48
+ return command;
49
+ }
50
+
51
+ function getLiteralValue(value: string, prefix: string): string {
52
+ if (value.startsWith(prefix + prefix)) {
53
+ return value.slice(prefix.length);
54
+ }
55
+ return value;
56
+ }
57
+
58
+ async function readLimitedText(
59
+ stream: ReadableStream<Uint8Array>,
60
+ maxBytes: number,
61
+ ): Promise<string> {
62
+ const reader = stream.getReader();
63
+ const chunks: Uint8Array[] = [];
64
+ let size = 0;
65
+
66
+ while (true) {
67
+ const { done, value } = await reader.read();
68
+ if (done) {
69
+ break;
70
+ }
71
+ size += value.byteLength;
72
+ if (size > maxBytes) {
73
+ throw shellCommandValueError(
74
+ `produced more than ${maxBytes} bytes`,
75
+ );
76
+ }
77
+ chunks.push(value);
78
+ }
79
+
80
+ const output = new Uint8Array(size);
81
+ let offset = 0;
82
+ for (const chunk of chunks) {
83
+ output.set(chunk, offset);
84
+ offset += chunk.byteLength;
85
+ }
86
+ return new TextDecoder().decode(output);
87
+ }
88
+
89
+ async function runShellCommand(
90
+ command: string,
91
+ options: ResolveShellCommandOptions = {},
92
+ ): Promise<ShellCommandOutput> {
93
+ const proc = Bun.spawn({
94
+ cmd: [...(options.shell ?? defaultShell()), command],
95
+ cwd: options.cwd,
96
+ env: options.env,
97
+ stderr: "pipe",
98
+ stdin: null,
99
+ stdout: "pipe",
100
+ timeout: options.timeoutMs ?? DEFAULT_SHELL_COMMAND_TIMEOUT_MS,
101
+ });
102
+ const stdout = readLimitedText(
103
+ proc.stdout,
104
+ options.maxOutputBytes ?? DEFAULT_SHELL_COMMAND_MAX_OUTPUT_BYTES,
105
+ );
106
+ const stderr = readLimitedText(
107
+ proc.stderr,
108
+ options.maxOutputBytes ?? DEFAULT_SHELL_COMMAND_MAX_OUTPUT_BYTES,
109
+ );
110
+
111
+ try {
112
+ const [output, errorOutput, exitCode] = await Promise.all([
113
+ stdout,
114
+ stderr,
115
+ proc.exited,
116
+ ]);
117
+ return {
118
+ errorOutput,
119
+ exitCode,
120
+ output,
121
+ signalCode: proc.signalCode ?? undefined,
122
+ };
123
+ } catch (err) {
124
+ proc.kill();
125
+ await Promise.allSettled([stdout, stderr, proc.exited]);
126
+ throw err;
127
+ }
128
+ }
129
+
130
+ function assertSuccessfulCommand(
131
+ result: ShellCommandOutput,
132
+ options: ResolveShellCommandOptions,
133
+ ): void {
134
+ if (result.exitCode === 0) {
135
+ return;
136
+ }
137
+
138
+ const status =
139
+ result.exitCode === null && result.signalCode
140
+ ? `terminated by signal ${result.signalCode}`
141
+ : `failed with exit code ${result.exitCode}`;
142
+ const stderrDetails =
143
+ options.includeStderrInErrors && result.errorOutput.trim().length > 0
144
+ ? `: ${result.errorOutput.trim()}`
145
+ : "";
146
+ throw shellCommandValueError(`${status}${stderrDetails}`);
147
+ }
148
+
149
+ export async function resolveShellCommand(
150
+ value: string,
151
+ options: ResolveShellCommandOptions = {},
152
+ ): Promise<string> {
153
+ const prefix = options.prefix ?? DEFAULT_SHELL_COMMAND_PREFIX;
154
+ if (prefix.length === 0) {
155
+ throw new Error("Shell command prefix must not be empty");
156
+ }
157
+
158
+ const command = getCommand(value, prefix);
159
+ if (!command) {
160
+ return getLiteralValue(value, prefix);
161
+ }
162
+
163
+ const result = await runShellCommand(command, options);
164
+ assertSuccessfulCommand(result, options);
165
+ return (options.stripFinalNewline ?? true)
166
+ ? stripFinalNewline(result.output)
167
+ : result.output;
168
+ }