@powerduck/conf-patch 0.3.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 +348 -0
- package/dist/chunk-YWMVVRYM.mjs +1 -0
- package/dist/core-CDrue4z-.d.mts +143 -0
- package/dist/core-CDrue4z-.d.ts +143 -0
- package/dist/core.d.mts +1 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +1 -0
- package/dist/core.mjs +1 -0
- package/dist/index.d.mts +207 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +1 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,348 @@
|
|
|
1
|
+
# @powerduckie/confedit
|
|
2
|
+
|
|
3
|
+
A production-grade configuration editor with a clean **two-layer architecture**:
|
|
4
|
+
|
|
5
|
+
1. **Core layer** (browser-safe): Pure functions that patch JSON, JSONC, and YAML strings. No filesystem access. Works in Node.js, Electron, and browsers.
|
|
6
|
+
2. **File layer** (Node.js / Electron only): Functions that read/write files, combining the core layer with atomic writes and cross-process file locking.
|
|
7
|
+
|
|
8
|
+
RFC 6902 JSON Patch semantics, comment and formatting preservation, OpenAPI validation, and full TypeScript support.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
### Core Layer (Browser-Safe)
|
|
13
|
+
- **`patchContent`** — Apply RFC 6902 patch operations to a configuration string
|
|
14
|
+
- **`setContentValue`** — Set or create a nested value
|
|
15
|
+
- **`deleteContentValue`** — Remove a key or array element
|
|
16
|
+
- **No filesystem access** — Works in browsers, IndexedDB, localStorage, or any storage layer
|
|
17
|
+
|
|
18
|
+
### File Layer (Node.js / Electron)
|
|
19
|
+
- **`readConfigFile`** — Read a configuration file
|
|
20
|
+
- **`writeConfigFile`** — Write a file with atomic writes and optional locking
|
|
21
|
+
- **`patchConfigFile`** — Read-patch-write transaction inside a file lock
|
|
22
|
+
- **`setConfigValue`** — Set a value in a file
|
|
23
|
+
- **`deleteConfigValue`** — Delete a value from a file
|
|
24
|
+
- **Atomic writes** — Temp file + rename pattern, no partial writes on crash
|
|
25
|
+
- **Cross-process file locking** — Exclusive locks with ownership tokens, exponential backoff, stale-lock recovery
|
|
26
|
+
|
|
27
|
+
### Format Support
|
|
28
|
+
- **JSON** — Standard JSON
|
|
29
|
+
- **JSONC** — JSON with comments and trailing commas (preserved)
|
|
30
|
+
- **YAML** — YAML 1.2 with comments and indentation preservation
|
|
31
|
+
|
|
32
|
+
### OpenAPI Validation
|
|
33
|
+
- **`validateOpenAPISpec`** — Validate raw OpenAPI/Swagger content (JSON or YAML)
|
|
34
|
+
- **`validateOpenAPIFile`** — Validate from a file path
|
|
35
|
+
- Delegates to `@powerduck/openapi-parser` with secure input handling (size limits, path sandboxing, deadlines, structural complexity guards)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install @powerduckie/confedit
|
|
41
|
+
pnpm add @powerduckie/confedit
|
|
42
|
+
yarn add @powerduckie/confedit
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Module System Support
|
|
46
|
+
|
|
47
|
+
This package supports both **CommonJS (`require`)** and **ES Modules (`import`)** for all entry points.
|
|
48
|
+
|
|
49
|
+
### CommonJS (Node.js / Electron main process)
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
// Full library (includes file IO)
|
|
53
|
+
const { setConfigValue, readConfigFile } = require("@powerduckie/confedit");
|
|
54
|
+
|
|
55
|
+
// Core layer only (browser-safe, no fs dependency)
|
|
56
|
+
const { patchContent, setContentValue } = require("@powerduckie/confedit/core");
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### ES Modules (modern Node.js / browsers / bundlers)
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Full library
|
|
63
|
+
import { setConfigValue, readConfigFile } from "@powerduckie/confedit";
|
|
64
|
+
|
|
65
|
+
// Core layer only (browser-safe)
|
|
66
|
+
import { patchContent, setContentValue } from "@powerduckie/confedit/core";
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Build Outputs
|
|
70
|
+
|
|
71
|
+
| Entry | CJS (`require`) | ESM (`import`) | Type Declarations |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| Main | `dist/index.js` (26KB) | `dist/index.mjs` (16KB) | `dist/index.d.ts` |
|
|
74
|
+
| Core | `dist/core.js` (9.5KB) | `dist/core.mjs` (1KB) | `dist/core.d.ts` |
|
|
75
|
+
|
|
76
|
+
The package does not set `"type": "module"`, so `.js` files are treated as CommonJS by default. The `exports` field in `package.json` explicitly maps `import` and `require` conditions to the correct builds.
|
|
77
|
+
|
|
78
|
+
## Quick Start
|
|
79
|
+
|
|
80
|
+
### Browser Usage (Core Layer Only)
|
|
81
|
+
|
|
82
|
+
> **Important:** For browser usage, import from `@powerduckie/confedit/core` (subpath export).
|
|
83
|
+
> This ensures the file layer (which depends on `node:fs`) is not bundled into your browser code.
|
|
84
|
+
> The core entry is only ~9KB gzipped and has zero Node.js dependencies.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { patchContent, setContentValue, deleteContentValue } from "@powerduckie/confedit/core";
|
|
88
|
+
|
|
89
|
+
// Patch a JSON string (no filesystem access)
|
|
90
|
+
const updated = patchContent(
|
|
91
|
+
'{"name": "app"}',
|
|
92
|
+
[{ op: "add", path: ["version"], value: "1.0.0" }],
|
|
93
|
+
"json",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Set a nested value in YAML
|
|
97
|
+
const yaml = setContentValue("name: app\n", ["server", "port"], 8080, "yaml");
|
|
98
|
+
|
|
99
|
+
// Delete a value
|
|
100
|
+
const cleaned = deleteContentValue(updated, ["legacy"], "json");
|
|
101
|
+
|
|
102
|
+
// Store in IndexedDB, localStorage, or any storage layer
|
|
103
|
+
localStorage.setItem("config", updated);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Node.js / Electron Usage (File Layer)
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { setConfigValue, readConfigFile, patchConfigFile } from "@powerduckie/confedit";
|
|
110
|
+
|
|
111
|
+
// Set a nested value in a file (atomic, lock-guarded)
|
|
112
|
+
await setConfigValue("config.yaml", ["database", "port"], 5432);
|
|
113
|
+
|
|
114
|
+
// Read a file
|
|
115
|
+
const content = await readConfigFile("config.json");
|
|
116
|
+
|
|
117
|
+
// Apply multiple patch operations atomically
|
|
118
|
+
await patchConfigFile("config.jsonc", [
|
|
119
|
+
{ op: "replace", path: ["server", "host"], value: "0.0.0.0" },
|
|
120
|
+
{ op: "add", path: ["server", "ssl"], value: true },
|
|
121
|
+
{ op: "remove", path: ["legacySection"] },
|
|
122
|
+
]);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Electron IPC Example
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
// In the Electron main process (CommonJS)
|
|
129
|
+
const { setConfigValue, readConfigFile } = require("@powerduckie/confedit");
|
|
130
|
+
|
|
131
|
+
ipcMain.handle("config:set", async (_event, { filePath, path, value }) => {
|
|
132
|
+
try {
|
|
133
|
+
await setConfigValue(filePath, path, value);
|
|
134
|
+
return { success: true };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { success: false, error: error.message };
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
ipcMain.handle("config:read", async (_event, { filePath }) => {
|
|
141
|
+
try {
|
|
142
|
+
const data = await readConfigFile(filePath);
|
|
143
|
+
return { success: true, data };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return { success: false, error: error.message };
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## API Reference
|
|
151
|
+
|
|
152
|
+
### Core Layer (Browser-Safe)
|
|
153
|
+
|
|
154
|
+
#### `patchContent(content, ops, format, options?)`
|
|
155
|
+
|
|
156
|
+
Applies RFC 6902 patch operations to a configuration string.
|
|
157
|
+
|
|
158
|
+
| Parameter | Type | Description |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `content` | `string` | The raw configuration content |
|
|
161
|
+
| `ops` | `JsonPatchOp[]` | Array of patch operations |
|
|
162
|
+
| `format` | `"json" \| "jsonc" \| "yaml"` | The configuration format |
|
|
163
|
+
| `options.strict` | `boolean` | When true, failed operations throw. Default: `true` |
|
|
164
|
+
|
|
165
|
+
**Returns:** The patched configuration content (`string`).
|
|
166
|
+
|
|
167
|
+
#### `setContentValue(content, path, value, format)`
|
|
168
|
+
|
|
169
|
+
Sets or creates a single value. Uses the `add` operation (replaces existing object properties, inserts at array indices).
|
|
170
|
+
|
|
171
|
+
#### `deleteContentValue(content, path, format)`
|
|
172
|
+
|
|
173
|
+
Removes a key or array element.
|
|
174
|
+
|
|
175
|
+
### File Layer (Node.js / Electron Only)
|
|
176
|
+
|
|
177
|
+
#### `readConfigFile(filePath)`
|
|
178
|
+
|
|
179
|
+
Reads UTF-8 text from a file. Throws on failure.
|
|
180
|
+
|
|
181
|
+
#### `writeConfigFile(filePath, content, options?)`
|
|
182
|
+
|
|
183
|
+
Writes content to a file using atomic writes and optional file locking.
|
|
184
|
+
|
|
185
|
+
| Option | Type | Description |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `lock` | `boolean` | Enable file locking. Default: `true` |
|
|
188
|
+
| `lockTimeoutMs` | `number` | Maximum time to wait for a lock |
|
|
189
|
+
| `lockRetryDelayMs` | `number` | Initial retry delay (exponential backoff) |
|
|
190
|
+
| `lockStaleThresholdMs` | `number` | Lock age after which recovery is allowed |
|
|
191
|
+
| `allowStaleRecovery` | `boolean` | Allow automatic stale-lock recovery |
|
|
192
|
+
|
|
193
|
+
#### `patchConfigFile(filePath, ops, options?)`
|
|
194
|
+
|
|
195
|
+
Reads, patches, and writes a file inside a single atomic, lock-guarded transaction.
|
|
196
|
+
|
|
197
|
+
| Option | Type | Description |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `format` | `"json" \| "jsonc" \| "yaml"` | Explicit format (auto-detected from extension if omitted) |
|
|
200
|
+
| `strict` | `boolean` | When true, failed operations throw. Default: `true` |
|
|
201
|
+
| `lock` | `boolean` | Enable file locking. Default: `true` |
|
|
202
|
+
| `lockTimeoutMs` | `number` | Maximum time to wait for a lock |
|
|
203
|
+
| `lockRetryDelayMs` | `number` | Initial retry delay |
|
|
204
|
+
| `lockStaleThresholdMs` | `number` | Lock age after which recovery is allowed |
|
|
205
|
+
| `allowStaleRecovery` | `boolean` | Allow automatic stale-lock recovery |
|
|
206
|
+
|
|
207
|
+
#### `setConfigValue(filePath, path, value, options?)`
|
|
208
|
+
|
|
209
|
+
Sets or creates a nested value in a file.
|
|
210
|
+
|
|
211
|
+
#### `deleteConfigValue(filePath, path, options?)`
|
|
212
|
+
|
|
213
|
+
Removes a nested value from a file.
|
|
214
|
+
|
|
215
|
+
### Types
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
type ConfigFormat = "json" | "jsonc" | "yaml";
|
|
219
|
+
type JsonPathSegment = string | number;
|
|
220
|
+
|
|
221
|
+
interface JsonPatchOp {
|
|
222
|
+
op: "add" | "replace" | "remove";
|
|
223
|
+
path: JsonPathSegment[];
|
|
224
|
+
value?: unknown; // required for add and replace
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### OpenAPI Validation
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
import { validateOpenAPISpec, validateOpenAPIFile, OpenApiValidationError } from "@powerduckie/confedit";
|
|
232
|
+
|
|
233
|
+
// Validate raw content (JSON or YAML)
|
|
234
|
+
try {
|
|
235
|
+
const doc = await validateOpenAPISpec(specContent);
|
|
236
|
+
console.log("Valid:", doc.openapi);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error instanceof OpenApiValidationError) {
|
|
239
|
+
console.error(`Validation failed [${error.code}]:`, error.message);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Validate from a file path (requires allowedRootDirectory for security)
|
|
244
|
+
const doc = await validateOpenAPIFile("openapi.yaml", {
|
|
245
|
+
allowedRootDirectory: "./configs",
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Returns the validated document on success. Throws `OpenApiValidationError` with a machine-readable `code` on failure.
|
|
250
|
+
|
|
251
|
+
#### Validation Options
|
|
252
|
+
|
|
253
|
+
| Option | Type | Default | Description |
|
|
254
|
+
|---|---|---|---|
|
|
255
|
+
| `inputKind` | `"content" \| "file"` | `"content"` | Whether input is raw content or a file path |
|
|
256
|
+
| `baseFilePath` | `string` | - | Base path for reference resolution context |
|
|
257
|
+
| `allowedRootDirectory` | `string` | - | Root directory for file input (required for file mode) |
|
|
258
|
+
| `timeoutMs` | `number` | `15000` | Operation deadline in milliseconds |
|
|
259
|
+
| `maxInputBytes` | `number` | `5242880` (5MB) | Maximum raw input size in bytes |
|
|
260
|
+
| `maxDocumentNodes` | `number` | `100000` | Maximum nodes in parsed document (DoS protection) |
|
|
261
|
+
| `maxDocumentDepth` | `number` | `100` | Maximum nesting depth (DoS protection) |
|
|
262
|
+
| `maxValidationErrors` | `number` | `50` | Maximum validation errors included in error message |
|
|
263
|
+
| `maxErrorMessageLength` | `number` | `1000` | Maximum characters per validation error |
|
|
264
|
+
| `signal` | `AbortSignal` | - | Optional cancellation signal |
|
|
265
|
+
|
|
266
|
+
#### Error Codes
|
|
267
|
+
|
|
268
|
+
| Code | Description |
|
|
269
|
+
|---|---|
|
|
270
|
+
| `INVALID_OPTION` | Invalid option value provided |
|
|
271
|
+
| `INPUT_TOO_LARGE` | Input exceeds maximum allowed size |
|
|
272
|
+
| `INPUT_FILE_TOO_LARGE` | File exceeds maximum allowed size |
|
|
273
|
+
| `FILE_INPUT_FORBIDDEN` | File input used without `allowedRootDirectory` |
|
|
274
|
+
| `PATH_OUTSIDE_ROOT` | File path is outside the allowed root directory |
|
|
275
|
+
| `PARSE_ERROR` | Failed to parse JSON/YAML content |
|
|
276
|
+
| `INVALID_DOCUMENT_SHAPE` | Document is not a JSON object |
|
|
277
|
+
| `UNSUPPORTED_VERSION` | Document does not declare a supported OpenAPI/Swagger version |
|
|
278
|
+
| `DOCUMENT_TOO_DEEP` | Document exceeds maximum nesting depth |
|
|
279
|
+
| `DOCUMENT_TOO_LARGE` | Document exceeds maximum node count |
|
|
280
|
+
| `SPEC_VALIDATION_FAILED` | OpenAPI specification validation failed |
|
|
281
|
+
| `OPERATION_TIMEOUT` | Operation timed out |
|
|
282
|
+
| `OPERATION_ABORTED` | Operation was aborted via signal |
|
|
283
|
+
| `UNKNOWN_ERROR` | Unexpected error occurred |
|
|
284
|
+
|
|
285
|
+
## Behavior Notes
|
|
286
|
+
|
|
287
|
+
### Array Operations
|
|
288
|
+
- `add` at an array index **inserts** the element (RFC 6902 behavior)
|
|
289
|
+
- `replace` at an array index **replaces** the element
|
|
290
|
+
- To replace an existing array element, use `patchConfigFile` with `op: "replace"`
|
|
291
|
+
|
|
292
|
+
### Nested Paths
|
|
293
|
+
- `add` requires the parent path to exist. To create a nested object, first create the parent, then the child.
|
|
294
|
+
- `replace` and `remove` require the target path to exist.
|
|
295
|
+
|
|
296
|
+
### Format Detection
|
|
297
|
+
- `.json` → JSON
|
|
298
|
+
- `.jsonc` → JSON with comments
|
|
299
|
+
- `.yaml` / `.yml` → YAML
|
|
300
|
+
- Other extensions → throw unless `format` is explicitly set
|
|
301
|
+
|
|
302
|
+
## Architecture
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
┌─────────────────────────────────────────────────────────┐
|
|
306
|
+
│ Application Code │
|
|
307
|
+
├─────────────────────────────────────────────────────────┤
|
|
308
|
+
│ File Layer (Node.js / Electron only) │
|
|
309
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
310
|
+
│ │ readConfigFile│ │writeConfigFile│ │patchConfigFile│ │
|
|
311
|
+
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
|
312
|
+
│ │ │ │ │
|
|
313
|
+
│ ┌──────▼───────────────────▼───────────────────▼───────┐│
|
|
314
|
+
│ │ Core Layer (Browser-Safe) ││
|
|
315
|
+
│ │ patchContent setContentValue deleteContentValue ││
|
|
316
|
+
│ └────────────────────────────────────────────────────────┘│
|
|
317
|
+
└───────────────────────────────────────────────────────────┘
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The core layer has zero dependencies on `node:fs`, `node:path`, or any Node.js-specific APIs. It can be used in browsers, Edge Functions, or any JavaScript environment.
|
|
321
|
+
|
|
322
|
+
The file layer wraps the core layer with filesystem access, atomic writes, and file locking. It only works in Node.js and Electron.
|
|
323
|
+
|
|
324
|
+
## Performance
|
|
325
|
+
|
|
326
|
+
- **Core patching**: Incremental text edits via `jsonc-parser` (JSON/JSONC) and AST-based mutation via `yaml` (YAML). No full re-serialization.
|
|
327
|
+
- **Atomic writes**: Temp file + rename, no in-place modification.
|
|
328
|
+
- **File locking**: Lock files with ownership tokens, exponential backoff, no busy-wait polling.
|
|
329
|
+
- **Process-local queue**: Same-file operations are serialized in-process to avoid lock contention.
|
|
330
|
+
- **Bundle size**: Core layer ~15KB gzipped. Full library ~30KB gzipped.
|
|
331
|
+
|
|
332
|
+
## Testing
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
npm test # Run all tests (280 tests)
|
|
336
|
+
npm run typecheck # TypeScript type checking
|
|
337
|
+
npm run build # Build CJS + ESM + type declarations
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Test coverage: 280 tests across 12 test files, covering all public APIs, edge cases, concurrency, error handling, browser compatibility, dual-module (CJS/ESM) support, and OpenAPI validation security controls.
|
|
341
|
+
|
|
342
|
+
## License
|
|
343
|
+
|
|
344
|
+
MIT — see [LICENSE](./LICENSE) for details.
|
|
345
|
+
|
|
346
|
+
## Repository
|
|
347
|
+
|
|
348
|
+
[https://github.com/PowerDuckie/confedit](https://github.com/PowerDuckie/confedit)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function assertNonEmptyString(t,r){if("string"!=typeof t||0===t.length)throw new TypeError(`[confedit] ${r} must be a non-empty string.`)}function assertPatchPath(t,r="path"){if(!Array.isArray(t)||0===t.length)throw new TypeError(`[confedit] ${r} must be a non-empty array.`);for(const[e,o]of t.entries())if("string"!=typeof o){if("number"!=typeof o||!Number.isSafeInteger(o)||o<0)throw new TypeError(`[confedit] ${r}[${e}] must be a non-empty string or a non-negative integer.`)}else if(0===o.length)throw new TypeError(`[confedit] ${r}[${e}] must not be an empty string.`)}function assertPatchOperations(t){if(!Array.isArray(t))throw new TypeError("[confedit] ops must be an array.");for(const[r,e]of t.entries()){if(null===e||"object"!=typeof e)throw new TypeError(`[confedit] Patch operation at index ${r} must be an object.`);if("add"!==e.op&&"replace"!==e.op&&"remove"!==e.op)throw new Error(`[confedit] Unsupported patch operation at index ${r}: ${String(e.op)}.`);if(assertPatchPath(e.path,`ops[${r}].path`),("add"===e.op||"replace"===e.op)&&!Object.prototype.hasOwnProperty.call(e,"value"))throw new TypeError(`[confedit] Patch operation at index ${r} requires a value.`)}}function assertConfigFormat(t){if("json"!==t&&"jsonc"!==t&&"yaml"!==t)throw new Error(`[confedit] Unsupported configuration format: ${String(t)}.`)}function getErrorMessage(t){return t instanceof Error?t.message:String(t)}function createError(t,r){const e=new Error(t);try{Object.defineProperty(e,"cause",{configurable:!0,enumerable:!1,value:r,writable:!0})}catch{}return e}import{applyEdits as t,findNodeAtLocation as r,modify as e,parseTree as o}from"jsonc-parser";var n={insertSpaces:!0,tabSize:2,eol:"\n"};function validateOperation(t){if(null===t||"object"!=typeof t)throw new TypeError("Patch operation must be an object.");if(!Array.isArray(t.path)||0===t.path.length)throw new Error("Patch operation path must be a non-empty array.");if("add"!==t.op&&"replace"!==t.op&&"remove"!==t.op)throw new Error(`Unsupported JSON patch operation: ${String(t.op)}`);if(("add"===t.op||"replace"===t.op)&&!Object.prototype.hasOwnProperty.call(t,"value"))throw new Error(`Patch operation "${t.op}" requires a value.`)}function normalizePath(t){return t.map(t=>{if("number"==typeof t){if(!Number.isSafeInteger(t)||t<0)throw new Error(`Invalid array index: ${t}`);return t}if("string"!=typeof t)throw new TypeError(`Patch path segments must be strings or numbers; received ${typeof t}.`);return t})}function applyAdd(t,e,o){const n=function(t,e){if(1===e.length){if("object"!==t.type&&"array"!==t.type)throw new Error("Cannot add a root child to a scalar JSON value.");return t}const o=e.slice(0,-1),n=r(t,o);if(void 0===n)throw new Error(`Cannot add value because its parent does not exist at path: ${formatPath(o)}`);if("object"!==n.type&&"array"!==n.type)throw new Error(`Cannot add value because its parent is not an object or array at path: ${formatPath(o)}`);return n}(getTree(t),e),a=e[e.length-1];if("array"===n.type){const r=function(t,r){if("number"!=typeof t)throw new Error(`Array index must be a number at path: ${formatPath(r)}`);if(!Number.isSafeInteger(t)||t<0)throw new Error(`Invalid array index ${t} at path: ${formatPath(r)}`);return t}(a,e),i=n.children?.length??0;if(r>i)throw new Error(`Cannot add at array index ${r}; array length is ${i} at path: ${formatPath(e.slice(0,-1))}`);return applyModify(t,e,o,!0)}if("object"===n.type){if("string"!=typeof a)throw new Error(`Object property path segment must be a string at path: ${formatPath(e)}`);return applyModify(t,e,o,!1)}throw new Error(`Cannot add a child to non-container value at path: ${formatPath(e.slice(0,-1))}`)}function applyReplace(t,e,o){const n=getTree(t);if(void 0===r(n,e))throw new Error(`Cannot replace a value that does not exist at path: ${formatPath(e)}`);return applyModify(t,e,o,!1)}function applyRemove(t,e){const o=getTree(t);if(void 0===r(o,e))throw new Error(`Cannot remove a value that does not exist at path: ${formatPath(e)}`);return applyModify(t,e,void 0,!1)}function applyModify(r,o,a,i){const s=e(r,o,a,{formattingOptions:n,isArrayInsertion:i});if(0===s.length)throw new Error(`No JSONC edit was generated for path: ${formatPath(o)}`);return t(r,s)}function getTree(t){const r=[],e=o(t,r,{allowTrailingComma:!0,disallowComments:!1});if(void 0===e||r.length>0)throw new Error(`The source text is not valid JSON or JSONC${function(t){if(0===t.length)return"";return` (parse errors: ${t.map(t=>`code=${t.error}, offset=${t.offset}`).join("; ")})`}(r)}.`);return e}function formatPath(t){return`[${t.map(t=>JSON.stringify(t)).join(", ")}]`}function safeStringify(t){try{return JSON.stringify(t)}catch{return"[unserializable patch operation]"}}function getErrorMessage2(t){return t instanceof Error?t.message:String(t)}function createError2(t,r){const e=new Error(t);try{Object.defineProperty(e,"cause",{configurable:!0,enumerable:!1,value:r,writable:!0})}catch{}return e}import{isMap as a,isSeq as i,parseDocument as s}from"yaml";function applyYamlOperation(t,r){!function(t){if(!Array.isArray(t.path)||0===t.path.length)throw new Error("[confedit] Patch path must be a non-empty array.");if("add"!==t.op&&"replace"!==t.op&&"remove"!==t.op)throw new Error(`[confedit] Unsupported patch operation: ${String(t.op)}.`)}(r);const e=r.path,o=function(t,r){if(0===r.length)throw new Error("[confedit] Replacing the YAML document root is not supported.");if(1===r.length){if(null===t.contents)throw new Error("[confedit] Cannot patch an empty YAML document without a root container.");return t.contents}const e=r.slice(0,-1),o=t.getIn(e,!0);if(null==o)throw new Error(`[confedit] Missing parent path: ${formatPath2(e)}.`);if(!a(o)&&!i(o))throw new Error(`[confedit] Parent at ${formatPath2(e)} is not a YAML map or sequence.`);return o}(t,e),n=e[e.length-1];if(a(o))!function(t,r,e,o){if("string"!=typeof e)throw new Error(`[confedit] YAML map keys must be strings at ${formatPath2(o.path)}.`);const n=r.has(e);switch(o.op){case"add":return void r.set(e,t.createNode(o.value));case"replace":if(!n)throw new Error(`[confedit] Cannot replace missing value at ${formatPath2(o.path)}.`);return void r.set(e,t.createNode(o.value));case"remove":if(!n)throw new Error(`[confedit] Cannot remove missing value at ${formatPath2(o.path)}.`);return void r.delete(e);default:throw new Error(`[confedit] Unsupported patch operation: ${String(o.op)}.`)}}(t,o,n,r);else{if(!i(o))throw new Error(`[confedit] Cannot apply patch at ${formatPath2(e)} because its parent is not a YAML map or sequence.`);!function(t,r,e,o){if("number"!=typeof e||!Number.isSafeInteger(e)||e<0)throw new Error(`[confedit] YAML sequence indexes must be non-negative integers at ${formatPath2(o.path)}.`);const n=r.items.length;switch(o.op){case"add":if(e>n)throw new Error(`[confedit] Cannot insert at index ${e}; sequence length is ${n}.`);return void r.items.splice(e,0,t.createNode(o.value));case"replace":if(e>=n)throw new Error(`[confedit] Cannot replace index ${e}; sequence length is ${n}.`);return void(r.items[e]=t.createNode(o.value));case"remove":if(e>=n)throw new Error(`[confedit] Cannot remove index ${e}; sequence length is ${n}.`);return void r.items.splice(e,1);default:throw new Error(`[confedit] Unsupported patch operation: ${String(o.op)}.`)}}(t,o,n,r)}}function formatPath2(t){return`[${t.map(t=>JSON.stringify(t)).join(", ")}]`}function patchContent(t,r,e,o={}){if("string"!=typeof t)throw new TypeError("[confedit] content must be a string.");if(assertPatchOperations(r),assertConfigFormat(e),0===r.length)return t;const n=o.strict??!0;switch(e){case"json":case"jsonc":return function(t,r,e=!0){if("string"!=typeof t)throw new TypeError("[confedit] sourceText must be a string.");if(!Array.isArray(r))throw new TypeError("[confedit] ops must be an array.");let o=t;for(const t of r)try{validateOperation(t);const r=normalizePath(t.path);switch(t.op){case"add":o=applyAdd(o,r,t.value);break;case"replace":o=applyReplace(o,r,t.value);break;case"remove":o=applyRemove(o,r);break;default:throw new Error(`[confedit] Unsupported JSON patch operation: ${String(t.op)}`)}}catch(r){const o=`[confedit] Failed to apply JSON patch ${safeStringify(t)}: ${getErrorMessage2(r)}`;if(e)throw createError2(o,r);console.warn(o)}return o}(t,r,n);case"yaml":return function(t,r,e=!0){const o=s(t,{prettyErrors:!0,strict:!0});if(o.errors.length>0)throw new Error(`[confedit] Invalid YAML source: ${o.errors.map(t=>t.message).join("; ")}`);for(const t of r)try{applyYamlOperation(o,t)}catch(r){if(e)throw r;console.warn(`[confedit yaml patch warn] Skip operation ${JSON.stringify(t)}`,r)}return o.toString()}(t,r,n)}}function setContentValue(t,r,e,o){return assertPatchPath(r),patchContent(t,[{op:"add",path:[...r],value:e}],o)}function deleteContentValue(t,r,e){return assertPatchPath(r),patchContent(t,[{op:"remove",path:[...r]}],e)}export{assertNonEmptyString,assertPatchPath,assertPatchOperations,assertConfigFormat,getErrorMessage,createError,patchContent,setContentValue,deleteContentValue};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
type ConfigFormat = "json" | "jsonc" | "yaml";
|
|
2
|
+
type JsonPathSegment = string | number;
|
|
3
|
+
interface JsonPatchOp {
|
|
4
|
+
op: "add" | "replace" | "remove";
|
|
5
|
+
path: JsonPathSegment[];
|
|
6
|
+
value?: unknown;
|
|
7
|
+
}
|
|
8
|
+
interface PatchConfigOptions {
|
|
9
|
+
format?: ConfigFormat;
|
|
10
|
+
strict?: boolean;
|
|
11
|
+
lock?: boolean;
|
|
12
|
+
lockTimeoutMs?: number;
|
|
13
|
+
lockRetryDelayMs?: number;
|
|
14
|
+
lockStaleThresholdMs?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Whether stale locks can be automatically reclaimed.
|
|
17
|
+
* Disable by default for Electron single main process to avoid live transaction preemption.
|
|
18
|
+
*/
|
|
19
|
+
allowStaleRecovery?: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface FileLockOptions {
|
|
22
|
+
/** Maximum time to wait for a lock. Defaults to 10 seconds. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
/** Initial retry delay. Defaults to 25ms. */
|
|
25
|
+
retryDelayMs?: number;
|
|
26
|
+
/** Explicit lock age after which recovery is allowed. */
|
|
27
|
+
staleThresholdMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Whether stale locks can be automatically reclaimed.
|
|
30
|
+
* Disable by default for Electron single main process to avoid live transaction preemption.
|
|
31
|
+
*/
|
|
32
|
+
allowStaleRecovery?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options for content-level patch operations.
|
|
37
|
+
* These do not involve file IO and work in both Node.js and browser environments.
|
|
38
|
+
*/
|
|
39
|
+
interface PatchContentOptions {
|
|
40
|
+
/** When true, failed operations throw. When false, they are skipped with a warning. Default: true */
|
|
41
|
+
strict?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Applies RFC 6902 patch operations to a configuration string.
|
|
45
|
+
*
|
|
46
|
+
* This is a pure function that does not touch the filesystem.
|
|
47
|
+
* It works in both Node.js and browser environments.
|
|
48
|
+
*
|
|
49
|
+
* @param content - The raw configuration content (JSON, JSONC, or YAML string)
|
|
50
|
+
* @param ops - Array of RFC 6902 patch operations
|
|
51
|
+
* @param format - The configuration format ("json" | "jsonc" | "yaml")
|
|
52
|
+
* @param options - Optional patch settings
|
|
53
|
+
* @returns The patched configuration content
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* import { patchContent } from "@powerduckie/confedit";
|
|
58
|
+
*
|
|
59
|
+
* const updated = patchContent(
|
|
60
|
+
* '{"name": "app"}',
|
|
61
|
+
* [{ op: "add", path: ["version"], value: "1.0.0" }],
|
|
62
|
+
* "json",
|
|
63
|
+
* );
|
|
64
|
+
* // => '{\n "name": "app",\n "version": "1.0.0"\n}'
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
declare function patchContent(content: string, ops: JsonPatchOp[], format: ConfigFormat, options?: PatchContentOptions): string;
|
|
68
|
+
/**
|
|
69
|
+
* Sets or creates a single value in a configuration string.
|
|
70
|
+
*
|
|
71
|
+
* Uses the `add` operation, which replaces existing object properties
|
|
72
|
+
* or inserts at array indices (RFC 6902 behavior).
|
|
73
|
+
*
|
|
74
|
+
* This is a pure function that does not touch the filesystem.
|
|
75
|
+
*
|
|
76
|
+
* @param content - The raw configuration content
|
|
77
|
+
* @param path - Segment path to the target key (e.g. ["server", "port"])
|
|
78
|
+
* @param value - Value to set
|
|
79
|
+
* @param format - The configuration format
|
|
80
|
+
* @returns The updated configuration content
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* import { setContentValue } from "@powerduckie/confedit";
|
|
85
|
+
*
|
|
86
|
+
* const updated = setContentValue(
|
|
87
|
+
* "name: app\n",
|
|
88
|
+
* ["version"],
|
|
89
|
+
* "1.0.0",
|
|
90
|
+
* "yaml",
|
|
91
|
+
* );
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
declare function setContentValue(content: string, path: readonly (string | number)[], value: unknown, format: ConfigFormat): string;
|
|
95
|
+
/**
|
|
96
|
+
* Removes a key or array element from a configuration string.
|
|
97
|
+
*
|
|
98
|
+
* This is a pure function that does not touch the filesystem.
|
|
99
|
+
*
|
|
100
|
+
* @param content - The raw configuration content
|
|
101
|
+
* @param path - Segment path to remove
|
|
102
|
+
* @param format - The configuration format
|
|
103
|
+
* @returns The updated configuration content
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* import { deleteContentValue } from "@powerduckie/confedit";
|
|
108
|
+
*
|
|
109
|
+
* const updated = deleteContentValue(
|
|
110
|
+
* '{"name": "app", "legacy": true}',
|
|
111
|
+
* ["legacy"],
|
|
112
|
+
* "json",
|
|
113
|
+
* );
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
declare function deleteContentValue(content: string, path: readonly (string | number)[], format: ConfigFormat): string;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Asserts that a value is a non-empty string.
|
|
120
|
+
*/
|
|
121
|
+
declare function assertNonEmptyString(value: unknown, label: string): asserts value is string;
|
|
122
|
+
/**
|
|
123
|
+
* Asserts that a patch path is a non-empty array of valid segments.
|
|
124
|
+
*/
|
|
125
|
+
declare function assertPatchPath(path: readonly (string | number)[], label?: string): void;
|
|
126
|
+
/**
|
|
127
|
+
* Asserts that an array of patch operations is valid.
|
|
128
|
+
*/
|
|
129
|
+
declare function assertPatchOperations(ops: JsonPatchOp[]): void;
|
|
130
|
+
/**
|
|
131
|
+
* Asserts that a config format is supported.
|
|
132
|
+
*/
|
|
133
|
+
declare function assertConfigFormat(format: ConfigFormat): void;
|
|
134
|
+
/**
|
|
135
|
+
* Extracts a human-readable error message from an unknown error.
|
|
136
|
+
*/
|
|
137
|
+
declare function getErrorMessage(error: unknown): string;
|
|
138
|
+
/**
|
|
139
|
+
* Creates an Error with an optional cause, preserving compatibility with older runtimes.
|
|
140
|
+
*/
|
|
141
|
+
declare function createError(message: string, cause: unknown): Error;
|
|
142
|
+
|
|
143
|
+
export { type ConfigFormat as C, type FileLockOptions as F, type JsonPatchOp as J, type PatchConfigOptions as P, type JsonPathSegment as a, type PatchContentOptions as b, assertConfigFormat as c, assertNonEmptyString as d, assertPatchOperations as e, assertPatchPath as f, createError as g, deleteContentValue as h, getErrorMessage as i, patchContent as p, setContentValue as s };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
type ConfigFormat = "json" | "jsonc" | "yaml";
|
|
2
|
+
type JsonPathSegment = string | number;
|
|
3
|
+
interface JsonPatchOp {
|
|
4
|
+
op: "add" | "replace" | "remove";
|
|
5
|
+
path: JsonPathSegment[];
|
|
6
|
+
value?: unknown;
|
|
7
|
+
}
|
|
8
|
+
interface PatchConfigOptions {
|
|
9
|
+
format?: ConfigFormat;
|
|
10
|
+
strict?: boolean;
|
|
11
|
+
lock?: boolean;
|
|
12
|
+
lockTimeoutMs?: number;
|
|
13
|
+
lockRetryDelayMs?: number;
|
|
14
|
+
lockStaleThresholdMs?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Whether stale locks can be automatically reclaimed.
|
|
17
|
+
* Disable by default for Electron single main process to avoid live transaction preemption.
|
|
18
|
+
*/
|
|
19
|
+
allowStaleRecovery?: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface FileLockOptions {
|
|
22
|
+
/** Maximum time to wait for a lock. Defaults to 10 seconds. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
/** Initial retry delay. Defaults to 25ms. */
|
|
25
|
+
retryDelayMs?: number;
|
|
26
|
+
/** Explicit lock age after which recovery is allowed. */
|
|
27
|
+
staleThresholdMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Whether stale locks can be automatically reclaimed.
|
|
30
|
+
* Disable by default for Electron single main process to avoid live transaction preemption.
|
|
31
|
+
*/
|
|
32
|
+
allowStaleRecovery?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options for content-level patch operations.
|
|
37
|
+
* These do not involve file IO and work in both Node.js and browser environments.
|
|
38
|
+
*/
|
|
39
|
+
interface PatchContentOptions {
|
|
40
|
+
/** When true, failed operations throw. When false, they are skipped with a warning. Default: true */
|
|
41
|
+
strict?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Applies RFC 6902 patch operations to a configuration string.
|
|
45
|
+
*
|
|
46
|
+
* This is a pure function that does not touch the filesystem.
|
|
47
|
+
* It works in both Node.js and browser environments.
|
|
48
|
+
*
|
|
49
|
+
* @param content - The raw configuration content (JSON, JSONC, or YAML string)
|
|
50
|
+
* @param ops - Array of RFC 6902 patch operations
|
|
51
|
+
* @param format - The configuration format ("json" | "jsonc" | "yaml")
|
|
52
|
+
* @param options - Optional patch settings
|
|
53
|
+
* @returns The patched configuration content
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* import { patchContent } from "@powerduckie/confedit";
|
|
58
|
+
*
|
|
59
|
+
* const updated = patchContent(
|
|
60
|
+
* '{"name": "app"}',
|
|
61
|
+
* [{ op: "add", path: ["version"], value: "1.0.0" }],
|
|
62
|
+
* "json",
|
|
63
|
+
* );
|
|
64
|
+
* // => '{\n "name": "app",\n "version": "1.0.0"\n}'
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
declare function patchContent(content: string, ops: JsonPatchOp[], format: ConfigFormat, options?: PatchContentOptions): string;
|
|
68
|
+
/**
|
|
69
|
+
* Sets or creates a single value in a configuration string.
|
|
70
|
+
*
|
|
71
|
+
* Uses the `add` operation, which replaces existing object properties
|
|
72
|
+
* or inserts at array indices (RFC 6902 behavior).
|
|
73
|
+
*
|
|
74
|
+
* This is a pure function that does not touch the filesystem.
|
|
75
|
+
*
|
|
76
|
+
* @param content - The raw configuration content
|
|
77
|
+
* @param path - Segment path to the target key (e.g. ["server", "port"])
|
|
78
|
+
* @param value - Value to set
|
|
79
|
+
* @param format - The configuration format
|
|
80
|
+
* @returns The updated configuration content
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* import { setContentValue } from "@powerduckie/confedit";
|
|
85
|
+
*
|
|
86
|
+
* const updated = setContentValue(
|
|
87
|
+
* "name: app\n",
|
|
88
|
+
* ["version"],
|
|
89
|
+
* "1.0.0",
|
|
90
|
+
* "yaml",
|
|
91
|
+
* );
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
declare function setContentValue(content: string, path: readonly (string | number)[], value: unknown, format: ConfigFormat): string;
|
|
95
|
+
/**
|
|
96
|
+
* Removes a key or array element from a configuration string.
|
|
97
|
+
*
|
|
98
|
+
* This is a pure function that does not touch the filesystem.
|
|
99
|
+
*
|
|
100
|
+
* @param content - The raw configuration content
|
|
101
|
+
* @param path - Segment path to remove
|
|
102
|
+
* @param format - The configuration format
|
|
103
|
+
* @returns The updated configuration content
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* import { deleteContentValue } from "@powerduckie/confedit";
|
|
108
|
+
*
|
|
109
|
+
* const updated = deleteContentValue(
|
|
110
|
+
* '{"name": "app", "legacy": true}',
|
|
111
|
+
* ["legacy"],
|
|
112
|
+
* "json",
|
|
113
|
+
* );
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
declare function deleteContentValue(content: string, path: readonly (string | number)[], format: ConfigFormat): string;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Asserts that a value is a non-empty string.
|
|
120
|
+
*/
|
|
121
|
+
declare function assertNonEmptyString(value: unknown, label: string): asserts value is string;
|
|
122
|
+
/**
|
|
123
|
+
* Asserts that a patch path is a non-empty array of valid segments.
|
|
124
|
+
*/
|
|
125
|
+
declare function assertPatchPath(path: readonly (string | number)[], label?: string): void;
|
|
126
|
+
/**
|
|
127
|
+
* Asserts that an array of patch operations is valid.
|
|
128
|
+
*/
|
|
129
|
+
declare function assertPatchOperations(ops: JsonPatchOp[]): void;
|
|
130
|
+
/**
|
|
131
|
+
* Asserts that a config format is supported.
|
|
132
|
+
*/
|
|
133
|
+
declare function assertConfigFormat(format: ConfigFormat): void;
|
|
134
|
+
/**
|
|
135
|
+
* Extracts a human-readable error message from an unknown error.
|
|
136
|
+
*/
|
|
137
|
+
declare function getErrorMessage(error: unknown): string;
|
|
138
|
+
/**
|
|
139
|
+
* Creates an Error with an optional cause, preserving compatibility with older runtimes.
|
|
140
|
+
*/
|
|
141
|
+
declare function createError(message: string, cause: unknown): Error;
|
|
142
|
+
|
|
143
|
+
export { type ConfigFormat as C, type FileLockOptions as F, type JsonPatchOp as J, type PatchConfigOptions as P, type JsonPathSegment as a, type PatchContentOptions as b, assertConfigFormat as c, assertNonEmptyString as d, assertPatchOperations as e, assertPatchPath as f, createError as g, deleteContentValue as h, getErrorMessage as i, patchContent as p, setContentValue as s };
|
package/dist/core.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { C as ConfigFormat, J as JsonPatchOp, a as JsonPathSegment, b as PatchContentOptions, c as assertConfigFormat, d as assertNonEmptyString, e as assertPatchOperations, f as assertPatchPath, g as createError, h as deleteContentValue, i as getErrorMessage, p as patchContent, s as setContentValue } from './core-CDrue4z-.mjs';
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { C as ConfigFormat, J as JsonPatchOp, a as JsonPathSegment, b as PatchContentOptions, c as assertConfigFormat, d as assertNonEmptyString, e as assertPatchOperations, f as assertPatchPath, g as createError, h as deleteContentValue, i as getErrorMessage, p as patchContent, s as setContentValue } from './core-CDrue4z-.js';
|
package/dist/core.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var t,e=Object.defineProperty,r=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyNames,n=Object.prototype.hasOwnProperty,a={};((t,r)=>{for(var o in r)e(t,o,{get:r[o],enumerable:!0})})(a,{assertConfigFormat:()=>assertConfigFormat,assertNonEmptyString:()=>assertNonEmptyString,assertPatchOperations:()=>assertPatchOperations,assertPatchPath:()=>assertPatchPath,createError:()=>createError2,deleteContentValue:()=>deleteContentValue,getErrorMessage:()=>getErrorMessage2,patchContent:()=>patchContent,setContentValue:()=>setContentValue}),module.exports=(t=a,((t,a,i,s)=>{if(a&&"object"==typeof a||"function"==typeof a)for(let p of o(a))n.call(t,p)||p===i||e(t,p,{get:()=>a[p],enumerable:!(s=r(a,p))||s.enumerable});return t})(e({},"__esModule",{value:!0}),t));var i=require("jsonc-parser"),s={insertSpaces:!0,tabSize:2,eol:"\n"};function validateOperation(t){if(null===t||"object"!=typeof t)throw new TypeError("Patch operation must be an object.");if(!Array.isArray(t.path)||0===t.path.length)throw new Error("Patch operation path must be a non-empty array.");if("add"!==t.op&&"replace"!==t.op&&"remove"!==t.op)throw new Error(`Unsupported JSON patch operation: ${String(t.op)}`);if(("add"===t.op||"replace"===t.op)&&!Object.prototype.hasOwnProperty.call(t,"value"))throw new Error(`Patch operation "${t.op}" requires a value.`)}function normalizePath(t){return t.map(t=>{if("number"==typeof t){if(!Number.isSafeInteger(t)||t<0)throw new Error(`Invalid array index: ${t}`);return t}if("string"!=typeof t)throw new TypeError(`Patch path segments must be strings or numbers; received ${typeof t}.`);return t})}function applyAdd(t,e,r){const o=function(t,e){if(1===e.length){if("object"!==t.type&&"array"!==t.type)throw new Error("Cannot add a root child to a scalar JSON value.");return t}const r=e.slice(0,-1),o=(0,i.findNodeAtLocation)(t,r);if(void 0===o)throw new Error(`Cannot add value because its parent does not exist at path: ${formatPath(r)}`);if("object"!==o.type&&"array"!==o.type)throw new Error(`Cannot add value because its parent is not an object or array at path: ${formatPath(r)}`);return o}(getTree(t),e),n=e[e.length-1];if("array"===o.type){const a=function(t,e){if("number"!=typeof t)throw new Error(`Array index must be a number at path: ${formatPath(e)}`);if(!Number.isSafeInteger(t)||t<0)throw new Error(`Invalid array index ${t} at path: ${formatPath(e)}`);return t}(n,e),i=o.children?.length??0;if(a>i)throw new Error(`Cannot add at array index ${a}; array length is ${i} at path: ${formatPath(e.slice(0,-1))}`);return applyModify(t,e,r,!0)}if("object"===o.type){if("string"!=typeof n)throw new Error(`Object property path segment must be a string at path: ${formatPath(e)}`);return applyModify(t,e,r,!1)}throw new Error(`Cannot add a child to non-container value at path: ${formatPath(e.slice(0,-1))}`)}function applyReplace(t,e,r){const o=getTree(t);if(void 0===(0,i.findNodeAtLocation)(o,e))throw new Error(`Cannot replace a value that does not exist at path: ${formatPath(e)}`);return applyModify(t,e,r,!1)}function applyRemove(t,e){const r=getTree(t);if(void 0===(0,i.findNodeAtLocation)(r,e))throw new Error(`Cannot remove a value that does not exist at path: ${formatPath(e)}`);return applyModify(t,e,void 0,!1)}function applyModify(t,e,r,o){const n=(0,i.modify)(t,e,r,{formattingOptions:s,isArrayInsertion:o});if(0===n.length)throw new Error(`No JSONC edit was generated for path: ${formatPath(e)}`);return(0,i.applyEdits)(t,n)}function getTree(t){const e=[],r=(0,i.parseTree)(t,e,{allowTrailingComma:!0,disallowComments:!1});if(void 0===r||e.length>0)throw new Error(`The source text is not valid JSON or JSONC${function(t){if(0===t.length)return"";return` (parse errors: ${t.map(t=>`code=${t.error}, offset=${t.offset}`).join("; ")})`}(e)}.`);return r}function formatPath(t){return`[${t.map(t=>JSON.stringify(t)).join(", ")}]`}function safeStringify(t){try{return JSON.stringify(t)}catch{return"[unserializable patch operation]"}}function getErrorMessage(t){return t instanceof Error?t.message:String(t)}function createError(t,e){const r=new Error(t);try{Object.defineProperty(r,"cause",{configurable:!0,enumerable:!1,value:e,writable:!0})}catch{}return r}var p=require("yaml");function applyYamlOperation(t,e){!function(t){if(!Array.isArray(t.path)||0===t.path.length)throw new Error("[confedit] Patch path must be a non-empty array.");if("add"!==t.op&&"replace"!==t.op&&"remove"!==t.op)throw new Error(`[confedit] Unsupported patch operation: ${String(t.op)}.`)}(e);const r=e.path,o=function(t,e){if(0===e.length)throw new Error("[confedit] Replacing the YAML document root is not supported.");if(1===e.length){if(null===t.contents)throw new Error("[confedit] Cannot patch an empty YAML document without a root container.");return t.contents}const r=e.slice(0,-1),o=t.getIn(r,!0);if(null==o)throw new Error(`[confedit] Missing parent path: ${formatPath2(r)}.`);if(!(0,p.isMap)(o)&&!(0,p.isSeq)(o))throw new Error(`[confedit] Parent at ${formatPath2(r)} is not a YAML map or sequence.`);return o}(t,r),n=r[r.length-1];if((0,p.isMap)(o))!function(t,e,r,o){if("string"!=typeof r)throw new Error(`[confedit] YAML map keys must be strings at ${formatPath2(o.path)}.`);const n=e.has(r);switch(o.op){case"add":return void e.set(r,t.createNode(o.value));case"replace":if(!n)throw new Error(`[confedit] Cannot replace missing value at ${formatPath2(o.path)}.`);return void e.set(r,t.createNode(o.value));case"remove":if(!n)throw new Error(`[confedit] Cannot remove missing value at ${formatPath2(o.path)}.`);return void e.delete(r);default:throw new Error(`[confedit] Unsupported patch operation: ${String(o.op)}.`)}}(t,o,n,e);else{if(!(0,p.isSeq)(o))throw new Error(`[confedit] Cannot apply patch at ${formatPath2(r)} because its parent is not a YAML map or sequence.`);!function(t,e,r,o){if("number"!=typeof r||!Number.isSafeInteger(r)||r<0)throw new Error(`[confedit] YAML sequence indexes must be non-negative integers at ${formatPath2(o.path)}.`);const n=e.items.length;switch(o.op){case"add":if(r>n)throw new Error(`[confedit] Cannot insert at index ${r}; sequence length is ${n}.`);return void e.items.splice(r,0,t.createNode(o.value));case"replace":if(r>=n)throw new Error(`[confedit] Cannot replace index ${r}; sequence length is ${n}.`);return void(e.items[r]=t.createNode(o.value));case"remove":if(r>=n)throw new Error(`[confedit] Cannot remove index ${r}; sequence length is ${n}.`);return void e.items.splice(r,1);default:throw new Error(`[confedit] Unsupported patch operation: ${String(o.op)}.`)}}(t,o,n,e)}}function formatPath2(t){return`[${t.map(t=>JSON.stringify(t)).join(", ")}]`}function assertNonEmptyString(t,e){if("string"!=typeof t||0===t.length)throw new TypeError(`[confedit] ${e} must be a non-empty string.`)}function assertPatchPath(t,e="path"){if(!Array.isArray(t)||0===t.length)throw new TypeError(`[confedit] ${e} must be a non-empty array.`);for(const[r,o]of t.entries())if("string"!=typeof o){if("number"!=typeof o||!Number.isSafeInteger(o)||o<0)throw new TypeError(`[confedit] ${e}[${r}] must be a non-empty string or a non-negative integer.`)}else if(0===o.length)throw new TypeError(`[confedit] ${e}[${r}] must not be an empty string.`)}function assertPatchOperations(t){if(!Array.isArray(t))throw new TypeError("[confedit] ops must be an array.");for(const[e,r]of t.entries()){if(null===r||"object"!=typeof r)throw new TypeError(`[confedit] Patch operation at index ${e} must be an object.`);if("add"!==r.op&&"replace"!==r.op&&"remove"!==r.op)throw new Error(`[confedit] Unsupported patch operation at index ${e}: ${String(r.op)}.`);if(assertPatchPath(r.path,`ops[${e}].path`),("add"===r.op||"replace"===r.op)&&!Object.prototype.hasOwnProperty.call(r,"value"))throw new TypeError(`[confedit] Patch operation at index ${e} requires a value.`)}}function assertConfigFormat(t){if("json"!==t&&"jsonc"!==t&&"yaml"!==t)throw new Error(`[confedit] Unsupported configuration format: ${String(t)}.`)}function getErrorMessage2(t){return t instanceof Error?t.message:String(t)}function createError2(t,e){const r=new Error(t);try{Object.defineProperty(r,"cause",{configurable:!0,enumerable:!1,value:e,writable:!0})}catch{}return r}function patchContent(t,e,r,o={}){if("string"!=typeof t)throw new TypeError("[confedit] content must be a string.");if(assertPatchOperations(e),assertConfigFormat(r),0===e.length)return t;const n=o.strict??!0;switch(r){case"json":case"jsonc":return function(t,e,r=!0){if("string"!=typeof t)throw new TypeError("[confedit] sourceText must be a string.");if(!Array.isArray(e))throw new TypeError("[confedit] ops must be an array.");let o=t;for(const t of e)try{validateOperation(t);const e=normalizePath(t.path);switch(t.op){case"add":o=applyAdd(o,e,t.value);break;case"replace":o=applyReplace(o,e,t.value);break;case"remove":o=applyRemove(o,e);break;default:throw new Error(`[confedit] Unsupported JSON patch operation: ${String(t.op)}`)}}catch(e){const o=`[confedit] Failed to apply JSON patch ${safeStringify(t)}: ${getErrorMessage(e)}`;if(r)throw createError(o,e);console.warn(o)}return o}(t,e,n);case"yaml":return function(t,e,r=!0){const o=(0,p.parseDocument)(t,{prettyErrors:!0,strict:!0});if(o.errors.length>0)throw new Error(`[confedit] Invalid YAML source: ${o.errors.map(t=>t.message).join("; ")}`);for(const t of e)try{applyYamlOperation(o,t)}catch(e){if(r)throw e;console.warn(`[confedit yaml patch warn] Skip operation ${JSON.stringify(t)}`,e)}return o.toString()}(t,e,n)}}function setContentValue(t,e,r,o){return assertPatchPath(e),patchContent(t,[{op:"add",path:[...e],value:r}],o)}function deleteContentValue(t,e,r){return assertPatchPath(e),patchContent(t,[{op:"remove",path:[...e]}],r)}
|
package/dist/core.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{assertConfigFormat as m,assertNonEmptyString as o,assertPatchOperations as r,assertPatchPath as p,createError as t,deleteContentValue as M,getErrorMessage as V,patchContent as Y,setContentValue as c}from"./chunk-YWMVVRYM.mjs";export{m as assertConfigFormat,o as assertNonEmptyString,r as assertPatchOperations,p as assertPatchPath,t as createError,M as deleteContentValue,V as getErrorMessage,Y as patchContent,c as setContentValue};
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { P as PatchConfigOptions, J as JsonPatchOp, C as ConfigFormat } from './core-CDrue4z-.mjs';
|
|
2
|
+
export { F as FileLockOptions, a as JsonPathSegment, b as PatchContentOptions, c as assertConfigFormat, d as assertNonEmptyString, e as assertPatchOperations, f as assertPatchPath, g as createError, h as deleteContentValue, i as getErrorMessage, p as patchContent, s as setContentValue } from './core-CDrue4z-.mjs';
|
|
3
|
+
import { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads UTF-8 text from a local file path or file URL.
|
|
7
|
+
*
|
|
8
|
+
* This function only works in Node.js / Electron environments.
|
|
9
|
+
* For browser environments, use the core `patchContent` / `setContentValue`
|
|
10
|
+
* functions directly with string content.
|
|
11
|
+
*
|
|
12
|
+
* @param filePath - Path to the configuration file (absolute, relative, or file:// URL)
|
|
13
|
+
* @returns The raw UTF-8 content of the file
|
|
14
|
+
* @throws If the file cannot be read
|
|
15
|
+
*/
|
|
16
|
+
declare function readConfigFile(filePath: string): Promise<string>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Options for writing a configuration file.
|
|
20
|
+
*/
|
|
21
|
+
interface WriteConfigOptions {
|
|
22
|
+
/** Enable file locking during the write. Default: true */
|
|
23
|
+
lock?: boolean;
|
|
24
|
+
/** Maximum time (ms) to wait to acquire a file lock. */
|
|
25
|
+
lockTimeoutMs?: number;
|
|
26
|
+
/** Delay (ms) between retry attempts to acquire a lock. */
|
|
27
|
+
lockRetryDelayMs?: number;
|
|
28
|
+
/** Duration (ms) after which a lock is considered stale. */
|
|
29
|
+
lockStaleThresholdMs?: number;
|
|
30
|
+
/** Whether stale locks can be automatically reclaimed. */
|
|
31
|
+
allowStaleRecovery?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Writes content to a configuration file using atomic writes and optional file locking.
|
|
35
|
+
*
|
|
36
|
+
* This function only works in Node.js / Electron environments.
|
|
37
|
+
* For browser environments, use the core `patchContent` / `setContentValue`
|
|
38
|
+
* functions and store the result in IndexedDB, localStorage, or another storage layer.
|
|
39
|
+
*
|
|
40
|
+
* @param filePath - Path to the configuration file
|
|
41
|
+
* @param content - The content to write
|
|
42
|
+
* @param options - Optional write settings
|
|
43
|
+
* @throws If the file cannot be written
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* import { writeConfigFile } from "@powerduckie/confedit";
|
|
48
|
+
*
|
|
49
|
+
* await writeConfigFile("config.json", '{"name": "app"}');
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
declare function writeConfigFile(filePath: string, content: string, options?: WriteConfigOptions): Promise<void>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Patches a JSON, JSONC, or YAML file using an atomic locked transaction.
|
|
56
|
+
*
|
|
57
|
+
* This function only works in Node.js / Electron environments.
|
|
58
|
+
* For browser environments, use `patchContent` directly and manage storage yourself.
|
|
59
|
+
*
|
|
60
|
+
* @param filePath - Path to the configuration file
|
|
61
|
+
* @param ops - Array of RFC 6902 patch operations
|
|
62
|
+
* @param options - Optional patch settings
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* import { patchConfigFile } from "@powerduckie/confedit";
|
|
67
|
+
*
|
|
68
|
+
* await patchConfigFile("config.json", [
|
|
69
|
+
* { op: "replace", path: ["server", "host"], value: "0.0.0.0" },
|
|
70
|
+
* { op: "add", path: ["server", "ssl"], value: true },
|
|
71
|
+
* { op: "remove", path: ["legacySection"] },
|
|
72
|
+
* ]);
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
declare function patchConfigFile(filePath: string, ops: JsonPatchOp[], options?: PatchConfigOptions): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Adds or replaces a nested configuration value in a file.
|
|
78
|
+
*
|
|
79
|
+
* Uses the `add` operation, which replaces existing object properties
|
|
80
|
+
* or inserts at array indices (RFC 6902 behavior).
|
|
81
|
+
*
|
|
82
|
+
* This function only works in Node.js / Electron environments.
|
|
83
|
+
*
|
|
84
|
+
* @param filePath - Path to the configuration file
|
|
85
|
+
* @param path - Segment path to the target key
|
|
86
|
+
* @param value - Value to set
|
|
87
|
+
* @param options - Optional patch settings
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```typescript
|
|
91
|
+
* import { setConfigValue } from "@powerduckie/confedit";
|
|
92
|
+
*
|
|
93
|
+
* await setConfigValue("config.yaml", ["database", "port"], 5432);
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
declare function setConfigValue(filePath: string, path: readonly (string | number)[], value: unknown, options?: PatchConfigOptions): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Removes an existing nested configuration value from a file.
|
|
99
|
+
*
|
|
100
|
+
* This function only works in Node.js / Electron environments.
|
|
101
|
+
*
|
|
102
|
+
* @param filePath - Path to the configuration file
|
|
103
|
+
* @param path - Segment path to remove
|
|
104
|
+
* @param options - Optional patch settings
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```typescript
|
|
108
|
+
* import { deleteConfigValue } from "@powerduckie/confedit";
|
|
109
|
+
*
|
|
110
|
+
* await deleteConfigValue("config.json", ["features", "betaPreview"]);
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function deleteConfigValue(filePath: string, path: readonly (string | number)[], options?: PatchConfigOptions): Promise<void>;
|
|
114
|
+
|
|
115
|
+
interface FileLockOptions {
|
|
116
|
+
/** Maximum time to wait for a lock. Defaults to 10 seconds. */
|
|
117
|
+
timeoutMs?: number;
|
|
118
|
+
/** Initial retry delay. Defaults to 25ms. */
|
|
119
|
+
retryDelayMs?: number;
|
|
120
|
+
/** Explicit lock age after which recovery is allowed. */
|
|
121
|
+
staleThresholdMs?: number;
|
|
122
|
+
/**
|
|
123
|
+
* Whether stale locks can be automatically reclaimed.
|
|
124
|
+
* Disable by default for Electron single main process to avoid live transaction preemption.
|
|
125
|
+
*/
|
|
126
|
+
allowStaleRecovery?: boolean;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Runs work under a process-local queue and an exclusive lock file.
|
|
130
|
+
*
|
|
131
|
+
* A stale lock is recovered only when allowStaleRecovery = true and the lock exceeds staleThresholdMs.
|
|
132
|
+
* PID checks are intentionally avoided: PIDs can be reused and cannot reliably identify the original process.
|
|
133
|
+
*/
|
|
134
|
+
declare function withFileLock<T>(filePath: string, callback: () => Promise<T>, options?: FileLockOptions): Promise<T>;
|
|
135
|
+
/**
|
|
136
|
+
* Release all locks owned by current process, designed for Electron app.will-quit
|
|
137
|
+
*/
|
|
138
|
+
declare function releaseAllLocalLocks(): Promise<void>;
|
|
139
|
+
|
|
140
|
+
/** A permissive structural type for OpenAPI 3.2 documents. */
|
|
141
|
+
interface OpenAPIV3_2Document {
|
|
142
|
+
openapi: string;
|
|
143
|
+
info: Record<string, unknown>;
|
|
144
|
+
paths?: Record<string, unknown>;
|
|
145
|
+
[key: string]: unknown;
|
|
146
|
+
}
|
|
147
|
+
/** Supported OpenAPI and Swagger document shapes. */
|
|
148
|
+
type AnyOpenAPIDocument = OpenAPIV2.Document | OpenAPIV3.Document | OpenAPIV3_1.Document | OpenAPIV3_2Document;
|
|
149
|
+
/** Selects how the supplied input string must be interpreted. */
|
|
150
|
+
type OpenApiInputKind = "content" | "file";
|
|
151
|
+
/** Options for secure OpenAPI parsing and validation. */
|
|
152
|
+
interface ValidateOpenApiOptions {
|
|
153
|
+
/** Explicitly selects whether input is raw content or a local file path. */
|
|
154
|
+
inputKind?: OpenApiInputKind;
|
|
155
|
+
/** Base file path used for reference resolution context. */
|
|
156
|
+
baseFilePath?: string;
|
|
157
|
+
/** Root directory that contains every allowed local file. */
|
|
158
|
+
allowedRootDirectory?: string;
|
|
159
|
+
/** Total operation deadline in milliseconds. */
|
|
160
|
+
timeoutMs?: number;
|
|
161
|
+
/** Maximum raw input size in bytes. */
|
|
162
|
+
maxInputBytes?: number;
|
|
163
|
+
/** Maximum nodes permitted in parsed documents. */
|
|
164
|
+
maxDocumentNodes?: number;
|
|
165
|
+
/** Maximum nested object or array depth permitted in documents. */
|
|
166
|
+
maxDocumentDepth?: number;
|
|
167
|
+
/** Maximum validation errors included in an error message. */
|
|
168
|
+
maxValidationErrors?: number;
|
|
169
|
+
/** Maximum characters included for each validation error. */
|
|
170
|
+
maxErrorMessageLength?: number;
|
|
171
|
+
/** Optional cancellation signal. */
|
|
172
|
+
signal?: AbortSignal;
|
|
173
|
+
}
|
|
174
|
+
/** A structured error safe to expose after mapping by an application boundary. */
|
|
175
|
+
declare class OpenApiValidationError extends Error {
|
|
176
|
+
readonly code: string;
|
|
177
|
+
readonly cause?: unknown;
|
|
178
|
+
constructor(code: string, message: string, cause?: unknown);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Validates a raw YAML or JSON OpenAPI document.
|
|
182
|
+
*
|
|
183
|
+
* Input is treated as content by default. File-path interpretation is only
|
|
184
|
+
* enabled by explicitly setting inputKind to "file".
|
|
185
|
+
*
|
|
186
|
+
* Validation is delegated to @powerduck/openapi-parser (which wraps
|
|
187
|
+
* @scalar/openapi-parser). This module adds secure input handling: size
|
|
188
|
+
* limits, path sandboxing, structural complexity guards, and deadlines.
|
|
189
|
+
*/
|
|
190
|
+
declare function validateOpenAPISpec(input: string, options?: ValidateOpenApiOptions): Promise<AnyOpenAPIDocument>;
|
|
191
|
+
/**
|
|
192
|
+
* Validates an OpenAPI document from an explicitly supplied local file path.
|
|
193
|
+
*/
|
|
194
|
+
declare function validateOpenAPIFile(filePath: string, options?: ValidateOpenApiOptions): Promise<AnyOpenAPIDocument>;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Detects a supported configuration format from a local file path or file URL.
|
|
198
|
+
*/
|
|
199
|
+
declare function detectFormat(filePath: string): ConfigFormat;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Converts a local path or file URL into an absolute native path.
|
|
203
|
+
* Keeps valid whitespace and Unicode file names unchanged.
|
|
204
|
+
*/
|
|
205
|
+
declare function normalizeFilePath(filePath: string): string;
|
|
206
|
+
|
|
207
|
+
export { type AnyOpenAPIDocument, ConfigFormat, JsonPatchOp, type OpenApiInputKind, OpenApiValidationError, PatchConfigOptions, type ValidateOpenApiOptions, type WriteConfigOptions, deleteConfigValue, detectFormat, normalizeFilePath, patchConfigFile, readConfigFile, releaseAllLocalLocks, setConfigValue, validateOpenAPIFile, validateOpenAPISpec, withFileLock, writeConfigFile };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { P as PatchConfigOptions, J as JsonPatchOp, C as ConfigFormat } from './core-CDrue4z-.js';
|
|
2
|
+
export { F as FileLockOptions, a as JsonPathSegment, b as PatchContentOptions, c as assertConfigFormat, d as assertNonEmptyString, e as assertPatchOperations, f as assertPatchPath, g as createError, h as deleteContentValue, i as getErrorMessage, p as patchContent, s as setContentValue } from './core-CDrue4z-.js';
|
|
3
|
+
import { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads UTF-8 text from a local file path or file URL.
|
|
7
|
+
*
|
|
8
|
+
* This function only works in Node.js / Electron environments.
|
|
9
|
+
* For browser environments, use the core `patchContent` / `setContentValue`
|
|
10
|
+
* functions directly with string content.
|
|
11
|
+
*
|
|
12
|
+
* @param filePath - Path to the configuration file (absolute, relative, or file:// URL)
|
|
13
|
+
* @returns The raw UTF-8 content of the file
|
|
14
|
+
* @throws If the file cannot be read
|
|
15
|
+
*/
|
|
16
|
+
declare function readConfigFile(filePath: string): Promise<string>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Options for writing a configuration file.
|
|
20
|
+
*/
|
|
21
|
+
interface WriteConfigOptions {
|
|
22
|
+
/** Enable file locking during the write. Default: true */
|
|
23
|
+
lock?: boolean;
|
|
24
|
+
/** Maximum time (ms) to wait to acquire a file lock. */
|
|
25
|
+
lockTimeoutMs?: number;
|
|
26
|
+
/** Delay (ms) between retry attempts to acquire a lock. */
|
|
27
|
+
lockRetryDelayMs?: number;
|
|
28
|
+
/** Duration (ms) after which a lock is considered stale. */
|
|
29
|
+
lockStaleThresholdMs?: number;
|
|
30
|
+
/** Whether stale locks can be automatically reclaimed. */
|
|
31
|
+
allowStaleRecovery?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Writes content to a configuration file using atomic writes and optional file locking.
|
|
35
|
+
*
|
|
36
|
+
* This function only works in Node.js / Electron environments.
|
|
37
|
+
* For browser environments, use the core `patchContent` / `setContentValue`
|
|
38
|
+
* functions and store the result in IndexedDB, localStorage, or another storage layer.
|
|
39
|
+
*
|
|
40
|
+
* @param filePath - Path to the configuration file
|
|
41
|
+
* @param content - The content to write
|
|
42
|
+
* @param options - Optional write settings
|
|
43
|
+
* @throws If the file cannot be written
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* import { writeConfigFile } from "@powerduckie/confedit";
|
|
48
|
+
*
|
|
49
|
+
* await writeConfigFile("config.json", '{"name": "app"}');
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
declare function writeConfigFile(filePath: string, content: string, options?: WriteConfigOptions): Promise<void>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Patches a JSON, JSONC, or YAML file using an atomic locked transaction.
|
|
56
|
+
*
|
|
57
|
+
* This function only works in Node.js / Electron environments.
|
|
58
|
+
* For browser environments, use `patchContent` directly and manage storage yourself.
|
|
59
|
+
*
|
|
60
|
+
* @param filePath - Path to the configuration file
|
|
61
|
+
* @param ops - Array of RFC 6902 patch operations
|
|
62
|
+
* @param options - Optional patch settings
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* import { patchConfigFile } from "@powerduckie/confedit";
|
|
67
|
+
*
|
|
68
|
+
* await patchConfigFile("config.json", [
|
|
69
|
+
* { op: "replace", path: ["server", "host"], value: "0.0.0.0" },
|
|
70
|
+
* { op: "add", path: ["server", "ssl"], value: true },
|
|
71
|
+
* { op: "remove", path: ["legacySection"] },
|
|
72
|
+
* ]);
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
declare function patchConfigFile(filePath: string, ops: JsonPatchOp[], options?: PatchConfigOptions): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Adds or replaces a nested configuration value in a file.
|
|
78
|
+
*
|
|
79
|
+
* Uses the `add` operation, which replaces existing object properties
|
|
80
|
+
* or inserts at array indices (RFC 6902 behavior).
|
|
81
|
+
*
|
|
82
|
+
* This function only works in Node.js / Electron environments.
|
|
83
|
+
*
|
|
84
|
+
* @param filePath - Path to the configuration file
|
|
85
|
+
* @param path - Segment path to the target key
|
|
86
|
+
* @param value - Value to set
|
|
87
|
+
* @param options - Optional patch settings
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```typescript
|
|
91
|
+
* import { setConfigValue } from "@powerduckie/confedit";
|
|
92
|
+
*
|
|
93
|
+
* await setConfigValue("config.yaml", ["database", "port"], 5432);
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
declare function setConfigValue(filePath: string, path: readonly (string | number)[], value: unknown, options?: PatchConfigOptions): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Removes an existing nested configuration value from a file.
|
|
99
|
+
*
|
|
100
|
+
* This function only works in Node.js / Electron environments.
|
|
101
|
+
*
|
|
102
|
+
* @param filePath - Path to the configuration file
|
|
103
|
+
* @param path - Segment path to remove
|
|
104
|
+
* @param options - Optional patch settings
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```typescript
|
|
108
|
+
* import { deleteConfigValue } from "@powerduckie/confedit";
|
|
109
|
+
*
|
|
110
|
+
* await deleteConfigValue("config.json", ["features", "betaPreview"]);
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function deleteConfigValue(filePath: string, path: readonly (string | number)[], options?: PatchConfigOptions): Promise<void>;
|
|
114
|
+
|
|
115
|
+
interface FileLockOptions {
|
|
116
|
+
/** Maximum time to wait for a lock. Defaults to 10 seconds. */
|
|
117
|
+
timeoutMs?: number;
|
|
118
|
+
/** Initial retry delay. Defaults to 25ms. */
|
|
119
|
+
retryDelayMs?: number;
|
|
120
|
+
/** Explicit lock age after which recovery is allowed. */
|
|
121
|
+
staleThresholdMs?: number;
|
|
122
|
+
/**
|
|
123
|
+
* Whether stale locks can be automatically reclaimed.
|
|
124
|
+
* Disable by default for Electron single main process to avoid live transaction preemption.
|
|
125
|
+
*/
|
|
126
|
+
allowStaleRecovery?: boolean;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Runs work under a process-local queue and an exclusive lock file.
|
|
130
|
+
*
|
|
131
|
+
* A stale lock is recovered only when allowStaleRecovery = true and the lock exceeds staleThresholdMs.
|
|
132
|
+
* PID checks are intentionally avoided: PIDs can be reused and cannot reliably identify the original process.
|
|
133
|
+
*/
|
|
134
|
+
declare function withFileLock<T>(filePath: string, callback: () => Promise<T>, options?: FileLockOptions): Promise<T>;
|
|
135
|
+
/**
|
|
136
|
+
* Release all locks owned by current process, designed for Electron app.will-quit
|
|
137
|
+
*/
|
|
138
|
+
declare function releaseAllLocalLocks(): Promise<void>;
|
|
139
|
+
|
|
140
|
+
/** A permissive structural type for OpenAPI 3.2 documents. */
|
|
141
|
+
interface OpenAPIV3_2Document {
|
|
142
|
+
openapi: string;
|
|
143
|
+
info: Record<string, unknown>;
|
|
144
|
+
paths?: Record<string, unknown>;
|
|
145
|
+
[key: string]: unknown;
|
|
146
|
+
}
|
|
147
|
+
/** Supported OpenAPI and Swagger document shapes. */
|
|
148
|
+
type AnyOpenAPIDocument = OpenAPIV2.Document | OpenAPIV3.Document | OpenAPIV3_1.Document | OpenAPIV3_2Document;
|
|
149
|
+
/** Selects how the supplied input string must be interpreted. */
|
|
150
|
+
type OpenApiInputKind = "content" | "file";
|
|
151
|
+
/** Options for secure OpenAPI parsing and validation. */
|
|
152
|
+
interface ValidateOpenApiOptions {
|
|
153
|
+
/** Explicitly selects whether input is raw content or a local file path. */
|
|
154
|
+
inputKind?: OpenApiInputKind;
|
|
155
|
+
/** Base file path used for reference resolution context. */
|
|
156
|
+
baseFilePath?: string;
|
|
157
|
+
/** Root directory that contains every allowed local file. */
|
|
158
|
+
allowedRootDirectory?: string;
|
|
159
|
+
/** Total operation deadline in milliseconds. */
|
|
160
|
+
timeoutMs?: number;
|
|
161
|
+
/** Maximum raw input size in bytes. */
|
|
162
|
+
maxInputBytes?: number;
|
|
163
|
+
/** Maximum nodes permitted in parsed documents. */
|
|
164
|
+
maxDocumentNodes?: number;
|
|
165
|
+
/** Maximum nested object or array depth permitted in documents. */
|
|
166
|
+
maxDocumentDepth?: number;
|
|
167
|
+
/** Maximum validation errors included in an error message. */
|
|
168
|
+
maxValidationErrors?: number;
|
|
169
|
+
/** Maximum characters included for each validation error. */
|
|
170
|
+
maxErrorMessageLength?: number;
|
|
171
|
+
/** Optional cancellation signal. */
|
|
172
|
+
signal?: AbortSignal;
|
|
173
|
+
}
|
|
174
|
+
/** A structured error safe to expose after mapping by an application boundary. */
|
|
175
|
+
declare class OpenApiValidationError extends Error {
|
|
176
|
+
readonly code: string;
|
|
177
|
+
readonly cause?: unknown;
|
|
178
|
+
constructor(code: string, message: string, cause?: unknown);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Validates a raw YAML or JSON OpenAPI document.
|
|
182
|
+
*
|
|
183
|
+
* Input is treated as content by default. File-path interpretation is only
|
|
184
|
+
* enabled by explicitly setting inputKind to "file".
|
|
185
|
+
*
|
|
186
|
+
* Validation is delegated to @powerduck/openapi-parser (which wraps
|
|
187
|
+
* @scalar/openapi-parser). This module adds secure input handling: size
|
|
188
|
+
* limits, path sandboxing, structural complexity guards, and deadlines.
|
|
189
|
+
*/
|
|
190
|
+
declare function validateOpenAPISpec(input: string, options?: ValidateOpenApiOptions): Promise<AnyOpenAPIDocument>;
|
|
191
|
+
/**
|
|
192
|
+
* Validates an OpenAPI document from an explicitly supplied local file path.
|
|
193
|
+
*/
|
|
194
|
+
declare function validateOpenAPIFile(filePath: string, options?: ValidateOpenApiOptions): Promise<AnyOpenAPIDocument>;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Detects a supported configuration format from a local file path or file URL.
|
|
198
|
+
*/
|
|
199
|
+
declare function detectFormat(filePath: string): ConfigFormat;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Converts a local path or file URL into an absolute native path.
|
|
203
|
+
* Keeps valid whitespace and Unicode file names unchanged.
|
|
204
|
+
*/
|
|
205
|
+
declare function normalizeFilePath(filePath: string): string;
|
|
206
|
+
|
|
207
|
+
export { type AnyOpenAPIDocument, ConfigFormat, JsonPatchOp, type OpenApiInputKind, OpenApiValidationError, PatchConfigOptions, type ValidateOpenApiOptions, type WriteConfigOptions, deleteConfigValue, detectFormat, normalizeFilePath, patchConfigFile, readConfigFile, releaseAllLocalLocks, setConfigValue, validateOpenAPIFile, validateOpenAPISpec, withFileLock, writeConfigFile };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.create;var e,t=Object.defineProperty,r=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyNames,n=(Object.getPrototypeOf,Object.prototype.hasOwnProperty),__copyProps=(e,a,i,s)=>{if(a&&"object"==typeof a||"function"==typeof a)for(let c of o(a))n.call(e,c)||c===i||t(e,c,{get:()=>a[c],enumerable:!(s=r(a,c))||s.enumerable});return e},a={};((e,r)=>{for(var o in r)t(e,o,{get:r[o],enumerable:!0})})(a,{OpenApiValidationError:()=>I,assertConfigFormat:()=>assertConfigFormat,assertNonEmptyString:()=>assertNonEmptyString,assertPatchOperations:()=>assertPatchOperations,assertPatchPath:()=>assertPatchPath,createError:()=>createError2,deleteConfigValue:()=>deleteConfigValue,deleteContentValue:()=>deleteContentValue,detectFormat:()=>detectFormat,getErrorMessage:()=>getErrorMessage2,normalizeFilePath:()=>normalizeFilePath,patchConfigFile:()=>patchConfigFile,patchContent:()=>patchContent,readConfigFile:()=>readConfigFile,releaseAllLocalLocks:()=>releaseAllLocalLocks,setConfigValue:()=>setConfigValue,setContentValue:()=>setContentValue,validateOpenAPIFile:()=>validateOpenAPIFile,validateOpenAPISpec:()=>validateOpenAPISpec,withFileLock:()=>withFileLock,writeConfigFile:()=>writeConfigFile}),module.exports=(e=a,__copyProps(t({},"__esModule",{value:!0}),e));var i=require("jsonc-parser"),s={insertSpaces:!0,tabSize:2,eol:"\n"};function validateOperation(e){if(null===e||"object"!=typeof e)throw new TypeError("Patch operation must be an object.");if(!Array.isArray(e.path)||0===e.path.length)throw new Error("Patch operation path must be a non-empty array.");if("add"!==e.op&&"replace"!==e.op&&"remove"!==e.op)throw new Error(`Unsupported JSON patch operation: ${String(e.op)}`);if(("add"===e.op||"replace"===e.op)&&!Object.prototype.hasOwnProperty.call(e,"value"))throw new Error(`Patch operation "${e.op}" requires a value.`)}function normalizePath(e){return e.map(e=>{if("number"==typeof e){if(!Number.isSafeInteger(e)||e<0)throw new Error(`Invalid array index: ${e}`);return e}if("string"!=typeof e)throw new TypeError(`Patch path segments must be strings or numbers; received ${typeof e}.`);return e})}function applyAdd(e,t,r){const o=function(e,t){if(1===t.length){if("object"!==e.type&&"array"!==e.type)throw new Error("Cannot add a root child to a scalar JSON value.");return e}const r=t.slice(0,-1),o=(0,i.findNodeAtLocation)(e,r);if(void 0===o)throw new Error(`Cannot add value because its parent does not exist at path: ${formatPath(r)}`);if("object"!==o.type&&"array"!==o.type)throw new Error(`Cannot add value because its parent is not an object or array at path: ${formatPath(r)}`);return o}(getTree(e),t),n=t[t.length-1];if("array"===o.type){const a=function(e,t){if("number"!=typeof e)throw new Error(`Array index must be a number at path: ${formatPath(t)}`);if(!Number.isSafeInteger(e)||e<0)throw new Error(`Invalid array index ${e} at path: ${formatPath(t)}`);return e}(n,t),i=o.children?.length??0;if(a>i)throw new Error(`Cannot add at array index ${a}; array length is ${i} at path: ${formatPath(t.slice(0,-1))}`);return applyModify(e,t,r,!0)}if("object"===o.type){if("string"!=typeof n)throw new Error(`Object property path segment must be a string at path: ${formatPath(t)}`);return applyModify(e,t,r,!1)}throw new Error(`Cannot add a child to non-container value at path: ${formatPath(t.slice(0,-1))}`)}function applyReplace(e,t,r){const o=getTree(e);if(void 0===(0,i.findNodeAtLocation)(o,t))throw new Error(`Cannot replace a value that does not exist at path: ${formatPath(t)}`);return applyModify(e,t,r,!1)}function applyRemove(e,t){const r=getTree(e);if(void 0===(0,i.findNodeAtLocation)(r,t))throw new Error(`Cannot remove a value that does not exist at path: ${formatPath(t)}`);return applyModify(e,t,void 0,!1)}function applyModify(e,t,r,o){const n=(0,i.modify)(e,t,r,{formattingOptions:s,isArrayInsertion:o});if(0===n.length)throw new Error(`No JSONC edit was generated for path: ${formatPath(t)}`);return(0,i.applyEdits)(e,n)}function getTree(e){const t=[],r=(0,i.parseTree)(e,t,{allowTrailingComma:!0,disallowComments:!1});if(void 0===r||t.length>0)throw new Error(`The source text is not valid JSON or JSONC${function(e){if(0===e.length)return"";return` (parse errors: ${e.map(e=>`code=${e.error}, offset=${e.offset}`).join("; ")})`}(t)}.`);return r}function formatPath(e){return`[${e.map(e=>JSON.stringify(e)).join(", ")}]`}function safeStringify(e){try{return JSON.stringify(e)}catch{return"[unserializable patch operation]"}}function getErrorMessage(e){return e instanceof Error?e.message:String(e)}function createError(e,t){const r=new Error(e);try{Object.defineProperty(r,"cause",{configurable:!0,enumerable:!1,value:t,writable:!0})}catch{}return r}var c=require("yaml");function applyYamlOperation(e,t){!function(e){if(!Array.isArray(e.path)||0===e.path.length)throw new Error("[confedit] Patch path must be a non-empty array.");if("add"!==e.op&&"replace"!==e.op&&"remove"!==e.op)throw new Error(`[confedit] Unsupported patch operation: ${String(e.op)}.`)}(t);const r=t.path,o=function(e,t){if(0===t.length)throw new Error("[confedit] Replacing the YAML document root is not supported.");if(1===t.length){if(null===e.contents)throw new Error("[confedit] Cannot patch an empty YAML document without a root container.");return e.contents}const r=t.slice(0,-1),o=e.getIn(r,!0);if(null==o)throw new Error(`[confedit] Missing parent path: ${formatPath2(r)}.`);if(!(0,c.isMap)(o)&&!(0,c.isSeq)(o))throw new Error(`[confedit] Parent at ${formatPath2(r)} is not a YAML map or sequence.`);return o}(e,r),n=r[r.length-1];if((0,c.isMap)(o))!function(e,t,r,o){if("string"!=typeof r)throw new Error(`[confedit] YAML map keys must be strings at ${formatPath2(o.path)}.`);const n=t.has(r);switch(o.op){case"add":return void t.set(r,e.createNode(o.value));case"replace":if(!n)throw new Error(`[confedit] Cannot replace missing value at ${formatPath2(o.path)}.`);return void t.set(r,e.createNode(o.value));case"remove":if(!n)throw new Error(`[confedit] Cannot remove missing value at ${formatPath2(o.path)}.`);return void t.delete(r);default:throw new Error(`[confedit] Unsupported patch operation: ${String(o.op)}.`)}}(e,o,n,t);else{if(!(0,c.isSeq)(o))throw new Error(`[confedit] Cannot apply patch at ${formatPath2(r)} because its parent is not a YAML map or sequence.`);!function(e,t,r,o){if("number"!=typeof r||!Number.isSafeInteger(r)||r<0)throw new Error(`[confedit] YAML sequence indexes must be non-negative integers at ${formatPath2(o.path)}.`);const n=t.items.length;switch(o.op){case"add":if(r>n)throw new Error(`[confedit] Cannot insert at index ${r}; sequence length is ${n}.`);return void t.items.splice(r,0,e.createNode(o.value));case"replace":if(r>=n)throw new Error(`[confedit] Cannot replace index ${r}; sequence length is ${n}.`);return void(t.items[r]=e.createNode(o.value));case"remove":if(r>=n)throw new Error(`[confedit] Cannot remove index ${r}; sequence length is ${n}.`);return void t.items.splice(r,1);default:throw new Error(`[confedit] Unsupported patch operation: ${String(o.op)}.`)}}(e,o,n,t)}}function formatPath2(e){return`[${e.map(e=>JSON.stringify(e)).join(", ")}]`}function assertNonEmptyString(e,t){if("string"!=typeof e||0===e.length)throw new TypeError(`[confedit] ${t} must be a non-empty string.`)}function assertPatchPath(e,t="path"){if(!Array.isArray(e)||0===e.length)throw new TypeError(`[confedit] ${t} must be a non-empty array.`);for(const[r,o]of e.entries())if("string"!=typeof o){if("number"!=typeof o||!Number.isSafeInteger(o)||o<0)throw new TypeError(`[confedit] ${t}[${r}] must be a non-empty string or a non-negative integer.`)}else if(0===o.length)throw new TypeError(`[confedit] ${t}[${r}] must not be an empty string.`)}function assertPatchOperations(e){if(!Array.isArray(e))throw new TypeError("[confedit] ops must be an array.");for(const[t,r]of e.entries()){if(null===r||"object"!=typeof r)throw new TypeError(`[confedit] Patch operation at index ${t} must be an object.`);if("add"!==r.op&&"replace"!==r.op&&"remove"!==r.op)throw new Error(`[confedit] Unsupported patch operation at index ${t}: ${String(r.op)}.`);if(assertPatchPath(r.path,`ops[${t}].path`),("add"===r.op||"replace"===r.op)&&!Object.prototype.hasOwnProperty.call(r,"value"))throw new TypeError(`[confedit] Patch operation at index ${t} requires a value.`)}}function assertConfigFormat(e){if("json"!==e&&"jsonc"!==e&&"yaml"!==e)throw new Error(`[confedit] Unsupported configuration format: ${String(e)}.`)}function getErrorMessage2(e){return e instanceof Error?e.message:String(e)}function createError2(e,t){const r=new Error(e);try{Object.defineProperty(r,"cause",{configurable:!0,enumerable:!1,value:t,writable:!0})}catch{}return r}function patchContent(e,t,r,o={}){if("string"!=typeof e)throw new TypeError("[confedit] content must be a string.");if(assertPatchOperations(t),assertConfigFormat(r),0===t.length)return e;const n=o.strict??!0;switch(r){case"json":case"jsonc":return function(e,t,r=!0){if("string"!=typeof e)throw new TypeError("[confedit] sourceText must be a string.");if(!Array.isArray(t))throw new TypeError("[confedit] ops must be an array.");let o=e;for(const e of t)try{validateOperation(e);const t=normalizePath(e.path);switch(e.op){case"add":o=applyAdd(o,t,e.value);break;case"replace":o=applyReplace(o,t,e.value);break;case"remove":o=applyRemove(o,t);break;default:throw new Error(`[confedit] Unsupported JSON patch operation: ${String(e.op)}`)}}catch(t){const o=`[confedit] Failed to apply JSON patch ${safeStringify(e)}: ${getErrorMessage(t)}`;if(r)throw createError(o,t);console.warn(o)}return o}(e,t,n);case"yaml":return function(e,t,r=!0){const o=(0,c.parseDocument)(e,{prettyErrors:!0,strict:!0});if(o.errors.length>0)throw new Error(`[confedit] Invalid YAML source: ${o.errors.map(e=>e.message).join("; ")}`);for(const e of t)try{applyYamlOperation(o,e)}catch(t){if(r)throw t;console.warn(`[confedit yaml patch warn] Skip operation ${JSON.stringify(e)}`,t)}return o.toString()}(e,t,n)}}function setContentValue(e,t,r,o){return assertPatchPath(t),patchContent(e,[{op:"add",path:[...t],value:r}],o)}function deleteContentValue(e,t,r){return assertPatchPath(t),patchContent(e,[{op:"remove",path:[...t]}],r)}var l=require("fs/promises"),u=require("path"),f=require("url");function normalizeFilePath(e){if("string"!=typeof e||0===e.length)throw new TypeError("[confedit] filePath must be a non-empty string.");if(e.startsWith("file:"))try{return(0,f.fileURLToPath)(e)}catch(t){throw function(e,t){const r=new Error(e);try{Object.defineProperty(r,"cause",{configurable:!0,enumerable:!1,value:t,writable:!0})}catch{}return r}(`[confedit] Invalid file URL "${e}": ${function(e){return e instanceof Error?e.message:String(e)}(t)}`,t)}return(0,u.isAbsolute)(e)?e:(0,u.resolve)(e)}async function readConfigFile(e){assertNonEmptyString(e,"filePath");const t=normalizeFilePath(e);try{return await(0,l.readFile)(t,"utf8")}catch(t){throw createError2(`[confedit] Failed to read "${e}": ${getErrorMessage2(t)}`,t)}}var p=require("fs/promises"),h=require("path"),d=require("crypto"),m=require("fs/promises"),w=require("path");async function atomicWrite(e,t){if(function(e,t){if("string"!=typeof e||0===e.length)throw new TypeError(`[confedit] ${t} must be a non-empty string.`)}(e,"filePath"),"string"!=typeof t)throw new TypeError("[confedit] content must be a string.");const r=normalizeFilePath(e),o=(0,w.dirname)(r),n=(0,w.basename)(r),a=`${process.pid}.${Date.now()}.${(0,d.randomBytes)(12).toString("hex")}`,i=(0,w.join)(o,`.${n}.${a}.tmp`),s=(0,w.join)(o,`.${n}.${a}.bak`);let c,l=!1,u=!1;try{await(0,m.mkdir)(o,{recursive:!0});const e=await async function(e){try{return 511&(await(0,m.stat)(e)).mode}catch(e){if("ENOENT"===getErrorCode(e))return;throw e}}(r);c=await(0,m.open)(i,"wx",e??384);try{await c.writeFile(t,"utf8"),await c.sync()}finally{await c.close(),c=void 0}void 0!==e&&await(0,m.chmod)(i,e);try{await(0,m.rename)(i,r),u=!0}catch(e){if(!function(e){if("win32"!==process.platform)return!1;const t=getErrorCode(e);return"EEXIST"===t||"EPERM"===t||"EACCES"===t}(e))throw e;await(0,m.rename)(r,s),l=!0;try{await(0,m.rename)(i,r),u=!0}catch(e){const t=await async function(e,t){try{return await(0,m.unlink)(e).catch(e=>{if("ENOENT"!==getErrorCode(e))throw e}),await(0,m.rename)(t,e),!0}catch{return!1}}(r,s);throw t&&(l=!1),createError4(t?`[confedit] Failed to replace "${r}", but the original file was restored.`:`[confedit] Failed to replace "${r}". The backup was retained at "${s}".`,e)}}await syncDirectory(o),l&&(await(0,m.unlink)(s),l=!1,await syncDirectory(o))}catch(t){throw void 0!==c&&await c.close().catch(()=>{}),createError4(`[confedit] Failed to atomically write "${e}": ${function(e){return e instanceof Error?e.message:String(e)}(t)}`,t)}finally{await(0,m.unlink)(i).catch(()=>{}),u&&l&&await(0,m.unlink)(s).catch(()=>{})}}async function syncDirectory(e){let t;try{t=await(0,m.open)(e,"r"),await t.sync()}catch(e){const t=getErrorCode(e);if("EINVAL"!==t&&"EPERM"!==t&&"EISDIR"!==t&&"ENOSYS"!==t&&"ENOTSUP"!==t)throw e}finally{await(t?.close().catch(()=>{}))}}function getErrorCode(e){if(null!==e&&"object"==typeof e&&"code"in e&&"string"==typeof e.code)return e.code}function createError4(e,t){const r=new Error(e);try{Object.defineProperty(r,"cause",{configurable:!0,enumerable:!1,value:t,writable:!0})}catch{}return r}var y=require("crypto"),g=require("fs/promises"),E=require("os"),b=1e4,v=25,P=1e3,O=6e4,$=new Map,M=new Set;async function withFileLock(e,t,r={}){const o=normalizeFilePath(e);return function(e){if(void 0!==e.timeoutMs&&(!Number.isFinite(e.timeoutMs)||e.timeoutMs<0))throw new TypeError("[confedit] lock timeoutMs must be a non-negative finite number.");if(void 0!==e.retryDelayMs&&(!Number.isFinite(e.retryDelayMs)||e.retryDelayMs<=0))throw new TypeError("[confedit] lock retryDelayMs must be a positive finite number.");if(void 0!==e.staleThresholdMs&&(!Number.isFinite(e.staleThresholdMs)||e.staleThresholdMs<=0))throw new TypeError("[confedit] lock staleThresholdMs must be a positive finite number.")}(r),function(e,t){const r=$.get(e)??Promise.resolve();let o;const n=new Promise(e=>{o=e}),a=r.catch(()=>{}).then(()=>n);return $.set(e,a),r.catch(()=>{}).then(t).finally(()=>{o(),$.get(e)===a&&$.delete(e)})}(o,async()=>{const e=await async function(e,t){const r=t.timeoutMs??b,o=t.retryDelayMs??v,n=t.staleThresholdMs??Math.max(2*r,O),a=t.allowStaleRecovery??!1,i=`${e}.confedit.lock`,s=Date.now();let c=o;const l=createToken(),u={version:1,pid:process.pid,hostname:(0,E.hostname)(),createdAt:(new Date).toISOString(),token:l};for(;;)try{const e=await(0,g.open)(i,"wx",384);try{await(0,g.writeFile)(e,`${JSON.stringify(u)}\n`,"utf8"),await e.sync()}finally{await e.close()}return M.add(i),createReleaseHandler(i,l)}catch(t){if("EEXIST"!==getErrorCode2(t))throw createError5(`[confedit] Failed to acquire lock "${i}": ${getErrorMessage5(t)}`,t);if(a&&await tryRecoverStaleLock(i,n),Date.now()-s>=r)throw new Error(`[confedit] Timed out waiting for lock on "${e}" after ${r}ms.`);await sleep(c),c=Math.min(Math.ceil(1.5*c),P)}}(o,r);try{return await t()}finally{await e()}})}async function releaseAllLocalLocks(){const e=Array.from(M).map(e=>(0,g.unlink)(e).catch(()=>{}));await Promise.allSettled(e),M.clear()}function createReleaseHandler(e,t){return async()=>{try{const r=await readLockData(e);r?.token===t&&(await(0,g.unlink)(e).catch(()=>{}),M.delete(e))}catch{}}}async function tryRecoverStaleLock(e,t){const r=await readLockData(e);if(void 0===r||!isExpiredLock(r,t))return;const o=`${e}.stale.${createToken()}`;try{await(0,g.rename)(e,o)}catch(e){return void getErrorCode2(e)}try{const n=await readLockData(o);if(n?.token===r.token&&isExpiredLock(n,t))return void await(0,g.unlink)(o).catch(()=>{});await(0,g.rename)(o,e).catch(()=>{})}catch{}}function isExpiredLock(e,t){const r=Date.parse(e.createdAt);return Number.isFinite(r)&&Date.now()-r>t}async function readLockData(e){try{return function(e){try{const t=JSON.parse(e);if(1!==t.version||"number"!=typeof t.pid||!Number.isSafeInteger(t.pid)||t.pid<=0||"string"!=typeof t.hostname||"string"!=typeof t.createdAt||!Number.isFinite(Date.parse(t.createdAt))||"string"!=typeof t.token||t.token.length<16)return;return{version:1,pid:t.pid,hostname:t.hostname,createdAt:t.createdAt,token:t.token}}catch{return}}(await(0,g.readFile)(e,"utf8"))}catch{return}}function createToken(){return`${process.pid}-${Date.now()}-${(0,y.randomBytes)(16).toString("hex")}`}function sleep(e){return new Promise(t=>setTimeout(t,e))}function getErrorCode2(e){if(null!==e&&"object"==typeof e&&"code"in e&&"string"==typeof e.code)return e.code}function getErrorMessage5(e){return e instanceof Error?e.message:String(e)}function createError5(e,t){const r=new Error(e);try{Object.defineProperty(r,"cause",{configurable:!0,enumerable:!1,value:t,writable:!0})}catch{}return r}async function writeConfigFile(e,t,r={}){if(assertNonEmptyString(e,"filePath"),"string"!=typeof t)throw new TypeError("[confedit] content must be a string.");const o=normalizeFilePath(e),writeTransaction=async()=>{try{await atomicWrite(o,t)}catch(t){throw createError2(`[confedit] Failed to write "${e}": ${getErrorMessage2(t)}`,t)}};r.lock??!0?(await(0,p.mkdir)((0,h.dirname)(o),{recursive:!0}),await withFileLock(o,writeTransaction,{timeoutMs:r.lockTimeoutMs,retryDelayMs:r.lockRetryDelayMs,staleThresholdMs:r.lockStaleThresholdMs,allowStaleRecovery:r.allowStaleRecovery})):await writeTransaction()}var T=require("path");function detectFormat(e){const t=normalizeFilePath(e);switch((0,T.extname)(t).toLowerCase()){case".json":return"json";case".jsonc":return"jsonc";case".yaml":case".yml":return"yaml";default:throw new Error(`[confedit] Cannot detect a supported configuration format for "${e}". Expected .json, .jsonc, .yaml, or .yml.`)}}async function patchConfigFile(e,t,r={}){if("string"!=typeof e||0===e.length)throw new TypeError("[confedit] filePath must be a non-empty string.");if(assertPatchOperations(t),0===t.length)return;const o=normalizeFilePath(e),n=r.format??detectFormat(o);assertConfigFormat(n),function(e){if(void 0!==e.lockTimeoutMs&&(!Number.isFinite(e.lockTimeoutMs)||e.lockTimeoutMs<0))throw new TypeError("[confedit] lockTimeoutMs must be a non-negative finite number.");if(void 0!==e.lockRetryDelayMs&&(!Number.isFinite(e.lockRetryDelayMs)||e.lockRetryDelayMs<=0))throw new TypeError("[confedit] lockRetryDelayMs must be a positive finite number.");if(void 0!==e.lockStaleThresholdMs&&(!Number.isFinite(e.lockStaleThresholdMs)||e.lockStaleThresholdMs<=0))throw new TypeError("[confedit] lockStaleThresholdMs must be a positive finite number.")}(r);const a=r.strict??!0,patchTransaction=async()=>{const r=await readConfigFile(o),i=patchContent(r,t,n,{strict:a});if(i!==r)try{await atomicWrite(o,i)}catch(t){throw createError2(`[confedit] Failed to write "${e}": ${getErrorMessage2(t)}`,t)}};r.lock??!0?await withFileLock(o,patchTransaction,{timeoutMs:r.lockTimeoutMs,retryDelayMs:r.lockRetryDelayMs,staleThresholdMs:r.lockStaleThresholdMs,allowStaleRecovery:r.allowStaleRecovery}):await patchTransaction()}async function setConfigValue(e,t,r,o={}){await patchConfigFile(e,[{op:"add",path:[...t],value:r}],o)}async function deleteConfigValue(e,t,r={}){await patchConfigFile(e,[{op:"remove",path:[...t]}],r)}var D,N=require("fs/promises"),S=require("path"),A=require("yaml"),I=class extends Error{constructor(e,t,r){super(t),this.name="OpenApiValidationError",this.code=e,this.cause=r}},F={inputKind:"content",timeoutMs:15e3,maxInputBytes:5242880,maxDocumentNodes:1e5,maxDocumentDepth:100,maxValidationErrors:50,maxErrorMessageLength:1e3};async function validateOpenAPISpec(e,t={}){!function(e,t){if("string"!=typeof e||0===e.length)throw new TypeError(`[confedit] ${t} must be a non-empty string.`)}(e,"input");const r=function(e){const t={inputKind:e.inputKind??F.inputKind,baseFilePath:e.baseFilePath,allowedRootDirectory:e.allowedRootDirectory,timeoutMs:e.timeoutMs??F.timeoutMs,maxInputBytes:e.maxInputBytes??F.maxInputBytes,maxDocumentNodes:e.maxDocumentNodes??F.maxDocumentNodes,maxDocumentDepth:e.maxDocumentDepth??F.maxDocumentDepth,maxValidationErrors:e.maxValidationErrors??F.maxValidationErrors,maxErrorMessageLength:e.maxErrorMessageLength??F.maxErrorMessageLength,signal:e.signal};if("content"!==t.inputKind&&"file"!==t.inputKind)throw new I("INVALID_OPTION",`inputKind must be "content" or "file", got: ${String(t.inputKind)}.`);return assertPositiveFiniteNumber(t.timeoutMs,"timeoutMs"),assertPositiveFiniteNumber(t.maxInputBytes,"maxInputBytes"),assertPositiveFiniteNumber(t.maxDocumentNodes,"maxDocumentNodes"),assertPositiveFiniteNumber(t.maxDocumentDepth,"maxDocumentDepth"),assertPositiveFiniteNumber(t.maxValidationErrors,"maxValidationErrors"),assertPositiveFiniteNumber(t.maxErrorMessageLength,"maxErrorMessageLength"),t}(t),o=function(e){const t=new AbortController;if(e){if(!e.aborted){const onAbort=()=>t.abort();return e.addEventListener("abort",onAbort,{once:!0}),{controller:t,dispose:()=>e.removeEventListener("abort",onAbort)}}t.abort()}return{controller:t,dispose:()=>{}}}(r.signal),n={options:r,controller:o.controller,deadline:Date.now()+r.timeoutMs};try{return await runWithDeadline(n,async()=>{if("file"===r.inputKind){const t=await async function(e,t){const r=normalizeFilePath(e);if(!t.options.allowedRootDirectory)throw new I("FILE_INPUT_FORBIDDEN","File input requires allowedRootDirectory.");return async function(e,t){const r=(0,S.isAbsolute)(e)?e:(0,S.resolve)(t,e),o=await(0,N.realpath)(r),n=(0,S.relative)(t,o);if(n.startsWith("..")||""===n||"\\"===S.sep&&/^[a-zA-Z]:/.test(n))throw new I("PATH_OUTSIDE_ROOT",`File path is outside the allowed root directory: ${e}.`);return o}(r,await async function(e){if(e.rootDirectory)return e.rootDirectory;const t=e.options.allowedRootDirectory;if(!t)throw new I("INVALID_OPTION","allowedRootDirectory is required for file operations.");const r=(0,S.resolve)(t),o=await(0,N.realpath)(r);if(!(await(0,N.stat)(o)).isDirectory())throw new I("INVALID_OPTION",`allowedRootDirectory is not a directory: ${t}.`);return e.rootDirectory=o,o}(t))}(e,n),o=await async function(e,t,r,o){throwIfCancelled(r);const n=await(0,N.open)(e,"r");try{const r=await n.stat();if(r.size>t)throw new I(o,`File exceeds maximum allowed size of ${t} bytes: ${e}.`);const a=Buffer.alloc(r.size);return await n.read(a,0,r.size,0),a.toString("utf8")}finally{await n.close()}}(t,r.maxInputBytes,n,"INPUT_FILE_TOO_LARGE");return validateRawContent(o,{...n,options:{...r,baseFilePath:t}})}return assertByteLength(e,r.maxInputBytes,"INPUT_TOO_LARGE"),validateRawContent(e,n)})}catch(e){throw function(e){if(e instanceof I)return e;return new I("UNKNOWN_ERROR",`OpenAPI validation failed: ${getErrorMessage6(e)}.`,e)}(e)}finally{o.dispose(),n.controller.abort()}}async function validateOpenAPIFile(e,t={}){return validateOpenAPISpec(e,{...t,inputKind:"file",baseFilePath:e})}async function validateRawContent(e,t){throwIfCancelled(t),assertByteLength(e,t.options.maxInputBytes,"INPUT_TOO_LARGE");const r=function(e){try{const t=(0,A.parse)(e);if(null===t||"object"!=typeof t||Array.isArray(t))throw new I("INVALID_DOCUMENT_SHAPE","OpenAPI document must be a JSON object.");return t}catch(e){if(e instanceof I)throw e;throw new I("PARSE_ERROR",`Failed to parse OpenAPI document: ${getErrorMessage6(e)}.`,e)}}(e);!function(e,t){let r=0;const o=[{value:e,depth:0}];for(;o.length>0;){const{value:e,depth:n}=o.pop();if(n>t.maxDocumentDepth)throw new I("DOCUMENT_TOO_DEEP",`Document exceeds maximum nesting depth of ${t.maxDocumentDepth}.`);if(r++,r>t.maxDocumentNodes)throw new I("DOCUMENT_TOO_LARGE",`Document exceeds maximum node count of ${t.maxDocumentNodes}.`);if(Array.isArray(e))for(const t of e)o.push({value:t,depth:n+1});else if(null!==e&&"object"==typeof e)for(const t of Object.values(e))o.push({value:t,depth:n+1})}}(r,t.options),function(e){const t=e.openapi??e.swagger;if("string"!=typeof t||0===t.length)throw new I("UNSUPPORTED_VERSION","Document must declare an openapi or swagger version.");const r=Number.parseInt(t.split(".")[0]??"",10);if(!Number.isFinite(r)||2!==r&&3!==r)throw new I("UNSUPPORTED_VERSION",`Unsupported OpenAPI/Swagger version: ${t}. Supported: 2.x, 3.x.`)}(r);const o=await runWithDeadline(t,()=>async function(){return D??=import("@powerduck/openapi-parser").then(({validate:e})=>e),D}()),n=await runWithDeadline(t,()=>o(r,{throwOnError:!1}));if(!n.valid)throw new I("SPEC_VALIDATION_FAILED",`OpenAPI validation failed: ${function(e,t,r){const o=e.slice(0,t).map(e=>{const t=e.instancePath??"",o=e.message??"Unknown error",n=t?`${t}: ${o}`:o;return n.length>r?`${n.slice(0,r)}...`:n});e.length>t&&o.push(`... and ${e.length-t} more errors`);return o.join("; ")}(n.errors??[],t.options.maxValidationErrors,t.options.maxErrorMessageLength)}`);return n.specification}function assertPositiveFiniteNumber(e,t){if(!Number.isFinite(e)||e<=0)throw new I("INVALID_OPTION",`${t} must be a positive finite number, got: ${String(e)}.`)}async function runWithDeadline(e,t){throwIfCancelled(e);const r=await Promise.race([t(),createDeadlinePromise(e)]);return throwIfCancelled(e),r}function createDeadlinePromise(e){return new Promise((t,r)=>{const o=e.deadline-Date.now(),n=Math.max(0,Math.min(o,2147483647)),a=setTimeout(()=>{r(new I("OPERATION_TIMEOUT",`OpenAPI validation operation timed out after ${e.options.timeoutMs}ms.`))},n);e.controller.signal.addEventListener("abort",()=>{clearTimeout(a),r(new I("OPERATION_ABORTED","OpenAPI validation operation was aborted."))},{once:!0})})}function throwIfCancelled(e){if(e.controller.signal.aborted)throw new I("OPERATION_ABORTED","OpenAPI validation operation was aborted.");if(Date.now()>e.deadline)throw new I("OPERATION_TIMEOUT",`OpenAPI validation operation timed out after ${e.options.timeoutMs}ms.`)}function assertByteLength(e,t,r){const o=Buffer.byteLength(e,"utf8");if(o>t)throw new I(r,`Input exceeds maximum allowed size of ${t} bytes (actual: ${o} bytes).`)}function getErrorMessage6(e){return e instanceof Error?e.message:String(e)}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{assertConfigFormat as t,assertNonEmptyString as e,assertPatchOperations as o,assertPatchPath as r,createError as n,deleteContentValue as i,getErrorMessage as a,patchContent as s,setContentValue as c}from"./chunk-YWMVVRYM.mjs";import{readFile as l}from"fs/promises";import{isAbsolute as u,resolve as f}from"path";import{fileURLToPath as m}from"url";function normalizeFilePath(t){if("string"!=typeof t||0===t.length)throw new TypeError("[confedit] filePath must be a non-empty string.");if(t.startsWith("file:"))try{return m(t)}catch(e){throw function(t,e){const o=new Error(t);try{Object.defineProperty(o,"cause",{configurable:!0,enumerable:!1,value:e,writable:!0})}catch{}return o}(`[confedit] Invalid file URL "${t}": ${function(t){return t instanceof Error?t.message:String(t)}(e)}`,e)}return u(t)?t:f(t)}async function readConfigFile(t){e(t,"filePath");const o=normalizeFilePath(t);try{return await l(o,"utf8")}catch(e){throw n(`[confedit] Failed to read "${t}": ${a(e)}`,e)}}import{mkdir as d}from"fs/promises";import{dirname as w}from"path";import{randomBytes as h}from"crypto";import{chmod as p,mkdir as y,open as g,rename as E,stat as D,unlink as b}from"fs/promises";import{basename as T,dirname as v,join as M}from"path";async function atomicWrite(t,e){if(function(t,e){if("string"!=typeof t||0===t.length)throw new TypeError(`[confedit] ${e} must be a non-empty string.`)}(t,"filePath"),"string"!=typeof e)throw new TypeError("[confedit] content must be a string.");const o=normalizeFilePath(t),r=v(o),n=T(o),i=`${process.pid}.${Date.now()}.${h(12).toString("hex")}`,a=M(r,`.${n}.${i}.tmp`),s=M(r,`.${n}.${i}.bak`);let c,l=!1,u=!1;try{await y(r,{recursive:!0});const t=await async function(t){try{return 511&(await D(t)).mode}catch(t){if("ENOENT"===getErrorCode(t))return;throw t}}(o);c=await g(a,"wx",t??384);try{await c.writeFile(e,"utf8"),await c.sync()}finally{await c.close(),c=void 0}void 0!==t&&await p(a,t);try{await E(a,o),u=!0}catch(t){if(!function(t){if("win32"!==process.platform)return!1;const e=getErrorCode(t);return"EEXIST"===e||"EPERM"===e||"EACCES"===e}(t))throw t;await E(o,s),l=!0;try{await E(a,o),u=!0}catch(t){const e=await async function(t,e){try{return await b(t).catch(t=>{if("ENOENT"!==getErrorCode(t))throw t}),await E(e,t),!0}catch{return!1}}(o,s);throw e&&(l=!1),createError3(e?`[confedit] Failed to replace "${o}", but the original file was restored.`:`[confedit] Failed to replace "${o}". The backup was retained at "${s}".`,t)}}await syncDirectory(r),l&&(await b(s),l=!1,await syncDirectory(r))}catch(e){throw void 0!==c&&await c.close().catch(()=>{}),createError3(`[confedit] Failed to atomically write "${t}": ${function(t){return t instanceof Error?t.message:String(t)}(e)}`,e)}finally{await b(a).catch(()=>{}),u&&l&&await b(s).catch(()=>{})}}async function syncDirectory(t){let e;try{e=await g(t,"r"),await e.sync()}catch(t){const e=getErrorCode(t);if("EINVAL"!==e&&"EPERM"!==e&&"EISDIR"!==e&&"ENOSYS"!==e&&"ENOTSUP"!==e)throw t}finally{await(e?.close().catch(()=>{}))}}function getErrorCode(t){if(null!==t&&"object"==typeof t&&"code"in t&&"string"==typeof t.code)return t.code}function createError3(t,e){const o=new Error(t);try{Object.defineProperty(o,"cause",{configurable:!0,enumerable:!1,value:e,writable:!0})}catch{}return o}import{randomBytes as P}from"crypto";import{open as O,readFile as I,rename as N,unlink as F,writeFile as x}from"fs/promises";import{hostname as k}from"os";var $=new Map,A=new Set;async function withFileLock(t,e,o={}){const r=normalizeFilePath(t);return function(t){if(void 0!==t.timeoutMs&&(!Number.isFinite(t.timeoutMs)||t.timeoutMs<0))throw new TypeError("[confedit] lock timeoutMs must be a non-negative finite number.");if(void 0!==t.retryDelayMs&&(!Number.isFinite(t.retryDelayMs)||t.retryDelayMs<=0))throw new TypeError("[confedit] lock retryDelayMs must be a positive finite number.");if(void 0!==t.staleThresholdMs&&(!Number.isFinite(t.staleThresholdMs)||t.staleThresholdMs<=0))throw new TypeError("[confedit] lock staleThresholdMs must be a positive finite number.")}(o),function(t,e){const o=$.get(t)??Promise.resolve();let r;const n=new Promise(t=>{r=t}),i=o.catch(()=>{}).then(()=>n);return $.set(t,i),o.catch(()=>{}).then(e).finally(()=>{r(),$.get(t)===i&&$.delete(t)})}(r,async()=>{const t=await async function(t,e){const o=e.timeoutMs??1e4,r=e.retryDelayMs??25,n=e.staleThresholdMs??Math.max(2*o,6e4),i=e.allowStaleRecovery??!1,a=`${t}.confedit.lock`,s=Date.now();let c=r;const l=createToken(),u={version:1,pid:process.pid,hostname:k(),createdAt:(new Date).toISOString(),token:l};for(;;)try{const t=await O(a,"wx",384);try{await x(t,`${JSON.stringify(u)}\n`,"utf8"),await t.sync()}finally{await t.close()}return A.add(a),createReleaseHandler(a,l)}catch(e){if("EEXIST"!==getErrorCode2(e))throw createError4(`[confedit] Failed to acquire lock "${a}": ${getErrorMessage4(e)}`,e);if(i&&await tryRecoverStaleLock(a,n),Date.now()-s>=o)throw new Error(`[confedit] Timed out waiting for lock on "${t}" after ${o}ms.`);await sleep(c),c=Math.min(Math.ceil(1.5*c),1e3)}}(r,o);try{return await e()}finally{await t()}})}async function releaseAllLocalLocks(){const t=Array.from(A).map(t=>F(t).catch(()=>{}));await Promise.allSettled(t),A.clear()}function createReleaseHandler(t,e){return async()=>{try{const o=await readLockData(t);o?.token===e&&(await F(t).catch(()=>{}),A.delete(t))}catch{}}}async function tryRecoverStaleLock(t,e){const o=await readLockData(t);if(void 0===o||!isExpiredLock(o,e))return;const r=`${t}.stale.${createToken()}`;try{await N(t,r)}catch(t){return void getErrorCode2(t)}try{const n=await readLockData(r);if(n?.token===o.token&&isExpiredLock(n,e))return void await F(r).catch(()=>{});await N(r,t).catch(()=>{})}catch{}}function isExpiredLock(t,e){const o=Date.parse(t.createdAt);return Number.isFinite(o)&&Date.now()-o>e}async function readLockData(t){try{return function(t){try{const e=JSON.parse(t);if(1!==e.version||"number"!=typeof e.pid||!Number.isSafeInteger(e.pid)||e.pid<=0||"string"!=typeof e.hostname||"string"!=typeof e.createdAt||!Number.isFinite(Date.parse(e.createdAt))||"string"!=typeof e.token||e.token.length<16)return;return{version:1,pid:e.pid,hostname:e.hostname,createdAt:e.createdAt,token:e.token}}catch{return}}(await I(t,"utf8"))}catch{return}}function createToken(){return`${process.pid}-${Date.now()}-${P(16).toString("hex")}`}function sleep(t){return new Promise(e=>setTimeout(e,t))}function getErrorCode2(t){if(null!==t&&"object"==typeof t&&"code"in t&&"string"==typeof t.code)return t.code}function getErrorMessage4(t){return t instanceof Error?t.message:String(t)}function createError4(t,e){const o=new Error(t);try{Object.defineProperty(o,"cause",{configurable:!0,enumerable:!1,value:e,writable:!0})}catch{}return o}async function writeConfigFile(t,o,r={}){if(e(t,"filePath"),"string"!=typeof o)throw new TypeError("[confedit] content must be a string.");const i=normalizeFilePath(t),writeTransaction=async()=>{try{await atomicWrite(i,o)}catch(e){throw n(`[confedit] Failed to write "${t}": ${a(e)}`,e)}};r.lock??!0?(await d(w(i),{recursive:!0}),await withFileLock(i,writeTransaction,{timeoutMs:r.lockTimeoutMs,retryDelayMs:r.lockRetryDelayMs,staleThresholdMs:r.lockStaleThresholdMs,allowStaleRecovery:r.allowStaleRecovery})):await writeTransaction()}import{extname as R}from"path";function detectFormat(t){const e=normalizeFilePath(t);switch(R(e).toLowerCase()){case".json":return"json";case".jsonc":return"jsonc";case".yaml":case".yml":return"yaml";default:throw new Error(`[confedit] Cannot detect a supported configuration format for "${t}". Expected .json, .jsonc, .yaml, or .yml.`)}}async function patchConfigFile(e,r,i={}){if("string"!=typeof e||0===e.length)throw new TypeError("[confedit] filePath must be a non-empty string.");if(o(r),0===r.length)return;const c=normalizeFilePath(e),l=i.format??detectFormat(c);t(l),function(t){if(void 0!==t.lockTimeoutMs&&(!Number.isFinite(t.lockTimeoutMs)||t.lockTimeoutMs<0))throw new TypeError("[confedit] lockTimeoutMs must be a non-negative finite number.");if(void 0!==t.lockRetryDelayMs&&(!Number.isFinite(t.lockRetryDelayMs)||t.lockRetryDelayMs<=0))throw new TypeError("[confedit] lockRetryDelayMs must be a positive finite number.");if(void 0!==t.lockStaleThresholdMs&&(!Number.isFinite(t.lockStaleThresholdMs)||t.lockStaleThresholdMs<=0))throw new TypeError("[confedit] lockStaleThresholdMs must be a positive finite number.")}(i);const u=i.strict??!0,patchTransaction=async()=>{const t=await readConfigFile(c),o=s(t,r,l,{strict:u});if(o!==t)try{await atomicWrite(c,o)}catch(t){throw n(`[confedit] Failed to write "${e}": ${a(t)}`,t)}};i.lock??!0?await withFileLock(c,patchTransaction,{timeoutMs:i.lockTimeoutMs,retryDelayMs:i.lockRetryDelayMs,staleThresholdMs:i.lockStaleThresholdMs,allowStaleRecovery:i.allowStaleRecovery}):await patchTransaction()}async function setConfigValue(t,e,o,r={}){await patchConfigFile(t,[{op:"add",path:[...e],value:o}],r)}async function deleteConfigValue(t,e,o={}){await patchConfigFile(t,[{op:"remove",path:[...e]}],o)}import{open as S,realpath as L,stat as C}from"fs/promises";import{isAbsolute as _,relative as V,resolve as U,sep as B}from"path";import{parse as j}from"yaml";var z,K=class extends Error{constructor(t,e,o){super(e),this.name="OpenApiValidationError",this.code=t,this.cause=o}},W={inputKind:"content",timeoutMs:15e3,maxInputBytes:5242880,maxDocumentNodes:1e5,maxDocumentDepth:100,maxValidationErrors:50,maxErrorMessageLength:1e3};async function validateOpenAPISpec(t,e={}){!function(t,e){if("string"!=typeof t||0===t.length)throw new TypeError(`[confedit] ${e} must be a non-empty string.`)}(t,"input");const o=function(t){const e={inputKind:t.inputKind??W.inputKind,baseFilePath:t.baseFilePath,allowedRootDirectory:t.allowedRootDirectory,timeoutMs:t.timeoutMs??W.timeoutMs,maxInputBytes:t.maxInputBytes??W.maxInputBytes,maxDocumentNodes:t.maxDocumentNodes??W.maxDocumentNodes,maxDocumentDepth:t.maxDocumentDepth??W.maxDocumentDepth,maxValidationErrors:t.maxValidationErrors??W.maxValidationErrors,maxErrorMessageLength:t.maxErrorMessageLength??W.maxErrorMessageLength,signal:t.signal};if("content"!==e.inputKind&&"file"!==e.inputKind)throw new K("INVALID_OPTION",`inputKind must be "content" or "file", got: ${String(e.inputKind)}.`);return assertPositiveFiniteNumber(e.timeoutMs,"timeoutMs"),assertPositiveFiniteNumber(e.maxInputBytes,"maxInputBytes"),assertPositiveFiniteNumber(e.maxDocumentNodes,"maxDocumentNodes"),assertPositiveFiniteNumber(e.maxDocumentDepth,"maxDocumentDepth"),assertPositiveFiniteNumber(e.maxValidationErrors,"maxValidationErrors"),assertPositiveFiniteNumber(e.maxErrorMessageLength,"maxErrorMessageLength"),e}(e),r=function(t){const e=new AbortController;if(t){if(!t.aborted){const onAbort=()=>e.abort();return t.addEventListener("abort",onAbort,{once:!0}),{controller:e,dispose:()=>t.removeEventListener("abort",onAbort)}}e.abort()}return{controller:e,dispose:()=>{}}}(o.signal),n={options:o,controller:r.controller,deadline:Date.now()+o.timeoutMs};try{return await runWithDeadline(n,async()=>{if("file"===o.inputKind){const e=await async function(t,e){const o=normalizeFilePath(t);if(!e.options.allowedRootDirectory)throw new K("FILE_INPUT_FORBIDDEN","File input requires allowedRootDirectory.");return async function(t,e){const o=_(t)?t:U(e,t),r=await L(o),n=V(e,r);if(n.startsWith("..")||""===n||"\\"===B&&/^[a-zA-Z]:/.test(n))throw new K("PATH_OUTSIDE_ROOT",`File path is outside the allowed root directory: ${t}.`);return r}(o,await async function(t){if(t.rootDirectory)return t.rootDirectory;const e=t.options.allowedRootDirectory;if(!e)throw new K("INVALID_OPTION","allowedRootDirectory is required for file operations.");const o=U(e),r=await L(o);if(!(await C(r)).isDirectory())throw new K("INVALID_OPTION",`allowedRootDirectory is not a directory: ${e}.`);return t.rootDirectory=r,r}(e))}(t,n),r=await async function(t,e,o,r){throwIfCancelled(o);const n=await S(t,"r");try{const o=await n.stat();if(o.size>e)throw new K(r,`File exceeds maximum allowed size of ${e} bytes: ${t}.`);const i=Buffer.alloc(o.size);return await n.read(i,0,o.size,0),i.toString("utf8")}finally{await n.close()}}(e,o.maxInputBytes,n,"INPUT_FILE_TOO_LARGE");return validateRawContent(r,{...n,options:{...o,baseFilePath:e}})}return assertByteLength(t,o.maxInputBytes,"INPUT_TOO_LARGE"),validateRawContent(t,n)})}catch(t){throw function(t){if(t instanceof K)return t;return new K("UNKNOWN_ERROR",`OpenAPI validation failed: ${getErrorMessage5(t)}.`,t)}(t)}finally{r.dispose(),n.controller.abort()}}async function validateOpenAPIFile(t,e={}){return validateOpenAPISpec(t,{...e,inputKind:"file",baseFilePath:t})}async function validateRawContent(t,e){throwIfCancelled(e),assertByteLength(t,e.options.maxInputBytes,"INPUT_TOO_LARGE");const o=function(t){try{const e=j(t);if(null===e||"object"!=typeof e||Array.isArray(e))throw new K("INVALID_DOCUMENT_SHAPE","OpenAPI document must be a JSON object.");return e}catch(t){if(t instanceof K)throw t;throw new K("PARSE_ERROR",`Failed to parse OpenAPI document: ${getErrorMessage5(t)}.`,t)}}(t);!function(t,e){let o=0;const r=[{value:t,depth:0}];for(;r.length>0;){const{value:t,depth:n}=r.pop();if(n>e.maxDocumentDepth)throw new K("DOCUMENT_TOO_DEEP",`Document exceeds maximum nesting depth of ${e.maxDocumentDepth}.`);if(o++,o>e.maxDocumentNodes)throw new K("DOCUMENT_TOO_LARGE",`Document exceeds maximum node count of ${e.maxDocumentNodes}.`);if(Array.isArray(t))for(const e of t)r.push({value:e,depth:n+1});else if(null!==t&&"object"==typeof t)for(const e of Object.values(t))r.push({value:e,depth:n+1})}}(o,e.options),function(t){const e=t.openapi??t.swagger;if("string"!=typeof e||0===e.length)throw new K("UNSUPPORTED_VERSION","Document must declare an openapi or swagger version.");const o=Number.parseInt(e.split(".")[0]??"",10);if(!Number.isFinite(o)||2!==o&&3!==o)throw new K("UNSUPPORTED_VERSION",`Unsupported OpenAPI/Swagger version: ${e}. Supported: 2.x, 3.x.`)}(o);const r=await runWithDeadline(e,()=>async function(){return z??=import("@powerduck/openapi-parser").then(({validate:t})=>t),z}()),n=await runWithDeadline(e,()=>r(o,{throwOnError:!1}));if(!n.valid)throw new K("SPEC_VALIDATION_FAILED",`OpenAPI validation failed: ${function(t,e,o){const r=t.slice(0,e).map(t=>{const e=t.instancePath??"",r=t.message??"Unknown error",n=e?`${e}: ${r}`:r;return n.length>o?`${n.slice(0,o)}...`:n});t.length>e&&r.push(`... and ${t.length-e} more errors`);return r.join("; ")}(n.errors??[],e.options.maxValidationErrors,e.options.maxErrorMessageLength)}`);return n.specification}function assertPositiveFiniteNumber(t,e){if(!Number.isFinite(t)||t<=0)throw new K("INVALID_OPTION",`${e} must be a positive finite number, got: ${String(t)}.`)}async function runWithDeadline(t,e){throwIfCancelled(t);const o=await Promise.race([e(),createDeadlinePromise(t)]);return throwIfCancelled(t),o}function createDeadlinePromise(t){return new Promise((e,o)=>{const r=t.deadline-Date.now(),n=Math.max(0,Math.min(r,2147483647)),i=setTimeout(()=>{o(new K("OPERATION_TIMEOUT",`OpenAPI validation operation timed out after ${t.options.timeoutMs}ms.`))},n);t.controller.signal.addEventListener("abort",()=>{clearTimeout(i),o(new K("OPERATION_ABORTED","OpenAPI validation operation was aborted."))},{once:!0})})}function throwIfCancelled(t){if(t.controller.signal.aborted)throw new K("OPERATION_ABORTED","OpenAPI validation operation was aborted.");if(Date.now()>t.deadline)throw new K("OPERATION_TIMEOUT",`OpenAPI validation operation timed out after ${t.options.timeoutMs}ms.`)}function assertByteLength(t,e,o){const r=Buffer.byteLength(t,"utf8");if(r>e)throw new K(o,`Input exceeds maximum allowed size of ${e} bytes (actual: ${r} bytes).`)}function getErrorMessage5(t){return t instanceof Error?t.message:String(t)}export{K as OpenApiValidationError,t as assertConfigFormat,e as assertNonEmptyString,o as assertPatchOperations,r as assertPatchPath,n as createError,deleteConfigValue,i as deleteContentValue,detectFormat,a as getErrorMessage,normalizeFilePath,patchConfigFile,s as patchContent,readConfigFile,releaseAllLocalLocks,setConfigValue,c as setContentValue,validateOpenAPIFile,validateOpenAPISpec,withFileLock,writeConfigFile};
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@powerduck/conf-patch",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Two-layer configuration editor: pure core for patching JSON/JSONC/YAML strings (browser-safe), plus file layer with atomic writes and locking for Node.js/Electron. RFC 6902 JSON Patch, comment and formatting preservation, OpenAPI validation.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./core": {
|
|
15
|
+
"types": "./dist/core.d.ts",
|
|
16
|
+
"import": "./dist/core.mjs",
|
|
17
|
+
"require": "./dist/core.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc --noEmit && tsup",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"demo": "ts-node demo/demo.ts",
|
|
29
|
+
"demo:openapi": "ts-node demo/openapi-demo.ts",
|
|
30
|
+
"reset-fixtures": "ts-node demo/reset-fixtures.ts",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@powerduck/openapi-parser": "^0.3.3",
|
|
36
|
+
"jsonc-parser": "^3.3.1",
|
|
37
|
+
"yaml": "^2.9.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.19.43",
|
|
41
|
+
"openapi-types": "^12.1.3",
|
|
42
|
+
"terser": "^5.51.2",
|
|
43
|
+
"ts-node": "^10.9.2",
|
|
44
|
+
"tsup": "^8.1.0",
|
|
45
|
+
"typescript": "^5.5.0",
|
|
46
|
+
"vitest": "^2.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"yaml",
|
|
53
|
+
"json",
|
|
54
|
+
"jsonc",
|
|
55
|
+
"config",
|
|
56
|
+
"rfc6902",
|
|
57
|
+
"config-editor",
|
|
58
|
+
"comment-preserve",
|
|
59
|
+
"atomic-write",
|
|
60
|
+
"electron"
|
|
61
|
+
],
|
|
62
|
+
"author": "PowerDuck",
|
|
63
|
+
"license": "MIT",
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "https://github.com/PowerDuckie/conf-patch.git"
|
|
67
|
+
},
|
|
68
|
+
"bugs": {
|
|
69
|
+
"url": "https://github.com/PowerDuckie/conf-patch/issues"
|
|
70
|
+
},
|
|
71
|
+
"homepage": "https://github.com/PowerDuckie/conf-patch#readme"
|
|
72
|
+
}
|