@zodal/dials-store-jsonc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/index.cjs +107 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @zodal/dials-store-jsonc
|
|
2
|
+
|
|
3
|
+
JSONC-file [`LayerStore`](https://github.com/i2mint/zodal-dials) for **zodal-dials** with **format-preserving** writes (VS Code `settings.json` style). **Node-only.**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i @zodal/dials-store-jsonc @zodal/dials-core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createJsoncStore } from '@zodal/dials-store-jsonc';
|
|
11
|
+
|
|
12
|
+
const store = createJsoncStore({ path: '~/.config/myapp/settings.jsonc' });
|
|
13
|
+
await store.load(); // { 'editor.fontSize': 14, ... }
|
|
14
|
+
await store.save({ 'editor.fontSize': 16 }); // comments & key order preserved
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The file is a **flat** map of dotted keys → values (`{"editor.fontSize": 14}`), comments allowed. Writes apply targeted `jsonc-parser` edits (comments/order/whitespace preserved), `mkdir` the parent, and write atomically (temp + rename). Saves on one store are **serialized**. The `UNSET` sentinel deletes a key; a plain `undefined` is skipped.
|
|
18
|
+
|
|
19
|
+
| Capability | |
|
|
20
|
+
|---|---|
|
|
21
|
+
| readable | ✅ |
|
|
22
|
+
| writable | ✅ |
|
|
23
|
+
| watchable | ❌ |
|
|
24
|
+
|
|
25
|
+
## Security
|
|
26
|
+
|
|
27
|
+
A settings file is plaintext. Either pass **`sensitivityFor`** so the store redacts `secret` keys on save (fail-closed, they never hit disk), or split secrets out yourself (dials-core's `splitBySensitivity`) **before** calling `save`.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
createJsoncStore({ path, sensitivityFor: dials.sensitivityFor }); // secret keys never written
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Part of the [zodal-dials](https://github.com/i2mint/zodal-dials) ecosystem.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
createJsoncStore: () => createJsoncStore
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(index_exports);
|
|
36
|
+
var import_jsonc_parser = require("jsonc-parser");
|
|
37
|
+
var import_dials_core = require("@zodal/dials-core");
|
|
38
|
+
function isPlainObject(value) {
|
|
39
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
var tmpCounter = 0;
|
|
42
|
+
function nodeFileIO() {
|
|
43
|
+
return {
|
|
44
|
+
async read(path) {
|
|
45
|
+
try {
|
|
46
|
+
const fs = await import("fs/promises");
|
|
47
|
+
return await fs.readFile(path, "utf8");
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === "ENOENT") return void 0;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
async write(path, text) {
|
|
54
|
+
const fs = await import("fs/promises");
|
|
55
|
+
const nodePath = await import("path");
|
|
56
|
+
await fs.mkdir(nodePath.dirname(path), { recursive: true });
|
|
57
|
+
const pid = globalThis.process?.pid ?? 0;
|
|
58
|
+
const tmp = `${path}.tmp-${pid}-${tmpCounter += 1}`;
|
|
59
|
+
await fs.writeFile(tmp, text, "utf8");
|
|
60
|
+
await fs.rename(tmp, path);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function createJsoncStore(options) {
|
|
65
|
+
const scope = options.scope ?? "file";
|
|
66
|
+
const io = options.fs ?? nodeFileIO();
|
|
67
|
+
const sensitivityFor = options.sensitivityFor;
|
|
68
|
+
let writeQueue = Promise.resolve();
|
|
69
|
+
return {
|
|
70
|
+
scope,
|
|
71
|
+
getCapabilities: () => ({ readable: true, writable: true, watchable: false }),
|
|
72
|
+
async load() {
|
|
73
|
+
const text = await io.read(options.path);
|
|
74
|
+
if (text === void 0 || text.trim() === "") return {};
|
|
75
|
+
const parsed = (0, import_jsonc_parser.parse)(text);
|
|
76
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
77
|
+
},
|
|
78
|
+
save(layer) {
|
|
79
|
+
const run = async () => {
|
|
80
|
+
let text = await io.read(options.path) ?? "{}";
|
|
81
|
+
if (text.trim() === "" || !isPlainObject((0, import_jsonc_parser.parse)(text))) text = "{}";
|
|
82
|
+
const formattingOptions = { tabSize: 2, insertSpaces: true, eol: "\n" };
|
|
83
|
+
for (const [key, value] of Object.entries(layer)) {
|
|
84
|
+
if (sensitivityFor && sensitivityFor(key) === "secret") continue;
|
|
85
|
+
let newValue;
|
|
86
|
+
if ((0, import_dials_core.isUnset)(value)) {
|
|
87
|
+
newValue = void 0;
|
|
88
|
+
} else if (value === void 0) {
|
|
89
|
+
continue;
|
|
90
|
+
} else {
|
|
91
|
+
newValue = value;
|
|
92
|
+
}
|
|
93
|
+
const edits = (0, import_jsonc_parser.modify)(text, [key], newValue, { formattingOptions });
|
|
94
|
+
text = (0, import_jsonc_parser.applyEdits)(text, edits);
|
|
95
|
+
}
|
|
96
|
+
await io.write(options.path, text);
|
|
97
|
+
};
|
|
98
|
+
writeQueue = writeQueue.then(run, run);
|
|
99
|
+
return writeQueue;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
104
|
+
0 && (module.exports = {
|
|
105
|
+
createJsoncStore
|
|
106
|
+
});
|
|
107
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @zodal/dials-store-jsonc — a JSONC-file `LayerStore` for zodal-dials. **Node-only** (it owns a file).\n *\n * The file is a FLAT map of dotted setting keys -> values (the VS Code `settings.json` convention),\n * with `//` and block comments allowed. Reads parse it to a layer; writes are FORMAT-PRESERVING —\n * comments, key order, and whitespace are kept — by applying targeted `jsonc-parser` edits rather\n * than re-stringifying. The UNSET sentinel removes a key; a plain `undefined` is skipped (it is NOT\n * a delete). File IO is injectable (defaults to Node fs, which mkdir's the parent and writes\n * atomically via temp-file + rename). Saves on one store are serialized to avoid lost updates.\n *\n * SECURITY: a settings file is plaintext. Pass `sensitivityFor` to REDACT secret keys on save (they\n * are never written to disk); otherwise the caller MUST split secrets out (e.g. dials-core's\n * `splitBySensitivity`) before calling `save`. See the README.\n */\n\nimport { applyEdits, modify, parse } from 'jsonc-parser';\nimport { isUnset } from '@zodal/dials-core';\nimport type { Layer, LayerStore, LayerStoreCapabilities, Sensitivity } from '@zodal/dials-core';\n\n/** Minimal file IO contract (injectable for tests / non-Node hosts). `read` returns undefined when\n * the file is absent. */\nexport interface FileIO {\n read(path: string): Promise<string | undefined>;\n write(path: string, text: string): Promise<void>;\n}\n\nexport interface JsoncStoreOptions {\n /** Scope id. Default: 'file'. */\n scope?: string;\n /** Path to the JSONC file. */\n path: string;\n /** File IO. Default: Node fs (mkdir parent + atomic temp-then-rename; read returns undefined on ENOENT). */\n fs?: FileIO;\n /** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save (never\n * written to disk) — fail-closed. Without it, the caller is responsible for excluding secrets. */\n sensitivityFor?: (key: string) => Sensitivity;\n}\n\nfunction isPlainObject(value: unknown): boolean {\n return !!value && typeof value === 'object' && !Array.isArray(value);\n}\n\nlet tmpCounter = 0;\n\nfunction nodeFileIO(): FileIO {\n return {\n async read(path: string): Promise<string | undefined> {\n try {\n const fs = await import('node:fs/promises');\n return await fs.readFile(path, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n },\n async write(path: string, text: string): Promise<void> {\n const fs = await import('node:fs/promises');\n const nodePath = await import('node:path');\n await fs.mkdir(nodePath.dirname(path), { recursive: true });\n // Atomic-ish: write a temp sibling then rename over the target so a crash can't truncate it.\n const pid = (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;\n const tmp = `${path}.tmp-${pid}-${(tmpCounter += 1)}`;\n await fs.writeFile(tmp, text, 'utf8');\n await fs.rename(tmp, path);\n },\n };\n}\n\n/** Create a JSONC-file LayerStore with format-preserving, secret-aware, serialized writes. */\nexport function createJsoncStore(options: JsoncStoreOptions): LayerStore {\n const scope = options.scope ?? 'file';\n const io = options.fs ?? nodeFileIO();\n const sensitivityFor = options.sensitivityFor;\n let writeQueue: Promise<void> = Promise.resolve();\n\n return {\n scope,\n getCapabilities: (): LayerStoreCapabilities => ({ readable: true, writable: true, watchable: false }),\n\n async load(): Promise<Layer> {\n const text = await io.read(options.path);\n if (text === undefined || text.trim() === '') return {};\n const parsed = parse(text) as unknown;\n return isPlainObject(parsed) ? (parsed as Layer) : {};\n },\n\n save(layer: Layer): Promise<void> {\n const run = async (): Promise<void> => {\n let text = (await io.read(options.path)) ?? '{}';\n // Reset a non-object root (array/scalar/null/empty) to an empty object so edits never throw.\n if (text.trim() === '' || !isPlainObject(parse(text))) text = '{}';\n const formattingOptions = { tabSize: 2, insertSpaces: true, eol: '\\n' };\n for (const [key, value] of Object.entries(layer)) {\n if (sensitivityFor && sensitivityFor(key) === 'secret') continue; // never persist secrets\n let newValue: unknown;\n if (isUnset(value)) {\n newValue = undefined; // jsonc modify with undefined deletes the property\n } else if (value === undefined) {\n continue; // a plain `undefined` is NOT a delete (distinct from the UNSET sentinel) — skip\n } else {\n newValue = value;\n }\n const edits = modify(text, [key], newValue, { formattingOptions });\n text = applyEdits(text, edits);\n }\n await io.write(options.path, text);\n };\n // Serialize saves to this store so concurrent calls can't lose updates (read-modify-write).\n writeQueue = writeQueue.then(run, run);\n return writeQueue;\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAeA,0BAA0C;AAC1C,wBAAwB;AAsBxB,SAAS,cAAc,OAAyB;AAC9C,SAAO,CAAC,CAAC,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK;AACrE;AAEA,IAAI,aAAa;AAEjB,SAAS,aAAqB;AAC5B,SAAO;AAAA,IACL,MAAM,KAAK,MAA2C;AACpD,UAAI;AACF,cAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,eAAO,MAAM,GAAG,SAAS,MAAM,MAAM;AAAA,MACvC,SAAS,OAAO;AACd,YAAK,MAA4B,SAAS,SAAU,QAAO;AAC3D,cAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,MAAM,MAAM,MAAc,MAA6B;AACrD,YAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,YAAM,WAAW,MAAM,OAAO,MAAW;AACzC,YAAM,GAAG,MAAM,SAAS,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAE1D,YAAM,MAAO,WAA8C,SAAS,OAAO;AAC3E,YAAM,MAAM,GAAG,IAAI,QAAQ,GAAG,IAAK,cAAc,CAAE;AACnD,YAAM,GAAG,UAAU,KAAK,MAAM,MAAM;AACpC,YAAM,GAAG,OAAO,KAAK,IAAI;AAAA,IAC3B;AAAA,EACF;AACF;AAGO,SAAS,iBAAiB,SAAwC;AACvE,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,KAAK,QAAQ,MAAM,WAAW;AACpC,QAAM,iBAAiB,QAAQ;AAC/B,MAAI,aAA4B,QAAQ,QAAQ;AAEhD,SAAO;AAAA,IACL;AAAA,IACA,iBAAiB,OAA+B,EAAE,UAAU,MAAM,UAAU,MAAM,WAAW,MAAM;AAAA,IAEnG,MAAM,OAAuB;AAC3B,YAAM,OAAO,MAAM,GAAG,KAAK,QAAQ,IAAI;AACvC,UAAI,SAAS,UAAa,KAAK,KAAK,MAAM,GAAI,QAAO,CAAC;AACtD,YAAM,aAAS,2BAAM,IAAI;AACzB,aAAO,cAAc,MAAM,IAAK,SAAmB,CAAC;AAAA,IACtD;AAAA,IAEA,KAAK,OAA6B;AAChC,YAAM,MAAM,YAA2B;AACrC,YAAI,OAAQ,MAAM,GAAG,KAAK,QAAQ,IAAI,KAAM;AAE5C,YAAI,KAAK,KAAK,MAAM,MAAM,CAAC,kBAAc,2BAAM,IAAI,CAAC,EAAG,QAAO;AAC9D,cAAM,oBAAoB,EAAE,SAAS,GAAG,cAAc,MAAM,KAAK,KAAK;AACtE,mBAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,cAAI,kBAAkB,eAAe,GAAG,MAAM,SAAU;AACxD,cAAI;AACJ,kBAAI,2BAAQ,KAAK,GAAG;AAClB,uBAAW;AAAA,UACb,WAAW,UAAU,QAAW;AAC9B;AAAA,UACF,OAAO;AACL,uBAAW;AAAA,UACb;AACA,gBAAM,YAAQ,4BAAO,MAAM,CAAC,GAAG,GAAG,UAAU,EAAE,kBAAkB,CAAC;AACjE,qBAAO,gCAAW,MAAM,KAAK;AAAA,QAC/B;AACA,cAAM,GAAG,MAAM,QAAQ,MAAM,IAAI;AAAA,MACnC;AAEA,mBAAa,WAAW,KAAK,KAAK,GAAG;AACrC,aAAO;AAAA,IACT;AAAA,EACF;AACF;","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Sensitivity, LayerStore } from '@zodal/dials-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @zodal/dials-store-jsonc — a JSONC-file `LayerStore` for zodal-dials. **Node-only** (it owns a file).
|
|
5
|
+
*
|
|
6
|
+
* The file is a FLAT map of dotted setting keys -> values (the VS Code `settings.json` convention),
|
|
7
|
+
* with `//` and block comments allowed. Reads parse it to a layer; writes are FORMAT-PRESERVING —
|
|
8
|
+
* comments, key order, and whitespace are kept — by applying targeted `jsonc-parser` edits rather
|
|
9
|
+
* than re-stringifying. The UNSET sentinel removes a key; a plain `undefined` is skipped (it is NOT
|
|
10
|
+
* a delete). File IO is injectable (defaults to Node fs, which mkdir's the parent and writes
|
|
11
|
+
* atomically via temp-file + rename). Saves on one store are serialized to avoid lost updates.
|
|
12
|
+
*
|
|
13
|
+
* SECURITY: a settings file is plaintext. Pass `sensitivityFor` to REDACT secret keys on save (they
|
|
14
|
+
* are never written to disk); otherwise the caller MUST split secrets out (e.g. dials-core's
|
|
15
|
+
* `splitBySensitivity`) before calling `save`. See the README.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal file IO contract (injectable for tests / non-Node hosts). `read` returns undefined when
|
|
19
|
+
* the file is absent. */
|
|
20
|
+
interface FileIO {
|
|
21
|
+
read(path: string): Promise<string | undefined>;
|
|
22
|
+
write(path: string, text: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
interface JsoncStoreOptions {
|
|
25
|
+
/** Scope id. Default: 'file'. */
|
|
26
|
+
scope?: string;
|
|
27
|
+
/** Path to the JSONC file. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** File IO. Default: Node fs (mkdir parent + atomic temp-then-rename; read returns undefined on ENOENT). */
|
|
30
|
+
fs?: FileIO;
|
|
31
|
+
/** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save (never
|
|
32
|
+
* written to disk) — fail-closed. Without it, the caller is responsible for excluding secrets. */
|
|
33
|
+
sensitivityFor?: (key: string) => Sensitivity;
|
|
34
|
+
}
|
|
35
|
+
/** Create a JSONC-file LayerStore with format-preserving, secret-aware, serialized writes. */
|
|
36
|
+
declare function createJsoncStore(options: JsoncStoreOptions): LayerStore;
|
|
37
|
+
|
|
38
|
+
export { type FileIO, type JsoncStoreOptions, createJsoncStore };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Sensitivity, LayerStore } from '@zodal/dials-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @zodal/dials-store-jsonc — a JSONC-file `LayerStore` for zodal-dials. **Node-only** (it owns a file).
|
|
5
|
+
*
|
|
6
|
+
* The file is a FLAT map of dotted setting keys -> values (the VS Code `settings.json` convention),
|
|
7
|
+
* with `//` and block comments allowed. Reads parse it to a layer; writes are FORMAT-PRESERVING —
|
|
8
|
+
* comments, key order, and whitespace are kept — by applying targeted `jsonc-parser` edits rather
|
|
9
|
+
* than re-stringifying. The UNSET sentinel removes a key; a plain `undefined` is skipped (it is NOT
|
|
10
|
+
* a delete). File IO is injectable (defaults to Node fs, which mkdir's the parent and writes
|
|
11
|
+
* atomically via temp-file + rename). Saves on one store are serialized to avoid lost updates.
|
|
12
|
+
*
|
|
13
|
+
* SECURITY: a settings file is plaintext. Pass `sensitivityFor` to REDACT secret keys on save (they
|
|
14
|
+
* are never written to disk); otherwise the caller MUST split secrets out (e.g. dials-core's
|
|
15
|
+
* `splitBySensitivity`) before calling `save`. See the README.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal file IO contract (injectable for tests / non-Node hosts). `read` returns undefined when
|
|
19
|
+
* the file is absent. */
|
|
20
|
+
interface FileIO {
|
|
21
|
+
read(path: string): Promise<string | undefined>;
|
|
22
|
+
write(path: string, text: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
interface JsoncStoreOptions {
|
|
25
|
+
/** Scope id. Default: 'file'. */
|
|
26
|
+
scope?: string;
|
|
27
|
+
/** Path to the JSONC file. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** File IO. Default: Node fs (mkdir parent + atomic temp-then-rename; read returns undefined on ENOENT). */
|
|
30
|
+
fs?: FileIO;
|
|
31
|
+
/** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save (never
|
|
32
|
+
* written to disk) — fail-closed. Without it, the caller is responsible for excluding secrets. */
|
|
33
|
+
sensitivityFor?: (key: string) => Sensitivity;
|
|
34
|
+
}
|
|
35
|
+
/** Create a JSONC-file LayerStore with format-preserving, secret-aware, serialized writes. */
|
|
36
|
+
declare function createJsoncStore(options: JsoncStoreOptions): LayerStore;
|
|
37
|
+
|
|
38
|
+
export { type FileIO, type JsoncStoreOptions, createJsoncStore };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
3
|
+
import { isUnset } from "@zodal/dials-core";
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
var tmpCounter = 0;
|
|
8
|
+
function nodeFileIO() {
|
|
9
|
+
return {
|
|
10
|
+
async read(path) {
|
|
11
|
+
try {
|
|
12
|
+
const fs = await import("fs/promises");
|
|
13
|
+
return await fs.readFile(path, "utf8");
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code === "ENOENT") return void 0;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
async write(path, text) {
|
|
20
|
+
const fs = await import("fs/promises");
|
|
21
|
+
const nodePath = await import("path");
|
|
22
|
+
await fs.mkdir(nodePath.dirname(path), { recursive: true });
|
|
23
|
+
const pid = globalThis.process?.pid ?? 0;
|
|
24
|
+
const tmp = `${path}.tmp-${pid}-${tmpCounter += 1}`;
|
|
25
|
+
await fs.writeFile(tmp, text, "utf8");
|
|
26
|
+
await fs.rename(tmp, path);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function createJsoncStore(options) {
|
|
31
|
+
const scope = options.scope ?? "file";
|
|
32
|
+
const io = options.fs ?? nodeFileIO();
|
|
33
|
+
const sensitivityFor = options.sensitivityFor;
|
|
34
|
+
let writeQueue = Promise.resolve();
|
|
35
|
+
return {
|
|
36
|
+
scope,
|
|
37
|
+
getCapabilities: () => ({ readable: true, writable: true, watchable: false }),
|
|
38
|
+
async load() {
|
|
39
|
+
const text = await io.read(options.path);
|
|
40
|
+
if (text === void 0 || text.trim() === "") return {};
|
|
41
|
+
const parsed = parse(text);
|
|
42
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
43
|
+
},
|
|
44
|
+
save(layer) {
|
|
45
|
+
const run = async () => {
|
|
46
|
+
let text = await io.read(options.path) ?? "{}";
|
|
47
|
+
if (text.trim() === "" || !isPlainObject(parse(text))) text = "{}";
|
|
48
|
+
const formattingOptions = { tabSize: 2, insertSpaces: true, eol: "\n" };
|
|
49
|
+
for (const [key, value] of Object.entries(layer)) {
|
|
50
|
+
if (sensitivityFor && sensitivityFor(key) === "secret") continue;
|
|
51
|
+
let newValue;
|
|
52
|
+
if (isUnset(value)) {
|
|
53
|
+
newValue = void 0;
|
|
54
|
+
} else if (value === void 0) {
|
|
55
|
+
continue;
|
|
56
|
+
} else {
|
|
57
|
+
newValue = value;
|
|
58
|
+
}
|
|
59
|
+
const edits = modify(text, [key], newValue, { formattingOptions });
|
|
60
|
+
text = applyEdits(text, edits);
|
|
61
|
+
}
|
|
62
|
+
await io.write(options.path, text);
|
|
63
|
+
};
|
|
64
|
+
writeQueue = writeQueue.then(run, run);
|
|
65
|
+
return writeQueue;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
createJsoncStore
|
|
71
|
+
};
|
|
72
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @zodal/dials-store-jsonc — a JSONC-file `LayerStore` for zodal-dials. **Node-only** (it owns a file).\n *\n * The file is a FLAT map of dotted setting keys -> values (the VS Code `settings.json` convention),\n * with `//` and block comments allowed. Reads parse it to a layer; writes are FORMAT-PRESERVING —\n * comments, key order, and whitespace are kept — by applying targeted `jsonc-parser` edits rather\n * than re-stringifying. The UNSET sentinel removes a key; a plain `undefined` is skipped (it is NOT\n * a delete). File IO is injectable (defaults to Node fs, which mkdir's the parent and writes\n * atomically via temp-file + rename). Saves on one store are serialized to avoid lost updates.\n *\n * SECURITY: a settings file is plaintext. Pass `sensitivityFor` to REDACT secret keys on save (they\n * are never written to disk); otherwise the caller MUST split secrets out (e.g. dials-core's\n * `splitBySensitivity`) before calling `save`. See the README.\n */\n\nimport { applyEdits, modify, parse } from 'jsonc-parser';\nimport { isUnset } from '@zodal/dials-core';\nimport type { Layer, LayerStore, LayerStoreCapabilities, Sensitivity } from '@zodal/dials-core';\n\n/** Minimal file IO contract (injectable for tests / non-Node hosts). `read` returns undefined when\n * the file is absent. */\nexport interface FileIO {\n read(path: string): Promise<string | undefined>;\n write(path: string, text: string): Promise<void>;\n}\n\nexport interface JsoncStoreOptions {\n /** Scope id. Default: 'file'. */\n scope?: string;\n /** Path to the JSONC file. */\n path: string;\n /** File IO. Default: Node fs (mkdir parent + atomic temp-then-rename; read returns undefined on ENOENT). */\n fs?: FileIO;\n /** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save (never\n * written to disk) — fail-closed. Without it, the caller is responsible for excluding secrets. */\n sensitivityFor?: (key: string) => Sensitivity;\n}\n\nfunction isPlainObject(value: unknown): boolean {\n return !!value && typeof value === 'object' && !Array.isArray(value);\n}\n\nlet tmpCounter = 0;\n\nfunction nodeFileIO(): FileIO {\n return {\n async read(path: string): Promise<string | undefined> {\n try {\n const fs = await import('node:fs/promises');\n return await fs.readFile(path, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n },\n async write(path: string, text: string): Promise<void> {\n const fs = await import('node:fs/promises');\n const nodePath = await import('node:path');\n await fs.mkdir(nodePath.dirname(path), { recursive: true });\n // Atomic-ish: write a temp sibling then rename over the target so a crash can't truncate it.\n const pid = (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;\n const tmp = `${path}.tmp-${pid}-${(tmpCounter += 1)}`;\n await fs.writeFile(tmp, text, 'utf8');\n await fs.rename(tmp, path);\n },\n };\n}\n\n/** Create a JSONC-file LayerStore with format-preserving, secret-aware, serialized writes. */\nexport function createJsoncStore(options: JsoncStoreOptions): LayerStore {\n const scope = options.scope ?? 'file';\n const io = options.fs ?? nodeFileIO();\n const sensitivityFor = options.sensitivityFor;\n let writeQueue: Promise<void> = Promise.resolve();\n\n return {\n scope,\n getCapabilities: (): LayerStoreCapabilities => ({ readable: true, writable: true, watchable: false }),\n\n async load(): Promise<Layer> {\n const text = await io.read(options.path);\n if (text === undefined || text.trim() === '') return {};\n const parsed = parse(text) as unknown;\n return isPlainObject(parsed) ? (parsed as Layer) : {};\n },\n\n save(layer: Layer): Promise<void> {\n const run = async (): Promise<void> => {\n let text = (await io.read(options.path)) ?? '{}';\n // Reset a non-object root (array/scalar/null/empty) to an empty object so edits never throw.\n if (text.trim() === '' || !isPlainObject(parse(text))) text = '{}';\n const formattingOptions = { tabSize: 2, insertSpaces: true, eol: '\\n' };\n for (const [key, value] of Object.entries(layer)) {\n if (sensitivityFor && sensitivityFor(key) === 'secret') continue; // never persist secrets\n let newValue: unknown;\n if (isUnset(value)) {\n newValue = undefined; // jsonc modify with undefined deletes the property\n } else if (value === undefined) {\n continue; // a plain `undefined` is NOT a delete (distinct from the UNSET sentinel) — skip\n } else {\n newValue = value;\n }\n const edits = modify(text, [key], newValue, { formattingOptions });\n text = applyEdits(text, edits);\n }\n await io.write(options.path, text);\n };\n // Serialize saves to this store so concurrent calls can't lose updates (read-modify-write).\n writeQueue = writeQueue.then(run, run);\n return writeQueue;\n },\n };\n}\n"],"mappings":";AAeA,SAAS,YAAY,QAAQ,aAAa;AAC1C,SAAS,eAAe;AAsBxB,SAAS,cAAc,OAAyB;AAC9C,SAAO,CAAC,CAAC,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK;AACrE;AAEA,IAAI,aAAa;AAEjB,SAAS,aAAqB;AAC5B,SAAO;AAAA,IACL,MAAM,KAAK,MAA2C;AACpD,UAAI;AACF,cAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,eAAO,MAAM,GAAG,SAAS,MAAM,MAAM;AAAA,MACvC,SAAS,OAAO;AACd,YAAK,MAA4B,SAAS,SAAU,QAAO;AAC3D,cAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,MAAM,MAAM,MAAc,MAA6B;AACrD,YAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,YAAM,WAAW,MAAM,OAAO,MAAW;AACzC,YAAM,GAAG,MAAM,SAAS,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAE1D,YAAM,MAAO,WAA8C,SAAS,OAAO;AAC3E,YAAM,MAAM,GAAG,IAAI,QAAQ,GAAG,IAAK,cAAc,CAAE;AACnD,YAAM,GAAG,UAAU,KAAK,MAAM,MAAM;AACpC,YAAM,GAAG,OAAO,KAAK,IAAI;AAAA,IAC3B;AAAA,EACF;AACF;AAGO,SAAS,iBAAiB,SAAwC;AACvE,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,KAAK,QAAQ,MAAM,WAAW;AACpC,QAAM,iBAAiB,QAAQ;AAC/B,MAAI,aAA4B,QAAQ,QAAQ;AAEhD,SAAO;AAAA,IACL;AAAA,IACA,iBAAiB,OAA+B,EAAE,UAAU,MAAM,UAAU,MAAM,WAAW,MAAM;AAAA,IAEnG,MAAM,OAAuB;AAC3B,YAAM,OAAO,MAAM,GAAG,KAAK,QAAQ,IAAI;AACvC,UAAI,SAAS,UAAa,KAAK,KAAK,MAAM,GAAI,QAAO,CAAC;AACtD,YAAM,SAAS,MAAM,IAAI;AACzB,aAAO,cAAc,MAAM,IAAK,SAAmB,CAAC;AAAA,IACtD;AAAA,IAEA,KAAK,OAA6B;AAChC,YAAM,MAAM,YAA2B;AACrC,YAAI,OAAQ,MAAM,GAAG,KAAK,QAAQ,IAAI,KAAM;AAE5C,YAAI,KAAK,KAAK,MAAM,MAAM,CAAC,cAAc,MAAM,IAAI,CAAC,EAAG,QAAO;AAC9D,cAAM,oBAAoB,EAAE,SAAS,GAAG,cAAc,MAAM,KAAK,KAAK;AACtE,mBAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,cAAI,kBAAkB,eAAe,GAAG,MAAM,SAAU;AACxD,cAAI;AACJ,cAAI,QAAQ,KAAK,GAAG;AAClB,uBAAW;AAAA,UACb,WAAW,UAAU,QAAW;AAC9B;AAAA,UACF,OAAO;AACL,uBAAW;AAAA,UACb;AACA,gBAAM,QAAQ,OAAO,MAAM,CAAC,GAAG,GAAG,UAAU,EAAE,kBAAkB,CAAC;AACjE,iBAAO,WAAW,MAAM,KAAK;AAAA,QAC/B;AACA,cAAM,GAAG,MAAM,QAAQ,MAAM,IAAI;AAAA,MACnC;AAEA,mBAAa,WAAW,KAAK,KAAK,GAAG;AACrC,aAAO;AAAA,IACT;AAAA,EACF;AACF;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zodal/dials-store-jsonc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JSONC-file LayerStore for zodal-dials with format-preserving writes (VS Code settings.json style)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"jsonc-parser": "^3.3.1"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@zodal/core": "^0.1.2",
|
|
32
|
+
"@zodal/dials-core": "^0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@zodal/core": "^0.1.2",
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"tsup": "^8.0.0",
|
|
38
|
+
"typescript": "^5.7.0",
|
|
39
|
+
"vitest": "^3.0.0",
|
|
40
|
+
"@zodal/dials-core": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
}
|
|
47
|
+
}
|