1 |
|
2 | 'use strict';
|
3 | const fs = require('fs');
|
4 | const path = require('path');
|
5 | const crypto = require('crypto');
|
6 | const assert = require('assert');
|
7 | const EventEmitter = require('events');
|
8 | const dotProp = require('dot-prop');
|
9 | const makeDir = require('make-dir');
|
10 | const pkgUp = require('pkg-up');
|
11 | const envPaths = require('env-paths');
|
12 | const writeFileAtomic = require('write-file-atomic');
|
13 | const Ajv = require('ajv');
|
14 |
|
15 | const plainObject = () => Object.create(null);
|
16 | const encryptionAlgorithm = 'aes-256-cbc';
|
17 |
|
18 |
|
19 | delete require.cache[__filename];
|
20 | const parentDir = path.dirname((module.parent && module.parent.filename) || '.');
|
21 |
|
22 | const checkValueType = (key, value) => {
|
23 | const nonJsonTypes = [
|
24 | 'undefined',
|
25 | 'symbol',
|
26 | 'function'
|
27 | ];
|
28 |
|
29 | const type = typeof value;
|
30 |
|
31 | if (nonJsonTypes.includes(type)) {
|
32 | throw new TypeError(`Setting a value of type \`${type}\` for key \`${key}\` is not allowed as it's not supported by JSON`);
|
33 | }
|
34 | };
|
35 |
|
36 | class Conf {
|
37 | constructor(options) {
|
38 | options = {
|
39 | configName: 'config',
|
40 | fileExtension: 'json',
|
41 | projectSuffix: 'nodejs',
|
42 | clearInvalidConfig: true,
|
43 | serialize: value => JSON.stringify(value, null, '\t'),
|
44 | deserialize: JSON.parse,
|
45 | accessPropertiesByDotNotation: true,
|
46 | ...options
|
47 | };
|
48 |
|
49 | if (!options.cwd) {
|
50 | if (!options.projectName) {
|
51 | const pkgPath = pkgUp.sync(parentDir);
|
52 |
|
53 |
|
54 | options.projectName = pkgPath && JSON.parse(fs.readFileSync(pkgPath, 'utf8')).name;
|
55 | }
|
56 |
|
57 | if (!options.projectName) {
|
58 | throw new Error('Project name could not be inferred. Please specify the `projectName` option.');
|
59 | }
|
60 |
|
61 | options.cwd = envPaths(options.projectName, {suffix: options.projectSuffix}).config;
|
62 | }
|
63 |
|
64 | this._options = options;
|
65 |
|
66 | if (options.schema) {
|
67 | if (typeof options.schema !== 'object') {
|
68 | throw new TypeError('The `schema` option must be an object.');
|
69 | }
|
70 |
|
71 | const ajv = new Ajv({
|
72 | allErrors: true,
|
73 | format: 'full',
|
74 | useDefaults: true,
|
75 | errorDataPath: 'property'
|
76 | });
|
77 | const schema = {
|
78 | type: 'object',
|
79 | properties: options.schema
|
80 | };
|
81 | this._validator = ajv.compile(schema);
|
82 | }
|
83 |
|
84 | this.events = new EventEmitter();
|
85 | this.encryptionKey = options.encryptionKey;
|
86 | this.serialize = options.serialize;
|
87 | this.deserialize = options.deserialize;
|
88 |
|
89 | const fileExtension = options.fileExtension ? `.${options.fileExtension}` : '';
|
90 | this.path = path.resolve(options.cwd, `${options.configName}${fileExtension}`);
|
91 |
|
92 | const fileStore = this.store;
|
93 | const store = Object.assign(plainObject(), options.defaults, fileStore);
|
94 | this._validate(store);
|
95 | try {
|
96 | assert.deepEqual(fileStore, store);
|
97 | } catch (_) {
|
98 | this.store = store;
|
99 | }
|
100 | }
|
101 |
|
102 | _validate(data) {
|
103 | if (!this._validator) {
|
104 | return;
|
105 | }
|
106 |
|
107 | const valid = this._validator(data);
|
108 | if (!valid) {
|
109 | const errors = this._validator.errors.reduce((error, {dataPath, message}) =>
|
110 | error + ` \`${dataPath.slice(1)}\` ${message};`, '');
|
111 | throw new Error('Config schema violation:' + errors.slice(0, -1));
|
112 | }
|
113 | }
|
114 |
|
115 | get(key, defaultValue) {
|
116 | if (this._options.accessPropertiesByDotNotation) {
|
117 | return dotProp.get(this.store, key, defaultValue);
|
118 | }
|
119 |
|
120 | return key in this.store ? this.store[key] : defaultValue;
|
121 | }
|
122 |
|
123 | set(key, value) {
|
124 | if (typeof key !== 'string' && typeof key !== 'object') {
|
125 | throw new TypeError(`Expected \`key\` to be of type \`string\` or \`object\`, got ${typeof key}`);
|
126 | }
|
127 |
|
128 | if (typeof key !== 'object' && value === undefined) {
|
129 | throw new TypeError('Use `delete()` to clear values');
|
130 | }
|
131 |
|
132 | const {store} = this;
|
133 |
|
134 | const set = (key, value) => {
|
135 | checkValueType(key, value);
|
136 | if (this._options.accessPropertiesByDotNotation) {
|
137 | dotProp.set(store, key, value);
|
138 | } else {
|
139 | store[key] = value;
|
140 | }
|
141 | };
|
142 |
|
143 | if (typeof key === 'object') {
|
144 | const object = key;
|
145 | for (const [key, value] of Object.entries(object)) {
|
146 | set(key, value);
|
147 | }
|
148 | } else {
|
149 | set(key, value);
|
150 | }
|
151 |
|
152 | this.store = store;
|
153 | }
|
154 |
|
155 | has(key) {
|
156 | if (this._options.accessPropertiesByDotNotation) {
|
157 | return dotProp.has(this.store, key);
|
158 | }
|
159 |
|
160 | return key in this.store;
|
161 | }
|
162 |
|
163 | delete(key) {
|
164 | const {store} = this;
|
165 | if (this._options.accessPropertiesByDotNotation) {
|
166 | dotProp.delete(store, key);
|
167 | } else {
|
168 | delete store[key];
|
169 | }
|
170 |
|
171 | this.store = store;
|
172 | }
|
173 |
|
174 | clear() {
|
175 | this.store = plainObject();
|
176 | }
|
177 |
|
178 | onDidChange(key, callback) {
|
179 | if (typeof key !== 'string') {
|
180 | throw new TypeError(`Expected \`key\` to be of type \`string\`, got ${typeof key}`);
|
181 | }
|
182 |
|
183 | if (typeof callback !== 'function') {
|
184 | throw new TypeError(`Expected \`callback\` to be of type \`function\`, got ${typeof callback}`);
|
185 | }
|
186 |
|
187 | const getter = () => this.get(key);
|
188 |
|
189 | return this.handleChange(getter, callback);
|
190 | }
|
191 |
|
192 | onDidAnyChange(callback) {
|
193 | if (typeof callback !== 'function') {
|
194 | throw new TypeError(`Expected \`callback\` to be of type \`function\`, got ${typeof callback}`);
|
195 | }
|
196 |
|
197 | const getter = () => this.store;
|
198 |
|
199 | return this.handleChange(getter, callback);
|
200 | }
|
201 |
|
202 | handleChange(getter, callback) {
|
203 | let currentValue = getter();
|
204 |
|
205 | const onChange = () => {
|
206 | const oldValue = currentValue;
|
207 | const newValue = getter();
|
208 |
|
209 | try {
|
210 |
|
211 | assert.deepEqual(newValue, oldValue);
|
212 | } catch (_) {
|
213 | currentValue = newValue;
|
214 | callback.call(this, newValue, oldValue);
|
215 | }
|
216 | };
|
217 |
|
218 | this.events.on('change', onChange);
|
219 | return () => this.events.removeListener('change', onChange);
|
220 | }
|
221 |
|
222 | get size() {
|
223 | return Object.keys(this.store).length;
|
224 | }
|
225 |
|
226 | get store() {
|
227 | try {
|
228 | let data = fs.readFileSync(this.path, this.encryptionKey ? null : 'utf8');
|
229 |
|
230 | if (this.encryptionKey) {
|
231 | try {
|
232 |
|
233 | if (data.slice(16, 17).toString() === ':') {
|
234 | const initializationVector = data.slice(0, 16);
|
235 | const password = crypto.pbkdf2Sync(this.encryptionKey, initializationVector.toString(), 10000, 32, 'sha512');
|
236 | const decipher = crypto.createDecipheriv(encryptionAlgorithm, password, initializationVector);
|
237 | data = Buffer.concat([decipher.update(data.slice(17)), decipher.final()]);
|
238 | } else {
|
239 | const decipher = crypto.createDecipher(encryptionAlgorithm, this.encryptionKey);
|
240 | data = Buffer.concat([decipher.update(data), decipher.final()]);
|
241 | }
|
242 | } catch (_) {}
|
243 | }
|
244 |
|
245 | data = this.deserialize(data);
|
246 | this._validate(data);
|
247 | return Object.assign(plainObject(), data);
|
248 | } catch (error) {
|
249 | if (error.code === 'ENOENT') {
|
250 |
|
251 | makeDir.sync(path.dirname(this.path));
|
252 | return plainObject();
|
253 | }
|
254 |
|
255 | if (this._options.clearInvalidConfig && error.name === 'SyntaxError') {
|
256 | return plainObject();
|
257 | }
|
258 |
|
259 | throw error;
|
260 | }
|
261 | }
|
262 |
|
263 | set store(value) {
|
264 |
|
265 | makeDir.sync(path.dirname(this.path));
|
266 |
|
267 | this._validate(value);
|
268 | let data = this.serialize(value);
|
269 |
|
270 | if (this.encryptionKey) {
|
271 | const initializationVector = crypto.randomBytes(16);
|
272 | const password = crypto.pbkdf2Sync(this.encryptionKey, initializationVector.toString(), 10000, 32, 'sha512');
|
273 | const cipher = crypto.createCipheriv(encryptionAlgorithm, password, initializationVector);
|
274 | data = Buffer.concat([initializationVector, Buffer.from(':'), cipher.update(Buffer.from(data)), cipher.final()]);
|
275 | }
|
276 |
|
277 | writeFileAtomic.sync(this.path, data);
|
278 | this.events.emit('change');
|
279 | }
|
280 |
|
281 | * [Symbol.iterator]() {
|
282 | for (const [key, value] of Object.entries(this.store)) {
|
283 | yield [key, value];
|
284 | }
|
285 | }
|
286 | }
|
287 |
|
288 | module.exports = Conf;
|