@openclaw/fs-safe 0.1.1 → 0.1.2
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/CHANGELOG.md +17 -0
- package/README.md +20 -1
- package/dist/file-store-boundary.d.ts +27 -0
- package/dist/file-store-boundary.d.ts.map +1 -0
- package/dist/file-store-boundary.js +164 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +62 -72
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +6 -3
- package/dist/private-file-store.d.ts +5 -7
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +21 -34
- package/dist/root-context.d.ts +23 -0
- package/dist/root-context.d.ts.map +1 -0
- package/dist/root-context.js +62 -0
- package/dist/root-errors.d.ts +4 -0
- package/dist/root-errors.d.ts.map +1 -0
- package/dist/root-errors.js +21 -0
- package/dist/root-impl.d.ts +108 -0
- package/dist/root-impl.d.ts.map +1 -0
- package/dist/{safe-root.js → root-impl.js} +464 -251
- package/dist/root.d.ts +1 -107
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +1 -1332
- package/docs/advanced.md +100 -0
- package/docs/archive.md +199 -0
- package/docs/atomic.md +163 -0
- package/docs/config.md +65 -0
- package/docs/contributing.md +80 -0
- package/docs/errors.md +154 -0
- package/docs/file-store.md +213 -0
- package/docs/filename.md +0 -0
- package/docs/index.md +76 -0
- package/docs/install-path.md +167 -0
- package/docs/install.md +150 -0
- package/docs/json-store.md +200 -0
- package/docs/json.md +158 -0
- package/docs/local-roots.md +164 -0
- package/docs/path-scope.md +130 -0
- package/docs/path.md +191 -0
- package/docs/permissions.md +95 -0
- package/docs/private-file-store.md +45 -0
- package/docs/python-helper.md +103 -0
- package/docs/quickstart.md +147 -0
- package/docs/reading.md +174 -0
- package/docs/regular-file.md +175 -0
- package/docs/root.md +205 -0
- package/docs/secret-file.md +154 -0
- package/docs/secure-file.md +82 -0
- package/docs/security-model.md +99 -0
- package/docs/sidecar-lock.md +229 -0
- package/docs/store.md +44 -0
- package/docs/temp.md +276 -0
- package/docs/test-hooks.md +74 -0
- package/docs/testing.md +168 -0
- package/docs/timing.md +121 -0
- package/docs/types.md +155 -0
- package/docs/walk.md +63 -0
- package/docs/writing.md +222 -0
- package/package.json +5 -2
- package/dist/archive-utils.d.ts +0 -3
- package/dist/archive-utils.d.ts.map +0 -1
- package/dist/archive-utils.js +0 -26
- package/dist/boundary-file-read.d.ts +0 -44
- package/dist/boundary-file-read.d.ts.map +0 -1
- package/dist/boundary-file-read.js +0 -129
- package/dist/boundary-path.d.ts +0 -39
- package/dist/boundary-path.d.ts.map +0 -1
- package/dist/boundary-path.js +0 -598
- package/dist/fs-pinned-path-helper.d.ts +0 -7
- package/dist/fs-pinned-path-helper.d.ts.map +0 -1
- package/dist/fs-pinned-path-helper.js +0 -182
- package/dist/fs-pinned-write-helper.d.ts +0 -21
- package/dist/fs-pinned-write-helper.d.ts.map +0 -1
- package/dist/fs-pinned-write-helper.js +0 -263
- package/dist/hardlink-guards.d.ts +0 -7
- package/dist/hardlink-guards.d.ts.map +0 -1
- package/dist/hardlink-guards.js +0 -30
- package/dist/install-safe-path.d.ts +0 -20
- package/dist/install-safe-path.d.ts.map +0 -1
- package/dist/install-safe-path.js +0 -94
- package/dist/json-file.d.ts +0 -3
- package/dist/json-file.d.ts.map +0 -1
- package/dist/json-file.js +0 -123
- package/dist/json-files.d.ts +0 -20
- package/dist/json-files.d.ts.map +0 -1
- package/dist/json-files.js +0 -153
- package/dist/path-alias-guards.d.ts +0 -19
- package/dist/path-alias-guards.d.ts.map +0 -1
- package/dist/path-alias-guards.js +0 -21
- package/dist/path-guards.d.ts +0 -7
- package/dist/path-guards.d.ts.map +0 -1
- package/dist/path-guards.js +0 -49
- package/dist/path-safety.d.ts +0 -12
- package/dist/path-safety.d.ts.map +0 -1
- package/dist/path-safety.js +0 -50
- package/dist/safe-open-sync.d.ts +0 -24
- package/dist/safe-open-sync.d.ts.map +0 -1
- package/dist/safe-open-sync.js +0 -71
- package/dist/safe-root.d.ts +0 -123
- package/dist/safe-root.d.ts.map +0 -1
- package/dist/secure-temp-workspace.d.ts +0 -25
- package/dist/secure-temp-workspace.d.ts.map +0 -1
- package/dist/secure-temp-workspace.js +0 -136
- package/dist/sibling-temp-file.d.ts +0 -16
- package/dist/sibling-temp-file.d.ts.map +0 -1
- package/dist/sibling-temp-file.js +0 -73
- package/dist/sibling-temp-write.d.ts +0 -8
- package/dist/sibling-temp-write.d.ts.map +0 -1
- package/dist/sibling-temp-write.js +0 -40
package/docs/install.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Install
|
|
2
|
+
|
|
3
|
+
`fs-safe` is published to npm as `@openclaw/fs-safe`. It targets Node 20.11 or newer, ships ESM only, and works on macOS, Linux, and Windows.
|
|
4
|
+
|
|
5
|
+
## Package managers
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @openclaw/fs-safe
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @openclaw/fs-safe
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
yarn add @openclaw/fs-safe
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bun add @openclaw/fs-safe
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Node version
|
|
24
|
+
|
|
25
|
+
Minimum **Node 20.11**. The package uses `fs.promises`, `fs.constants.O_NOFOLLOW` where available, and `node:stream/promises`. Earlier Node releases will fail at import time.
|
|
26
|
+
|
|
27
|
+
Verify the runtime:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
node --version
|
|
31
|
+
# v20.11.0 or newer
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## TypeScript
|
|
35
|
+
|
|
36
|
+
Types ship with the package — no `@types/openclaw__fs-safe` needed. The `exports` map in `package.json` provides typed entries for every subpath:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { root, FsSafeError } from "@openclaw/fs-safe";
|
|
40
|
+
import { writeJson } from "@openclaw/fs-safe/json";
|
|
41
|
+
import { extractArchive } from "@openclaw/fs-safe/archive";
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A working `tsconfig.json` for consumers:
|
|
45
|
+
|
|
46
|
+
```jsonc
|
|
47
|
+
{
|
|
48
|
+
"compilerOptions": {
|
|
49
|
+
"target": "es2022",
|
|
50
|
+
"module": "node18",
|
|
51
|
+
"moduleResolution": "node16",
|
|
52
|
+
"strict": true,
|
|
53
|
+
"esModuleInterop": true,
|
|
54
|
+
"skipLibCheck": true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Subpath exports
|
|
60
|
+
|
|
61
|
+
Use the main entry for the common surface, or the focused subpaths when you want a leaner import or to depend on a narrower contract:
|
|
62
|
+
|
|
63
|
+
| Subpath | Contents |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `@openclaw/fs-safe` | Small common surface: `root`, root types, and errors. |
|
|
66
|
+
| `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, related types. |
|
|
67
|
+
| `@openclaw/fs-safe/config` | Process-global Python helper configuration. |
|
|
68
|
+
| `@openclaw/fs-safe/path` | `isPathInside`, `safeRealpathSync`, `isWithinDir`, error helpers. |
|
|
69
|
+
| `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, sync variants. |
|
|
70
|
+
| `@openclaw/fs-safe/store` | `fileStore()`, `fileStoreSync()`, and `jsonStore<T>()`. |
|
|
71
|
+
| `@openclaw/fs-safe/secret` | Secret file read/write helpers. |
|
|
72
|
+
| `@openclaw/fs-safe/atomic` | `replaceFileAtomic`, `writeTextAtomic`, `replaceDirectoryAtomic`, `movePathWithCopyFallback`. |
|
|
73
|
+
| `@openclaw/fs-safe/temp` | `tempWorkspace`, `withTempWorkspace`, sync variants, `resolveSecureTempRoot`. |
|
|
74
|
+
| `@openclaw/fs-safe/secure-file` | `readSecureFile` for pinned absolute file reads with permissions checks. |
|
|
75
|
+
| `@openclaw/fs-safe/file-lock` | `acquireFileLock`, `withFileLock`, `createFileLockManager`, and related lock types. |
|
|
76
|
+
| `@openclaw/fs-safe/permissions` | POSIX mode and Windows ACL inspection/remediation helpers. |
|
|
77
|
+
| `@openclaw/fs-safe/walk` | `walkDirectory`, `walkDirectorySync`, related types. Budget-bounded, not root-bounded. |
|
|
78
|
+
| `@openclaw/fs-safe/archive` | `extractArchive`, `resolveArchiveKind`, limits, preflight helpers. |
|
|
79
|
+
| `@openclaw/fs-safe/advanced` | Lower-level composition helpers: path scopes, root-file open, install paths, local-root readers, temp-file targets, sibling-temp writes, regular-file helpers, `pathExists`, `withTimeout`, and related advanced types. This surface is less stable than the focused public subpaths. |
|
|
80
|
+
| `@openclaw/fs-safe/errors` | `FsSafeError`, `FsSafeErrorCode`. |
|
|
81
|
+
| `@openclaw/fs-safe/types` | Shared types: `DirEntry`, `PathStat`, `BasePathOptions`, … |
|
|
82
|
+
| `@openclaw/fs-safe/test-hooks` | Test-only hooks for injecting races. Active under `NODE_ENV=test`. |
|
|
83
|
+
|
|
84
|
+
## Runtime dependencies
|
|
85
|
+
|
|
86
|
+
`@openclaw/fs-safe` lists `jszip` and `tar` as optional dependencies for [archive extraction](archive.md). They are loaded lazily and only required when ZIP/TAR helpers run. Installs that omit optional dependencies can still import and use every non-archive subpath; archive calls fail with a clear missing-optional-dependency message.
|
|
87
|
+
|
|
88
|
+
There are no peer dependencies and no native build step.
|
|
89
|
+
|
|
90
|
+
## Python helper policy
|
|
91
|
+
|
|
92
|
+
On POSIX, `root()` uses one persistent Python helper process for the
|
|
93
|
+
fd-relative operations Node does not expose cleanly. The default is `auto`: use
|
|
94
|
+
the helper when it starts, fall back to Node-only behavior when it is disabled
|
|
95
|
+
or unavailable.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { configureFsSafePython } from "@openclaw/fs-safe/config";
|
|
99
|
+
|
|
100
|
+
configureFsSafePython({ mode: "auto" }); // default
|
|
101
|
+
configureFsSafePython({ mode: "off" }); // never spawn Python
|
|
102
|
+
configureFsSafePython({ mode: "require" }); // fail closed if unavailable
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Environment variables are read at runtime:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
FS_SAFE_PYTHON_MODE=off # auto | off | require
|
|
109
|
+
FS_SAFE_PYTHON=/usr/bin/python3
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
OpenClaw compatibility aliases are also accepted:
|
|
113
|
+
`OPENCLAW_FS_SAFE_PYTHON_MODE`, `OPENCLAW_FS_SAFE_PYTHON`,
|
|
114
|
+
`OPENCLAW_PINNED_PYTHON`, and `OPENCLAW_PINNED_WRITE_PYTHON`.
|
|
115
|
+
|
|
116
|
+
Disabling Python keeps the public API working, but downgrades POSIX mutation
|
|
117
|
+
hardening from fd-relative syscalls to Node path operations guarded by lexical
|
|
118
|
+
and canonical checks plus identity verification. Use `require` for
|
|
119
|
+
security-sensitive deployments where that downgrade should be a startup/runtime
|
|
120
|
+
failure instead of a fallback. The full tradeoff is documented in
|
|
121
|
+
[Python helper policy](python-helper.md).
|
|
122
|
+
|
|
123
|
+
## Verify the install
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { root, FsSafeError } from "@openclaw/fs-safe";
|
|
127
|
+
import os from "node:os";
|
|
128
|
+
import path from "node:path";
|
|
129
|
+
|
|
130
|
+
const dir = path.join(os.tmpdir(), "fs-safe-smoke");
|
|
131
|
+
await import("node:fs/promises").then((fs) => fs.mkdir(dir, { recursive: true }));
|
|
132
|
+
|
|
133
|
+
const fs = await root(dir);
|
|
134
|
+
await fs.write("hello.txt", "ok\n");
|
|
135
|
+
console.log(await fs.readText("hello.txt"));
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await fs.write("../escape.txt", "x");
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err instanceof FsSafeError) console.log("blocked:", err.code);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
If the script prints `ok` followed by `blocked: outside-workspace`, your install is healthy.
|
|
145
|
+
|
|
146
|
+
## Next
|
|
147
|
+
|
|
148
|
+
- [Quickstart](quickstart.md) — write, read, atomic, temp.
|
|
149
|
+
- [Security model](security-model.md) — what the boundary defends against.
|
|
150
|
+
- [Errors](errors.md) — the closed code union you'll be catching.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# JSON store
|
|
2
|
+
|
|
3
|
+
`jsonStore` is exported from `@openclaw/fs-safe/store`. It is the absolute-path
|
|
4
|
+
convenience wrapper for `fileStore(...).json(...)`: a small read-modify-write
|
|
5
|
+
handle around a single JSON file. It bakes in atomic writes, explicit fallback
|
|
6
|
+
reads, and optional cross-process locking via
|
|
7
|
+
[`acquireFileLock`](sidecar-lock.md).
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { jsonStore } from "@openclaw/fs-safe/store";
|
|
11
|
+
|
|
12
|
+
const settings = jsonStore<{ theme: "light" | "dark"; volume: number }>({
|
|
13
|
+
filePath: "/var/lib/app/settings.json",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const current = await settings.readOr({ theme: "dark", volume: 0.7 });
|
|
17
|
+
await settings.write({ ...current, volume: 1 });
|
|
18
|
+
await settings.updateOr({ theme: "dark", volume: 0.7 }, (prev) => ({ ...prev, theme: "light" }));
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
If you already have a store/root context, prefer binding the JSON file from that
|
|
22
|
+
store:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { fileStore } from "@openclaw/fs-safe/store";
|
|
26
|
+
|
|
27
|
+
const files = fileStore({ rootDir: "/var/lib/app", private: true });
|
|
28
|
+
const settings = files.json<Settings>("settings.json", { lock: true });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## When to reach for it
|
|
32
|
+
|
|
33
|
+
- You have a single JSON state file and want `read / readOr / readRequired / write / update` semantics.
|
|
34
|
+
- You want every write atomic at file mode `0o600` and parents at `0o700` by default.
|
|
35
|
+
- You want optional cross-process locking with one boolean.
|
|
36
|
+
|
|
37
|
+
For ad-hoc read/write of multiple JSON files, use the standalone helpers in
|
|
38
|
+
[`json`](json.md). For object-style storage of many files at known modes, use
|
|
39
|
+
[`fileStore`](file-store.md) and bind JSON files with `store.json(rel)`.
|
|
40
|
+
|
|
41
|
+
## Factory: `jsonStore<T>(options)`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
type JsonStoreOptions<T> = {
|
|
45
|
+
filePath: string;
|
|
46
|
+
dirMode?: number; // default 0o700
|
|
47
|
+
mode?: number; // default 0o600
|
|
48
|
+
trailingNewline?: boolean; // default true
|
|
49
|
+
lock?: boolean | JsonStoreLockOptions; // false / undefined = no lock
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type JsonStoreLockOptions = {
|
|
53
|
+
staleMs?: number; // default 30_000
|
|
54
|
+
timeoutMs?: number; // default 30_000
|
|
55
|
+
retry?: FileLockRetryOptions;
|
|
56
|
+
managerKey?: string; // default `fs-safe.json-store:<filePath>`
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type JsonStore<T> = {
|
|
60
|
+
readonly filePath: string;
|
|
61
|
+
read(): Promise<T | undefined>;
|
|
62
|
+
readOr(fallback: T): Promise<T>;
|
|
63
|
+
readRequired(): Promise<T>;
|
|
64
|
+
write(value: T): Promise<void>;
|
|
65
|
+
update(run: (current: T | undefined) => T | Promise<T>): Promise<T>;
|
|
66
|
+
updateOr(fallback: T, run: (current: T) => T | Promise<T>): Promise<T>;
|
|
67
|
+
};
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`jsonStore({ filePath })` resolves `rootDir = dirname(filePath)` and calls
|
|
71
|
+
`fileStore({ rootDir, private: true }).json(basename(filePath), options)`.
|
|
72
|
+
|
|
73
|
+
The store does **not** validate the parsed value against `T` at runtime — the cast is unchecked. Wrap with a schema (zod/valibot) if the file might be hand-edited or written by another process you don't control.
|
|
74
|
+
|
|
75
|
+
## `read()`
|
|
76
|
+
|
|
77
|
+
Returns the parsed contents, or `undefined` if the file does not exist. Invalid JSON throws (via [`readJsonIfExists`](json.md)).
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const state = await store.read();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## `readOr(fallback)`
|
|
84
|
+
|
|
85
|
+
Returns the parsed contents or the per-call fallback. Object fallbacks are cloned so callers can safely mutate the returned value:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const state = await store.readOr(defaultState);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## `readRequired()`
|
|
92
|
+
|
|
93
|
+
Strict disk read. Throws when the file is missing or invalid:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const state = await store.readRequired();
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## `write(value)`
|
|
100
|
+
|
|
101
|
+
Atomic JSON write at `mode` (default `0o600`), creating parent dirs at `dirMode` (default `0o700`) if needed. When `lock: true` is set, takes the sidecar lock for the duration of the write.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
await store.write({ ...state, lastSeen: Date.now() });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## `update(run)`
|
|
108
|
+
|
|
109
|
+
Read, transform, write — under the lock if locking is enabled. Returns the new value:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const next = await store.update((prev) => ({ count: (prev?.count ?? 0) + 1 }));
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`run` is async-friendly. The whole `read → run → write` sequence runs inside one `withLock` call, so concurrent updaters from different processes serialize cleanly.
|
|
116
|
+
|
|
117
|
+
Use `update(run)` when missing state is part of your model. Use `updateOr(fallback, run)` when the missing-file case should start from a concrete value and you want to merge into defaults:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const next = await store.updateOr({ count: 0 }, (prev) => ({ count: prev.count + 1 }));
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Locking
|
|
124
|
+
|
|
125
|
+
Set `lock: true` for default behavior, or pass an options object to tune:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const counter = jsonStore<{ count: number }>({
|
|
129
|
+
filePath: "/var/lib/app/counter.json",
|
|
130
|
+
lock: {
|
|
131
|
+
staleMs: 60_000,
|
|
132
|
+
timeoutMs: 10_000,
|
|
133
|
+
retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, randomize: true },
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` shape is still useful — it gives you a single function for the read-modify-write pattern — but it offers no concurrency guarantees if other processes also write to the file.
|
|
139
|
+
|
|
140
|
+
The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
|
|
141
|
+
|
|
142
|
+
## Common patterns
|
|
143
|
+
|
|
144
|
+
### Per-feature settings file
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
type Settings = { theme: "light" | "dark"; muted: boolean };
|
|
148
|
+
|
|
149
|
+
const settings = jsonStore<Settings>({
|
|
150
|
+
filePath: path.join(homedir(), ".myapp/settings.json"),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Read on boot
|
|
154
|
+
applySettings(await settings.readOr({ theme: "dark", muted: false }));
|
|
155
|
+
|
|
156
|
+
// Toggle on UI action
|
|
157
|
+
await settings.update((prev) => {
|
|
158
|
+
const current = prev ?? { theme: "dark", muted: false };
|
|
159
|
+
return { ...current, muted: !current.muted };
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Cross-process counter
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const counter = jsonStore<{ count: number }>({
|
|
167
|
+
filePath: "/var/lib/app/counter.json",
|
|
168
|
+
lock: true,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const { count } = await counter.updateOr({ count: 0 }, (prev) => ({ count: prev.count + 1 }));
|
|
172
|
+
console.log("now at", count);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Migration on boot
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const config = jsonStore<Config>({ filePath });
|
|
179
|
+
const current = await config.readOr(defaultConfig);
|
|
180
|
+
if (current.version !== CURRENT_VERSION) {
|
|
181
|
+
await config.write(migrate(current));
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Difference from raw `writeJson` / `readJsonIfExists`
|
|
186
|
+
|
|
187
|
+
| `jsonStore` | Raw helpers |
|
|
188
|
+
|---|---|
|
|
189
|
+
| Read-modify-write in one call (`update`). | Compose `readJsonIfExists` + `writeJson` yourself. |
|
|
190
|
+
| Optional cross-process lock with one flag. | Manage `withFileLock` yourself. |
|
|
191
|
+
| Explicit `readOr` / `updateOr` fallbacks. | Caller handles `null` and clones. |
|
|
192
|
+
| Mode/dirMode locked per store. | Per-call. |
|
|
193
|
+
|
|
194
|
+
`jsonStore` is the right shape when one file owns one piece of state and many call sites read or update it. For one-off writes, the raw helpers are leaner.
|
|
195
|
+
|
|
196
|
+
## See also
|
|
197
|
+
|
|
198
|
+
- [JSON files](json.md) — the standalone helpers `jsonStore` is built on.
|
|
199
|
+
- [File lock](sidecar-lock.md) — the cross-process lock used when `lock: true`.
|
|
200
|
+
- [File store](file-store.md) — the multi-file equivalent of this surface.
|
package/docs/json.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# JSON files
|
|
2
|
+
|
|
3
|
+
`@openclaw/fs-safe/json` is the standalone JSON surface: strict and lenient read variants plus atomic JSON writes.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
tryReadJson,
|
|
8
|
+
readJson,
|
|
9
|
+
readJsonIfExists,
|
|
10
|
+
readJsonSync,
|
|
11
|
+
tryReadJsonSync,
|
|
12
|
+
writeJson,
|
|
13
|
+
writeJsonSync,
|
|
14
|
+
JsonFileReadError,
|
|
15
|
+
} from "@openclaw/fs-safe/json";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Three reads, three failure shapes
|
|
19
|
+
|
|
20
|
+
Same input, three distinct contracts — pick the one whose error story matches your call site:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
await readJson<T>("./manifest.json"); // throws JsonFileReadError on missing or invalid
|
|
24
|
+
await readJsonIfExists<T>("./cache.json"); // returns null on missing; throws on invalid
|
|
25
|
+
await tryReadJson<T>("./optional.json"); // returns null on missing or invalid
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Helper | Missing file | Invalid JSON |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `readJson` | throws | throws |
|
|
31
|
+
| `readJsonIfExists` | `null` | throws |
|
|
32
|
+
| `tryReadJson` | `null` | `null` |
|
|
33
|
+
|
|
34
|
+
Use `readJson` when missing-or-malformed is a programmer error you want to surface immediately. Use `readJsonIfExists` when "file not there" is normal but malformed bytes should still page someone. Use `tryReadJson` when neither outcome should crash the caller.
|
|
35
|
+
|
|
36
|
+
`JsonFileReadError` carries `cause` so you can inspect whether the underlying failure was an `ENOENT`, a `SyntaxError`, or something else.
|
|
37
|
+
|
|
38
|
+
## Reading
|
|
39
|
+
|
|
40
|
+
### `readJson<T>(filePath)`
|
|
41
|
+
|
|
42
|
+
Async strict reader. Throws `JsonFileReadError` on missing or invalid input. The cast is unchecked — validate the shape with your own schema (zod, valibot, …) if it came from an untrusted source.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const manifest = await readJson<Manifest>("./manifest.json");
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### `readJsonIfExists<T>(filePath)`
|
|
49
|
+
|
|
50
|
+
Async semi-lenient reader. Returns `null` if the file is missing; throws `JsonFileReadError` if the file exists but cannot be parsed.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const cache = (await readJsonIfExists<Cache>("./cache.json")) ?? freshCache();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `tryReadJson<T>(filePath)`
|
|
57
|
+
|
|
58
|
+
Async lenient reader. Returns `null` for any failure (missing, unreadable, invalid). The "no fuss" sibling.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const optional = (await tryReadJson<Settings>("./settings.json")) ?? defaults;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### `readJsonSync<T>(filePath)`
|
|
65
|
+
|
|
66
|
+
Synchronous strict reader. Throws `JsonFileReadError` on missing or invalid input, matching the async `readJson` contract.
|
|
67
|
+
|
|
68
|
+
### `tryReadJsonSync<T>(pathname)`
|
|
69
|
+
|
|
70
|
+
Synchronous, generic, lenient. Returns `T | null`. Useful in boot paths where you want a typed result without async.
|
|
71
|
+
|
|
72
|
+
## Writing
|
|
73
|
+
|
|
74
|
+
### `writeJson(filePath, value, options?)`
|
|
75
|
+
|
|
76
|
+
Async atomic JSON write. `JSON.stringify(value, null, 2)` + sibling-temp + rename. Defaults to file mode `0o600`.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
await writeJson("./state.json", state, { trailingNewline: true });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Options:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
type WriteJsonOptions = {
|
|
86
|
+
mode?: number; // file mode (default 0o600)
|
|
87
|
+
dirMode?: number; // mode for parent dirs created on demand
|
|
88
|
+
trailingNewline?: boolean; // append "\n" if missing (default false)
|
|
89
|
+
};
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `writeJsonSync(pathname, data)`
|
|
93
|
+
|
|
94
|
+
Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
writeJsonSync("./prefs.json", { theme: "dark" });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
For atomic text writes, use [`writeTextAtomic`](atomic.md) from `@openclaw/fs-safe/atomic`. For in-process serialization, use `createAsyncLock` from the advanced surface, or prefer [`jsonStore`](json-store.md) when you want a JSON-specific read-modify-write helper.
|
|
101
|
+
|
|
102
|
+
## Common patterns
|
|
103
|
+
|
|
104
|
+
### Read-modify-write
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const state = (await readJsonIfExists<State>("./state.json")) ?? initialState();
|
|
108
|
+
state.lastRun = Date.now();
|
|
109
|
+
await writeJson("./state.json", state, { mode: 0o600, dirMode: 0o700 });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Atomic with secure mode
|
|
113
|
+
|
|
114
|
+
For credentials or other sensitive JSON, write at mode `0o600`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
await writeJson("./auth.json", token, { mode: 0o600, dirMode: 0o700 });
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
For higher-assurance secrets, prefer the dedicated [secret-file helpers](secret-file.md) — they create the parent directory at `0o700` if missing.
|
|
121
|
+
|
|
122
|
+
### Strict load on boot
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
let manifest: Manifest;
|
|
126
|
+
try {
|
|
127
|
+
manifest = await readJson<Manifest>("./manifest.json");
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof JsonFileReadError) {
|
|
130
|
+
console.error("manifest unreadable:", err.cause);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Concurrent readers, single writer
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const state = await readJsonIfExists<State>("./state.json");
|
|
141
|
+
// missing returns null; malformed JSON still throws
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Error reference
|
|
145
|
+
|
|
146
|
+
| Throw / return | When |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `null` (lenient reads) | File missing or contents are not valid JSON. |
|
|
149
|
+
| `JsonFileReadError` | `readJson` or `readJsonIfExists` saw unreadable or invalid input. Inspect `cause`. |
|
|
150
|
+
| Native `NodeJS.ErrnoException` | Lower-level fs errors not wrapped. |
|
|
151
|
+
|
|
152
|
+
## See also
|
|
153
|
+
|
|
154
|
+
- [JSON store](json-store.md) — a single-file state wrapper with explicit per-call fallback (`readOr` / `updateOr`) and optional sidecar locking.
|
|
155
|
+
- [Atomic writes](atomic.md) — lower-level sibling-temp replacement helpers.
|
|
156
|
+
- [Secret files](secret-file.md) — JSON-or-text writes with mode 0600 in mode 0700 dirs.
|
|
157
|
+
- [Private file-store mode](private-file-store.md) — root-bounded JSON+text state stores.
|
|
158
|
+
- [File lock](sidecar-lock.md) — cross-process coordination.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Local roots
|
|
2
|
+
|
|
3
|
+
`local-roots` is a small set of helpers for code that holds a list of trusted base directories ("roots") and wants to look up an absolute path or a relative-to-some-root reference against any of them.
|
|
4
|
+
|
|
5
|
+
The shape covers two needs:
|
|
6
|
+
|
|
7
|
+
- "Resolve this path string to an absolute path that lives inside one of the configured roots, or refuse it."
|
|
8
|
+
- "Read this path, where it could be `/abs/path`, `~/relative`, `file://…`, or simply the basename of a file in one of my roots."
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
readLocalFileFromRoots,
|
|
13
|
+
resolveLocalPathFromRootsSync,
|
|
14
|
+
} from "@openclaw/fs-safe/advanced";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Shape of a "roots input"
|
|
18
|
+
|
|
19
|
+
Both helpers take roots as either an array of strings or a `LocalRootsInputOptions` record. Each root is an absolute path the caller already trusts:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
type LocalRootsInputOptions = {
|
|
23
|
+
roots: string[]; // absolute paths
|
|
24
|
+
allowAbsolute?: boolean; // accept absolute inputs (default true)
|
|
25
|
+
allowFileUrls?: boolean; // accept file:// URLs (default true)
|
|
26
|
+
expandHome?: boolean; // expand ~ in inputs (default true)
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If a root is a symlink, it is canonicalized at lookup time. The helpers work in the order roots are listed: the first root that contains the resolved path wins.
|
|
31
|
+
|
|
32
|
+
## `resolveLocalPathFromRootsSync(input, options)`
|
|
33
|
+
|
|
34
|
+
Synchronous resolution. Returns:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
type LocalRootsPathResult =
|
|
38
|
+
| { ok: true; absolutePath: string; rootDir: string; relativePath: string }
|
|
39
|
+
| { ok: false; reason: "outside-roots" | "invalid-input" };
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { resolveLocalPathFromRootsSync } from "@openclaw/fs-safe/advanced";
|
|
44
|
+
|
|
45
|
+
const r = resolveLocalPathFromRootsSync("photo.jpg", {
|
|
46
|
+
roots: ["/srv/uploads", "/srv/cache"],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!r.ok) return reply(400, r.reason);
|
|
50
|
+
console.log(r.absolutePath); // /srv/uploads/photo.jpg (assuming it's there)
|
|
51
|
+
console.log(r.rootDir); // /srv/uploads
|
|
52
|
+
console.log(r.relativePath); // photo.jpg
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Resolution order
|
|
56
|
+
|
|
57
|
+
For each candidate input:
|
|
58
|
+
|
|
59
|
+
1. If the input is a `file://` URL and `allowFileUrls` is true, decode to an absolute path.
|
|
60
|
+
2. If the input begins with `~/` and `expandHome` is true, expand to the user's home dir.
|
|
61
|
+
3. If the input is absolute (`/...` or Windows drive) and `allowAbsolute` is true, accept it as-is and check it falls under one of the roots.
|
|
62
|
+
4. Otherwise, treat the input as relative and resolve it against each root in order until one contains the resulting path.
|
|
63
|
+
|
|
64
|
+
If no root contains the result, returns `{ ok: false, reason: "outside-roots" }`.
|
|
65
|
+
|
|
66
|
+
`"invalid-input"` covers empty strings, embedded NULs, encoded `..` traversal, Windows network paths (`\\server\share`), and other constructs that should not be resolved at all.
|
|
67
|
+
|
|
68
|
+
## `readLocalFileFromRoots(input, options)`
|
|
69
|
+
|
|
70
|
+
Async. Resolves through the same logic, then reads the file via [`Root`](root.md) so the read benefits from boundary checks, `O_NOFOLLOW`, and fd identity verification.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
type LocalRootsReadResult = ReadResult & {
|
|
74
|
+
rootDir: string;
|
|
75
|
+
relativePath: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const r = await readLocalFileFromRoots("photo.jpg", {
|
|
79
|
+
roots: ["/srv/uploads", "/srv/cache"],
|
|
80
|
+
maxBytes: 8 * 1024 * 1024,
|
|
81
|
+
});
|
|
82
|
+
if (!r) return reply(404);
|
|
83
|
+
process.stdout.write(r.buffer);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The result extends `ReadResult` (`{ buffer, realPath, stat }`) with the matched `rootDir` and the path relative to it. Returns `null` if the input doesn't resolve into any root or the file is missing.
|
|
87
|
+
|
|
88
|
+
### Read options
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
|
|
92
|
+
hardlinks?: "reject" | "allow";
|
|
93
|
+
maxBytes?: number;
|
|
94
|
+
symlinks?: "reject" | "follow-within-root";
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The read-side options are forwarded to `Root` for the actual read.
|
|
99
|
+
|
|
100
|
+
## `local-file-access` companions
|
|
101
|
+
|
|
102
|
+
The `local-file-access` module (re-exported from `@openclaw/fs-safe/advanced`) supplies a few small helpers for input normalization that the roots helpers use under the hood. They are also useful on their own:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import {
|
|
106
|
+
assertNoWindowsNetworkPath,
|
|
107
|
+
basenameFromMediaSource,
|
|
108
|
+
hasEncodedFileUrlSeparator,
|
|
109
|
+
isWindowsDriveLetterPath,
|
|
110
|
+
isWindowsNetworkPath,
|
|
111
|
+
safeFileURLToPath,
|
|
112
|
+
trySafeFileURLToPath,
|
|
113
|
+
} from "@openclaw/fs-safe/advanced";
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- `safeFileURLToPath(fileUrl)` — `url.fileURLToPath` with explicit error throwing. Refuses URLs that decode to network paths.
|
|
117
|
+
- `trySafeFileURLToPath(fileUrl)` — same, returns `undefined` instead of throwing.
|
|
118
|
+
- `isWindowsDriveLetterPath(p, platform?)` — true for `C:\...` style absolute paths when the platform is Windows.
|
|
119
|
+
- `isWindowsNetworkPath(p, platform?)` — true for `\\server\share` and `//server/share` style paths when the platform is Windows.
|
|
120
|
+
- `assertNoWindowsNetworkPath(p, label?)` — throws if it is.
|
|
121
|
+
- `basenameFromMediaSource(source?)` — best-effort filename extraction from URLs / data URIs / paths, for naming downloaded media.
|
|
122
|
+
- `hasEncodedFileUrlSeparator(pathname)` — true for paths containing percent-encoded `/` (`%2F` / `%5C`), which often indicate traversal attempts.
|
|
123
|
+
|
|
124
|
+
## Common patterns
|
|
125
|
+
|
|
126
|
+
### Multi-root config: search project, then user, then system
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const text = await readLocalFileFromRoots(name, {
|
|
130
|
+
roots: [path.join(projectDir, "templates"), path.join(homedir(), ".app/templates"), "/etc/app/templates"],
|
|
131
|
+
allowAbsolute: false, // only resolve names, never absolute paths
|
|
132
|
+
maxBytes: 256 * 1024,
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Validate a file:// URL at the API boundary
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { safeFileURLToPath, isWindowsNetworkPath } from "@openclaw/fs-safe/advanced";
|
|
140
|
+
|
|
141
|
+
let abs: string;
|
|
142
|
+
try {
|
|
143
|
+
abs = safeFileURLToPath(req.body.fileUrl);
|
|
144
|
+
} catch {
|
|
145
|
+
return reply(400, "invalid file URL");
|
|
146
|
+
}
|
|
147
|
+
if (isWindowsNetworkPath(abs)) return reply(400, "network paths not allowed");
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Deny absolute, allow relative-only
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const r = resolveLocalPathFromRootsSync(input, {
|
|
154
|
+
roots: ["/srv/workspace"],
|
|
155
|
+
allowAbsolute: false,
|
|
156
|
+
allowFileUrls: false,
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## See also
|
|
161
|
+
|
|
162
|
+
- [`root()`](root.md) — single-root variant of this multi-root setup.
|
|
163
|
+
- [Path helpers](path.md) — `isPathInside`, `safeRealpathSync` for ad-hoc checks.
|
|
164
|
+
- [`pathScope()`](path-scope.md) — single-root with `Result`-style returns.
|