@narumitw/pi-langfuse 0.14.1
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/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +49 -0
- package/src/config.ts +208 -0
- package/src/langfuse.ts +295 -0
- package/src/runtime.ts +247 -0
- package/src/tracing.ts +472 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 narumiruna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# 🪢 pi-langfuse — Langfuse Observability for Pi
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@narumitw/pi-langfuse) [](https://pi.dev) [](./LICENSE)
|
|
4
|
+
|
|
5
|
+
`@narumitw/pi-langfuse` is a [Pi coding agent](https://pi.dev) extension that sends Pi LLM generations, agent runs, and tool executions to [Langfuse](https://langfuse.com/) through OpenTelemetry.
|
|
6
|
+
|
|
7
|
+
## ✨ Features
|
|
8
|
+
|
|
9
|
+
- Names each Langfuse trace `pi.trace` and creates a native `pi.agent` observation for the Pi agent run.
|
|
10
|
+
- Adds a `pi.turn` span for every Pi turn, with generations and tools nested beneath it.
|
|
11
|
+
- Records finalized assistant outputs without retaining intermediate provider request payloads.
|
|
12
|
+
- Records provider, model, stop reason, token usage, and known non-zero reported cost.
|
|
13
|
+
- Records normalized tool inputs, finalized outputs, duration, and failures as child spans.
|
|
14
|
+
- Groups traces with Pi's session id.
|
|
15
|
+
- Records provider HTTP status codes when Pi exposes them.
|
|
16
|
+
- Reads Langfuse credentials and options only from a private `pi-langfuse.json` file.
|
|
17
|
+
- Batches routine exports without delaying normal Pi agent completion.
|
|
18
|
+
- Keeps its OpenTelemetry provider isolated so it coexists with other tracing extensions.
|
|
19
|
+
- Redacts Langfuse public and secret keys from exported trace data.
|
|
20
|
+
- Supports metadata-only tracing when content capture is disabled.
|
|
21
|
+
|
|
22
|
+
## 📦 Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install npm:@narumitw/pi-langfuse
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Try the local workspace package:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi -e ./extensions/pi-langfuse
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The Langfuse v4 SDK requires Node.js 20 or newer.
|
|
35
|
+
|
|
36
|
+
## ⚙️ Configuration
|
|
37
|
+
|
|
38
|
+
Create or update the private config interactively from Pi:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
/langfuse init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The command prompts for the secret key, public key, and base URL in the same order Langfuse presents them. Leave either key blank to preserve its existing value when updating a valid config. Leave the base URL blank to use `https://us.cloud.langfuse.com`. The file is saved atomically with mode `0600`; restart Pi after saving. In print or JSON mode, edit the file manually because interactive input is unavailable.
|
|
45
|
+
|
|
46
|
+
You can also create the file manually:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"publicKey": "pk-lf-...",
|
|
51
|
+
"secretKey": "sk-lf-...",
|
|
52
|
+
"baseUrl": "https://us.cloud.langfuse.com",
|
|
53
|
+
"environment": "development",
|
|
54
|
+
"release": "local",
|
|
55
|
+
"captureContent": true
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`publicKey` and `secretKey` are required literal strings. Environment-variable and command interpolation are intentionally unsupported. `baseUrl` defaults to `https://us.cloud.langfuse.com`; regional and self-hosted HTTP or HTTPS endpoints are supported. Prefer HTTPS because HTTP sends Langfuse credentials and trace content without transport encryption.
|
|
60
|
+
|
|
61
|
+
`environment` and `release` are optional Langfuse trace attributes. Set `captureContent` to `false` to trace timing, model, usage, cost, and status metadata without sending prompts, responses, or tool content.
|
|
62
|
+
|
|
63
|
+
The extension automatically restricts an existing config file to mode `0600` and refuses to load credentials if that protection cannot be enforced. You can also set it explicitly:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
chmod 600 ~/.pi/agent/pi-langfuse.json
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Restart Pi after changing credentials, endpoint, environment, or release. The isolated OpenTelemetry tracer provider is initialized once per Pi process and selected only for Langfuse; it does not replace Pi's process-global provider or send Langfuse observations to another extension's exporter.
|
|
70
|
+
|
|
71
|
+
## 🔭 What is traced
|
|
72
|
+
|
|
73
|
+
Each `pi.trace` contains one `pi.agent` native `agent` observation, which contains:
|
|
74
|
+
|
|
75
|
+
- a `pi.turn` native `span` for every Pi turn, including its index, stop reason, tool-result count, duration, and failure status;
|
|
76
|
+
- a `pi.llm` native `generation` under the active turn for every provider request;
|
|
77
|
+
- a `pi.tool.<tool-name>` native `tool` observation under the active turn for every tool execution;
|
|
78
|
+
- the Pi session id, working directory, mode, provider, and model;
|
|
79
|
+
- generation token usage and positive total cost when Pi reports a known price;
|
|
80
|
+
- the concrete response model when Pi reports one, with a differing requested alias retained in metadata;
|
|
81
|
+
- error levels and status messages for failed provider responses, model calls, and tools.
|
|
82
|
+
|
|
83
|
+
Pi does not expose a post-transform provider payload event, so generation request bodies are intentionally omitted rather than risking capture of a payload that a later extension rewrites or redacts. The agent observation still records the user prompt, and assistant output is reconciled after message transformers. Tool input is captured after argument preparation and `tool_call` mutations, while tool output is captured after `tool_result` transformers.
|
|
84
|
+
|
|
85
|
+
Images and embedded base64 data URIs are represented without their payloads, including provider data URLs. Every captured input or output has one cumulative 64 KiB serialized UTF-8 budget, bounded object/array traversal, and deterministic truncation markers. Langfuse credentials are masked again in the span processor before network export.
|
|
86
|
+
|
|
87
|
+
Completed observations are exported in batches while Pi remains live. Normal `agent_end` handling never waits for Langfuse network I/O. Use `/langfuse flush` when you need to wait for completed exports; quit shutdown also drains the provider.
|
|
88
|
+
|
|
89
|
+
Automatic retries or continuations that begin without a new user prompt are recorded as a new trace labeled `[automatic continuation]`, so provider activity is not lost on Pi versions without a final `agent_settled` extension event.
|
|
90
|
+
|
|
91
|
+
## 💬 Command
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
/langfuse status
|
|
95
|
+
/langfuse flush
|
|
96
|
+
/langfuse help
|
|
97
|
+
/langfuse init
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `status` reports whether tracing is enabled, the endpoint, configuration source, and content-capture mode. It never displays credentials.
|
|
101
|
+
- `flush` waits for all completed observations to export.
|
|
102
|
+
- `help` displays command guidance.
|
|
103
|
+
- `init` interactively creates or updates the private config without displaying existing credentials. Blank keys preserve valid existing values; a blank base URL uses the US cloud endpoint.
|
|
104
|
+
|
|
105
|
+
## 🔐 Privacy
|
|
106
|
+
|
|
107
|
+
With content capture enabled, traces can contain user prompts, model responses, tool arguments, and tool results. These may include source code, file contents, shell output, or other sensitive project data. Review your Langfuse retention and access controls before enabling this extension.
|
|
108
|
+
|
|
109
|
+
The built-in mask specifically protects Langfuse credentials; it is not a general secret scanner. Set `"captureContent": false` in `pi-langfuse.json` when content must remain local.
|
|
110
|
+
|
|
111
|
+
## 🗂️ Package layout
|
|
112
|
+
|
|
113
|
+
```txt
|
|
114
|
+
extensions/pi-langfuse/
|
|
115
|
+
├── src/
|
|
116
|
+
│ ├── langfuse.ts # Pi lifecycle integration and slash command
|
|
117
|
+
│ ├── tracing.ts # Trace lifecycle and content bounding
|
|
118
|
+
│ ├── runtime.ts # Langfuse/OpenTelemetry runtime
|
|
119
|
+
│ └── config.ts # Private pi-langfuse.json loading and validation
|
|
120
|
+
├── test/
|
|
121
|
+
├── README.md
|
|
122
|
+
├── LICENSE
|
|
123
|
+
├── tsconfig.json
|
|
124
|
+
└── package.json
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Only `langfuse.ts` is a Pi entrypoint; the other source modules are internal.
|
|
128
|
+
|
|
129
|
+
## 🔎 Keywords
|
|
130
|
+
|
|
131
|
+
Pi extension, Pi coding agent, Langfuse, LLM observability, OpenTelemetry, tracing, generations, tool spans, token usage, AI agent monitoring.
|
|
132
|
+
|
|
133
|
+
## 📄 License
|
|
134
|
+
|
|
135
|
+
MIT. See [`LICENSE`](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@narumitw/pi-langfuse",
|
|
3
|
+
"version": "0.14.1",
|
|
4
|
+
"description": "Pi extension that traces LLM generations and tool activity to Langfuse.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"langfuse",
|
|
13
|
+
"observability",
|
|
14
|
+
"tracing"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./src/langfuse.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check": "biome check . && npm run typecheck",
|
|
28
|
+
"format": "biome check --write .",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@langfuse/otel": "^4.0.0",
|
|
33
|
+
"@langfuse/tracing": "^4.0.0",
|
|
34
|
+
"@opentelemetry/api": "^1.9.0",
|
|
35
|
+
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
36
|
+
"@opentelemetry/sdk-trace-node": "^2.9.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "2.5.3",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "0.80.3",
|
|
41
|
+
"@types/node": "26.1.1",
|
|
42
|
+
"typescript": "6.0.3"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/narumiruna/pi-extensions",
|
|
47
|
+
"directory": "extensions/pi-langfuse"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILE_NAME = "pi-langfuse.json";
|
|
7
|
+
export const DEFAULT_BASE_URL = "https://us.cloud.langfuse.com";
|
|
8
|
+
|
|
9
|
+
export interface LangfuseConfig {
|
|
10
|
+
publicKey: string;
|
|
11
|
+
secretKey: string;
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
environment?: string;
|
|
14
|
+
release?: string;
|
|
15
|
+
captureContent: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type LangfuseConfigResult =
|
|
19
|
+
| { ok: true; config: LangfuseConfig; path: string; warnings: string[] }
|
|
20
|
+
| { ok: false; path: string; warnings: string[]; reason: string };
|
|
21
|
+
|
|
22
|
+
export function langfuseConfigPath(): string {
|
|
23
|
+
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), CONFIG_FILE_NAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeLangfuseConfig(
|
|
27
|
+
config: LangfuseConfig,
|
|
28
|
+
path = langfuseConfigPath(),
|
|
29
|
+
): Promise<LangfuseConfig> {
|
|
30
|
+
const normalized = normalizeLangfuseConfig(config);
|
|
31
|
+
if (!normalized.ok) throw new Error(normalized.reason);
|
|
32
|
+
|
|
33
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
|
+
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
35
|
+
try {
|
|
36
|
+
await writeFile(tempPath, `${JSON.stringify(normalized.config, null, 2)}\n`, {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
mode: 0o600,
|
|
39
|
+
});
|
|
40
|
+
await chmod(tempPath, 0o600);
|
|
41
|
+
await rename(tempPath, path);
|
|
42
|
+
await chmod(path, 0o600);
|
|
43
|
+
return normalized.config;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function loadLangfuseConfig(
|
|
51
|
+
path = langfuseConfigPath(),
|
|
52
|
+
): Promise<LangfuseConfigResult> {
|
|
53
|
+
const warnings: string[] = [];
|
|
54
|
+
const permissionFailure = await ensurePrivatePermissions(path, warnings);
|
|
55
|
+
if (permissionFailure) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
path,
|
|
59
|
+
warnings,
|
|
60
|
+
reason: `Refusing to load credentials: ${permissionFailure}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let parsed: unknown;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
69
|
+
return { ok: false, path, warnings, reason: `Configuration file not found: ${path}` };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
path,
|
|
74
|
+
warnings,
|
|
75
|
+
reason: `Failed to read ${path}: ${formatError(error)}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const normalized = normalizeLangfuseConfig(parsed);
|
|
80
|
+
if (!normalized.ok) return { ok: false, path, warnings, reason: normalized.reason };
|
|
81
|
+
return { ok: true, config: normalized.config, path, warnings };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeLangfuseConfig(
|
|
85
|
+
value: unknown,
|
|
86
|
+
): { ok: true; config: LangfuseConfig } | { ok: false; reason: string } {
|
|
87
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
88
|
+
return { ok: false, reason: "pi-langfuse.json must contain a JSON object." };
|
|
89
|
+
}
|
|
90
|
+
const input = value as Record<string, unknown>;
|
|
91
|
+
const publicKey = normalizeString(input.publicKey);
|
|
92
|
+
const secretKey = normalizeString(input.secretKey);
|
|
93
|
+
if (!publicKey) {
|
|
94
|
+
return { ok: false, reason: "pi-langfuse.json requires a literal publicKey string." };
|
|
95
|
+
}
|
|
96
|
+
if (!secretKey) {
|
|
97
|
+
return { ok: false, reason: "pi-langfuse.json requires a literal secretKey string." };
|
|
98
|
+
}
|
|
99
|
+
if (isInterpolation(publicKey)) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason:
|
|
103
|
+
"pi-langfuse.json publicKey must be literal; environment and command interpolation are not supported.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (isInterpolation(secretKey)) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason:
|
|
110
|
+
"pi-langfuse.json secretKey must be literal; environment and command interpolation are not supported.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rawBaseUrl =
|
|
115
|
+
input.baseUrl === undefined ? DEFAULT_BASE_URL : normalizeString(input.baseUrl);
|
|
116
|
+
if (!rawBaseUrl) {
|
|
117
|
+
return { ok: false, reason: "pi-langfuse.json baseUrl must be a non-empty string." };
|
|
118
|
+
}
|
|
119
|
+
const baseUrl = normalizeBaseUrl(rawBaseUrl);
|
|
120
|
+
if (!baseUrl) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason:
|
|
124
|
+
"pi-langfuse.json baseUrl must use HTTP or HTTPS without credentials, a query, or a fragment.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (input.captureContent !== undefined && typeof input.captureContent !== "boolean") {
|
|
129
|
+
return { ok: false, reason: "pi-langfuse.json captureContent must be a boolean." };
|
|
130
|
+
}
|
|
131
|
+
const environment = optionalString(input.environment, "environment");
|
|
132
|
+
if (!environment.ok) return environment;
|
|
133
|
+
const release = optionalString(input.release, "release");
|
|
134
|
+
if (!release.ok) return release;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
config: {
|
|
139
|
+
publicKey,
|
|
140
|
+
secretKey,
|
|
141
|
+
baseUrl,
|
|
142
|
+
...(environment.value ? { environment: environment.value } : {}),
|
|
143
|
+
...(release.value ? { release: release.value } : {}),
|
|
144
|
+
captureContent: input.captureContent ?? true,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function ensurePrivatePermissions(
|
|
150
|
+
path: string,
|
|
151
|
+
warnings: string[],
|
|
152
|
+
): Promise<string | undefined> {
|
|
153
|
+
try {
|
|
154
|
+
const file = await stat(path);
|
|
155
|
+
if ((file.mode & 0o777) !== 0o600) {
|
|
156
|
+
await chmod(path, 0o600);
|
|
157
|
+
const repaired = await stat(path);
|
|
158
|
+
if ((repaired.mode & 0o777) !== 0o600) {
|
|
159
|
+
throw new Error(`permissions remained ${(repaired.mode & 0o777).toString(8)}`);
|
|
160
|
+
}
|
|
161
|
+
warnings.push(`Restricted ${path} permissions to 0600.`);
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
165
|
+
const failure = `Failed to enforce 0600 permissions for ${path}: ${formatError(error)}`;
|
|
166
|
+
warnings.push(failure);
|
|
167
|
+
return failure;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeBaseUrl(value: string): string | undefined {
|
|
173
|
+
try {
|
|
174
|
+
const url = new URL(value);
|
|
175
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
176
|
+
if (url.username || url.password || url.search || url.hash) return undefined;
|
|
177
|
+
return url.toString().replace(/\/+$/, "");
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function optionalString(
|
|
184
|
+
value: unknown,
|
|
185
|
+
name: string,
|
|
186
|
+
): { ok: true; value?: string } | { ok: false; reason: string } {
|
|
187
|
+
if (value === undefined) return { ok: true };
|
|
188
|
+
const normalized = normalizeString(value);
|
|
189
|
+
return normalized
|
|
190
|
+
? { ok: true, value: normalized }
|
|
191
|
+
: { ok: false, reason: `pi-langfuse.json ${name} must be a non-empty string.` };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeString(value: unknown): string | undefined {
|
|
195
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isInterpolation(value: string): boolean {
|
|
199
|
+
return value.startsWith("$") || value.startsWith("!");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
203
|
+
return error instanceof Error && "code" in error;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatError(error: unknown): string {
|
|
207
|
+
return error instanceof Error ? error.message : String(error);
|
|
208
|
+
}
|