termdock 1.4.383 → 1.4.384

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.
@@ -0,0 +1,171 @@
1
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CollaborationError } from '../agent/collaborationProtocol.js';
5
+ const readActions = ['session.view', 'session.chat-view', 'session.terminal-view', 'session.metadata'];
6
+ const writeActions = [...readActions, 'session.chat-send', 'session.input', 'session.resize'];
7
+ /** Local integrations may lend access to their own sessions without lending their
8
+ * credential. The encrypted peer must prove the device identity in the ticket. */
9
+ export class SessionAccessStore {
10
+ file;
11
+ authorized;
12
+ now;
13
+ key;
14
+ states;
15
+ used = new Map();
16
+ constructor(file, authorized, now = Date.now) {
17
+ this.file = file;
18
+ this.authorized = authorized;
19
+ this.now = now;
20
+ try {
21
+ const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
22
+ if (saved.version !== 1 || typeof saved.key !== 'string' || !saved.states)
23
+ throw new Error('Invalid session access store');
24
+ this.key = Buffer.from(saved.key, 'base64');
25
+ this.states = saved.states;
26
+ if (this.key.length !== 32)
27
+ throw new Error('Invalid session access key');
28
+ if (saved.used !== undefined && (!Array.isArray(saved.used) || saved.used.some((entry) => !Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || !Number.isSafeInteger(entry[1])))) {
29
+ throw new Error('Invalid consumed session tickets');
30
+ }
31
+ this.used = new Map((saved.used ?? []).filter(([, expiry]) => expiry > this.now()));
32
+ }
33
+ catch (error) {
34
+ if (error.code !== 'ENOENT')
35
+ throw error;
36
+ this.key = randomBytes(32);
37
+ this.states = {};
38
+ this.save(this.states);
39
+ }
40
+ }
41
+ save(states, used = this.used) {
42
+ fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
43
+ const temp = `${this.file}.${randomUUID()}.tmp`;
44
+ const fd = fs.openSync(temp, 'wx', 0o600);
45
+ try {
46
+ fs.writeFileSync(fd, JSON.stringify({ version: 1, key: this.key.toString('base64'), states, used: [...used] }));
47
+ fs.fsyncSync(fd);
48
+ }
49
+ finally {
50
+ fs.closeSync(fd);
51
+ }
52
+ fs.renameSync(temp, this.file);
53
+ const directory = fs.openSync(path.dirname(this.file), 'r');
54
+ try {
55
+ fs.fsyncSync(directory);
56
+ }
57
+ finally {
58
+ fs.closeSync(directory);
59
+ }
60
+ this.states = states;
61
+ this.used = used;
62
+ }
63
+ /** Non-mutating recovery metadata; never disclose lease identities or keys. */
64
+ revision(sessionId, principalId) {
65
+ const state = this.states[sessionId];
66
+ if (!this.authorized(sessionId, principalId) || state && state.principalId !== principalId) {
67
+ throw new CollaborationError('SESSION_ACCESS_DENIED', 'SESSION_ACCESS_DENIED', 409);
68
+ }
69
+ return { fence: state?.fence || 0, readerFence: state?.readers?.fence || 0, exclusive: state?.exclusive === true };
70
+ }
71
+ /** Exclusive ownership also fences ordinary session-scoped invitations. The
72
+ * federation runtime preserves separately authenticated full-service admins. */
73
+ restrictsNativeWriter(sessionId, action) {
74
+ const state = this.states[sessionId];
75
+ return state?.exclusive === true && this.authorized(sessionId, state.principalId)
76
+ && ['session.input', 'session.chat-send', 'session.resize'].includes(action);
77
+ }
78
+ update(sessionId, principalId, input) {
79
+ const old = this.states[sessionId], lease = input?.lease, now = this.now();
80
+ if (!this.authorized(sessionId, principalId) || !Number.isSafeInteger(input?.fence) || input.fence < 1
81
+ || input.exclusive !== undefined && typeof input.exclusive !== 'boolean'
82
+ || old && (old.principalId !== principalId || input.fence < old.fence))
83
+ throw new CollaborationError('SESSION_ACCESS_STALE', 'SESSION_ACCESS_STALE', 409);
84
+ if (lease !== null && (!lease || !/^[a-zA-Z0-9-]{16,80}$/.test(lease.id) || typeof lease.userId !== 'string' || !lease.userId || lease.userId.length > 256
85
+ || typeof lease.deviceId !== 'string' || lease.deviceId.length > 160 || !/^[a-zA-Z0-9]*$/.test(lease.deviceId)
86
+ || !Number.isSafeInteger(lease.expiresAt) || lease.expiresAt <= now || lease.expiresAt > now + 125000))
87
+ throw new CollaborationError('SESSION_ACCESS_INVALID_LEASE', 'SESSION_ACCESS_INVALID_LEASE', 409);
88
+ if (old && input.fence === old.fence && (old.lease?.id !== lease?.id || old.lease?.userId !== lease?.userId
89
+ || old.lease?.deviceId && old.lease.deviceId !== lease?.deviceId || old.lease && lease && lease.expiresAt < old.lease.expiresAt
90
+ || input.exclusive !== undefined && input.exclusive !== (old.exclusive === true)))
91
+ throw new CollaborationError('SESSION_ACCESS_CONFLICT', 'SESSION_ACCESS_CONFLICT', 409);
92
+ this.save({ ...this.states, [sessionId]: { ...old, principalId, fence: input.fence, lease,
93
+ ...(input.exclusive === undefined ? {} : { exclusive: input.exclusive }) } });
94
+ return { accepted: true, fence: input.fence };
95
+ }
96
+ /** Readers have an independent revision: renewing or releasing the writer must
97
+ * not resize, disconnect or upgrade an observer. The caller owns membership. */
98
+ updateReaders(sessionId, principalId, input) {
99
+ const old = this.states[sessionId], now = this.now();
100
+ if (!this.authorized(sessionId, principalId) || old && old.principalId !== principalId || !Number.isSafeInteger(input?.fence) || input.fence < 1
101
+ || !Array.isArray(input.leases) || input.leases.length > 32 || old?.readers && input.fence < old.readers.fence)
102
+ throw new CollaborationError('SESSION_ACCESS_STALE', 'SESSION_ACCESS_STALE', 409);
103
+ const ids = new Set();
104
+ for (const lease of input.leases) {
105
+ if (!lease || !/^[a-zA-Z0-9-]{16,80}$/.test(lease.id) || ids.has(lease.id) || typeof lease.userId !== 'string' || !lease.userId || lease.userId.length > 256
106
+ || typeof lease.deviceId !== 'string' || !/^[a-zA-Z0-9]{0,160}$/.test(lease.deviceId) || !Number.isSafeInteger(lease.expiresAt) || lease.expiresAt <= now || lease.expiresAt > now + 125000)
107
+ throw new CollaborationError('SESSION_ACCESS_INVALID_LEASE', 'SESSION_ACCESS_INVALID_LEASE', 409);
108
+ ids.add(lease.id);
109
+ }
110
+ if (old?.readers && input.fence === old.readers.fence && JSON.stringify(old.readers.leases) !== JSON.stringify(input.leases))
111
+ throw new CollaborationError('SESSION_ACCESS_CONFLICT', 'SESSION_ACCESS_CONFLICT', 409);
112
+ const generations = Object.fromEntries(input.leases.map(lease => {
113
+ const previous = old?.readers?.leases.find(reader => reader.id === lease.id);
114
+ const sameIdentity = previous?.userId === lease.userId && previous?.deviceId === lease.deviceId;
115
+ return [lease.id, sameIdentity ? old?.readers?.generations[lease.id] ?? input.fence : input.fence];
116
+ }));
117
+ this.save({ ...this.states, [sessionId]: { ...old, principalId, fence: old?.fence || 0, lease: old?.lease || null, readers: { fence: input.fence, leases: input.leases, generations } } });
118
+ return { accepted: true, fence: input.fence };
119
+ }
120
+ ticket(sessionId, principalId, deviceId, readerId) {
121
+ const state = this.states[sessionId];
122
+ const lease = readerId ? state?.readers?.leases.find(x => x.id === readerId) : state?.lease;
123
+ if (!lease || state.principalId !== principalId || lease.deviceId !== deviceId || !deviceId || lease.expiresAt <= this.now()
124
+ || !this.authorized(sessionId, principalId))
125
+ throw new CollaborationError('SESSION_ACCESS_DENIED', 'SESSION_ACCESS_DENIED', 409);
126
+ const claims = { aud: 'termdock-session-access', sessionId, principalId, deviceId, leaseId: lease.id,
127
+ fence: readerId ? state.readers.generations[readerId] : state.fence, ...(readerId ? { readOnly: true } : {}), exp: Math.min(lease.expiresAt, this.now() + 30000), jti: randomUUID() };
128
+ const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
129
+ return `session.${body}.${createHmac('sha256', this.key).update(body).digest('base64url')}`;
130
+ }
131
+ consume(ticket, subjectId) {
132
+ if (typeof ticket !== 'string' || ticket.length > 4096)
133
+ throw new CollaborationError('SESSION_ACCESS_INVALID_TICKET', 'SESSION_ACCESS_INVALID_TICKET', 409);
134
+ const [prefix, body, signature, extra] = ticket.split('.');
135
+ if (prefix !== 'session' || !body || !signature || extra)
136
+ throw new CollaborationError('SESSION_ACCESS_INVALID_TICKET', 'SESSION_ACCESS_INVALID_TICKET', 409);
137
+ const expected = createHmac('sha256', this.key).update(body).digest(), supplied = Buffer.from(signature, 'base64url');
138
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
139
+ throw new CollaborationError('SESSION_ACCESS_INVALID_TICKET', 'SESSION_ACCESS_INVALID_TICKET', 409);
140
+ const claims = JSON.parse(Buffer.from(body, 'base64url').toString());
141
+ const used = new Map([...this.used].filter(([, expiry]) => expiry > this.now()));
142
+ if (claims.aud !== 'termdock-session-access' || !Number.isSafeInteger(claims.exp) || claims.exp <= this.now() || claims.exp > this.now() + 35000
143
+ || typeof claims.jti !== 'string' || !claims.jti || used.has(claims.jti) || used.size > 4096 || !this.valid(claims, subjectId))
144
+ throw new CollaborationError('SESSION_ACCESS_DENIED', 'SESSION_ACCESS_DENIED', 409);
145
+ used.set(claims.jti, claims.exp);
146
+ // Admission is successful only after the single-use decision is durable.
147
+ try {
148
+ this.save(this.states, used);
149
+ }
150
+ catch (error) {
151
+ // A rename may already have committed before a directory fsync fails.
152
+ // Never retry the same admission in this process after an uncertain write.
153
+ this.used = used;
154
+ throw error;
155
+ }
156
+ return claims;
157
+ }
158
+ valid(claims, subjectId) {
159
+ const state = this.states[claims.sessionId];
160
+ const lease = claims.readOnly === true ? state?.readers?.leases.find(x => x.id === claims.leaseId) : state?.lease;
161
+ return !!lease && claims.deviceId === subjectId && lease.deviceId === subjectId && lease.id === claims.leaseId
162
+ && (claims.readOnly === true ? state.readers.generations[claims.leaseId] === claims.fence : state.fence === claims.fence) && state.principalId === claims.principalId && lease.expiresAt > this.now() && this.authorized(claims.sessionId, claims.principalId);
163
+ }
164
+ allows(claims, subjectId, action, sessionId) {
165
+ return this.valid(claims, subjectId) && sessionId === claims.sessionId && (claims.readOnly === true ? readActions : writeActions).includes(action);
166
+ }
167
+ grant(claims, subjectId) {
168
+ return this.valid(claims, subjectId) ? [{ id: claims.leaseId, subjectId, scope: { kind: 'sessions', sessionIds: [claims.sessionId] },
169
+ actions: claims.readOnly === true ? readActions : writeActions, expiresAt: (claims.readOnly === true ? this.states[claims.sessionId].readers.leases.find(x => x.id === claims.leaseId) : this.states[claims.sessionId].lease).expiresAt }] : [];
170
+ }
171
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { canClaimTodo } from './claimEligibility.js';
3
- import path from 'node:path';
3
+ import { JsonStorageBudget } from '../utils/jsonStorageBudget.js';
4
4
  import { randomUUID, randomBytes, createHash } from 'node:crypto';
5
5
  import { nextAutomationRunAt, normalizeAutomationSchedule } from '../agent/automationStore.js';
6
6
  export class TodoError extends Error {
@@ -45,10 +45,10 @@ export function collectionSessionPolicy(input) {
45
45
  }
46
46
  const blank = () => ({ version: 1, todos: [], collectors: [], runs: [], requests: {}, defaults: { agentSlug: '', command: '', cwd: '', prompt: '' } });
47
47
  export class TodoStore {
48
- file;
49
48
  doc = blank();
50
- constructor(file) {
51
- this.file = file;
49
+ storage;
50
+ constructor(file, options = {}) {
51
+ this.storage = new JsonStorageBudget(file, 'todos', 'TERMDOCK_TODO_STORAGE_MAX_BYTES', 32 * 1024 * 1024, () => new TodoError('待办新增内容已达容量上限;可完成或释放已有工作,或调整 TERMDOCK_TODO_STORAGE_MAX_BYTES', 409), options);
52
52
  try {
53
53
  const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
54
54
  if (doc.version !== 1 || !Array.isArray(doc.todos) || !Array.isArray(doc.collectors) || !Array.isArray(doc.runs) || !doc.requests || !doc.defaults)
@@ -63,36 +63,18 @@ export class TodoStore {
63
63
  run.error = '服务重启中断了启动确认,请检查已有终端后重新发起';
64
64
  run.updatedAt = Date.now();
65
65
  }
66
- });
66
+ }, true);
67
67
  }
68
68
  catch (error) {
69
69
  if (error.code !== 'ENOENT')
70
70
  throw error;
71
71
  }
72
72
  }
73
- transaction(change) {
73
+ storageStatus() { return this.storage.status(); }
74
+ transaction(change, control = false) {
74
75
  const next = structuredClone(this.doc), result = change(next);
75
76
  const bytes = JSON.stringify(next);
76
- if (Buffer.byteLength(bytes) > 32 * 1024 * 1024)
77
- throw new TodoError('待办记录已达容量上限,请整理后重试', 409);
78
- fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
79
- const temporary = `${this.file}.${process.pid}.tmp`;
80
- const fd = fs.openSync(temporary, 'w', 0o600);
81
- try {
82
- fs.writeFileSync(fd, bytes);
83
- fs.fsyncSync(fd);
84
- }
85
- finally {
86
- fs.closeSync(fd);
87
- }
88
- fs.renameSync(temporary, this.file);
89
- const directory = fs.openSync(path.dirname(this.file), 'r');
90
- try {
91
- fs.fsyncSync(directory);
92
- }
93
- finally {
94
- fs.closeSync(directory);
95
- }
77
+ this.storage.write(bytes, control);
96
78
  this.doc = next;
97
79
  return structuredClone(result);
98
80
  }
@@ -174,7 +156,7 @@ export class TodoStore {
174
156
  todo.revision++;
175
157
  todo.updatedAt = Date.now();
176
158
  return todo;
177
- });
159
+ }, input.title === undefined && input.description === undefined && (input.status === 'completed' || input.status === 'ignored'));
178
160
  }
179
161
  defaults(input) { const config = launchConfig(input, this.doc.defaults); return this.transaction(doc => doc.defaults = config); }
180
162
  saveCollector(input) {
@@ -297,6 +279,8 @@ export class TodoStore {
297
279
  return this.hash({ todoIds: input.todoIds, note: input.note, sessionId: input.sessionId ?? null, config: input.sessionId ? null : input.config });
298
280
  }
299
281
  mark(id, update) {
282
+ if (update.error)
283
+ update = { ...update, error: update.error.slice(0, 4000) };
300
284
  return this.transaction(doc => {
301
285
  const run = doc.runs.find(r => r.id === id);
302
286
  if (!run)
@@ -312,18 +296,18 @@ export class TodoStore {
312
296
  }
313
297
  run.updatedAt = Date.now();
314
298
  return run;
315
- });
299
+ }, true);
316
300
  }
317
301
  automaticFailed(id, error) {
318
302
  this.transaction(doc => {
319
303
  const todo = doc.todos.find(t => t.id === id);
320
304
  if (todo?.status === 'open' && todo.autoProcessing?.state === 'pending') {
321
305
  todo.autoProcessing.state = 'failed';
322
- todo.autoProcessing.error = error;
306
+ todo.autoProcessing.error = error.slice(0, 4000);
323
307
  todo.updatedAt = Date.now();
324
308
  todo.revision++;
325
309
  }
326
- });
310
+ }, true);
327
311
  }
328
312
  claim(input) {
329
313
  if (![input.source, input.collectorId, input.todoId].some(value => typeof value === 'string' && value.trim()))
@@ -385,7 +369,7 @@ export class TodoStore {
385
369
  todo.revision++;
386
370
  todo.updatedAt = Date.now();
387
371
  return todo;
388
- });
372
+ }, true);
389
373
  }
390
374
  renewClaim(id, token, minutes) {
391
375
  if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440)
@@ -400,7 +384,7 @@ export class TodoStore {
400
384
  todo.revision++;
401
385
  todo.updatedAt = Date.now();
402
386
  return todo;
403
- });
387
+ }, true);
404
388
  }
405
389
  bindCollectionSession(sessionId, runtimeIdentity) {
406
390
  this.transaction(doc => {
@@ -408,7 +392,7 @@ export class TodoStore {
408
392
  if (run.kind === 'collect' && run.sessionId === sessionId && run.collectionSession?.owned !== false && !run.collectionSession?.recycledAt) {
409
393
  run.collectionSession = { ...collectionSessionPolicy(run.collectionSession), ...run.collectionSession, owned: true, runtimeIdentity };
410
394
  }
411
- });
395
+ }, true);
412
396
  }
413
397
  recycled(sessionId) {
414
398
  this.transaction(doc => {
@@ -416,7 +400,7 @@ export class TodoStore {
416
400
  if (run.kind === 'collect' && run.sessionId === sessionId && run.collectionSession?.owned !== false) {
417
401
  run.collectionSession = { ...collectionSessionPolicy(run.collectionSession), owned: true, recycledAt: Date.now() };
418
402
  }
419
- });
403
+ }, true);
420
404
  }
421
405
  report(id, status, content) {
422
406
  if (!['complete', 'blocked', 'failed'].includes(status))
@@ -434,7 +418,7 @@ export class TodoStore {
434
418
  run.result = result;
435
419
  run.updatedAt = Date.now();
436
420
  return run;
437
- });
421
+ }, true);
438
422
  }
439
423
  hash(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
440
424
  }
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { atomicIntegrationWrite } from '../agent/integrationJournal.js';
5
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
6
+ /** Admission limits protect against new work, not against recording the outcome
7
+ * of work already admitted. Domain stores explicitly mark bounded control writes;
8
+ * ordinary content edits/new requests still need capacity. No history is erased. */
9
+ export class JsonStorageBudget {
10
+ file;
11
+ label;
12
+ full;
13
+ bytes = 0;
14
+ fingerprint;
15
+ warning = '';
16
+ uncertain = false;
17
+ maxBytes;
18
+ warnPercent;
19
+ constructor(file, label, setting, fallback, full, options = {}) {
20
+ this.file = file;
21
+ this.label = label;
22
+ this.full = full;
23
+ this.maxBytes = options.maxBytes ?? Number(process.env[setting] ?? fallback);
24
+ this.warnPercent = options.warnPercent ?? Number(process.env.TERMDOCK_STORAGE_WARN_PERCENT ?? 80);
25
+ if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1 || !Number.isSafeInteger(this.warnPercent) || this.warnPercent < 1 || this.warnPercent > 100)
26
+ throw new Error(`Invalid storage configuration: ${setting} / TERMDOCK_STORAGE_WARN_PERCENT`);
27
+ try {
28
+ const raw = fs.readFileSync(file);
29
+ this.bytes = raw.length;
30
+ this.fingerprint = digest(raw);
31
+ }
32
+ catch (error) {
33
+ if (error.code !== 'ENOENT')
34
+ throw error;
35
+ }
36
+ this.report();
37
+ }
38
+ status() {
39
+ return { store: this.label, bytes: this.bytes, max_bytes: this.maxBytes, warning_percent: this.warnPercent,
40
+ state: this.bytes >= this.maxBytes ? 'full' : this.bytes >= this.maxBytes * this.warnPercent / 100 ? 'warning' : 'ok',
41
+ control_writes_use_admission_budget: false, commit_unconfirmed: this.uncertain };
42
+ }
43
+ report() {
44
+ const state = this.status().state;
45
+ if (state !== 'ok' && state !== this.warning)
46
+ console.warn('[storage-capacity]', JSON.stringify(this.status()));
47
+ this.warning = state;
48
+ }
49
+ admit(serialized, control = false) {
50
+ if (this.uncertain)
51
+ throw new Error(`${this.label}: storage commit unconfirmed; reopen before writing`);
52
+ const bytes = Buffer.byteLength(serialized);
53
+ // Shrinking an already oversized legacy document must remain possible.
54
+ if (!control && bytes > this.maxBytes && bytes > this.bytes) {
55
+ if (this.warning !== 'rejected')
56
+ console.warn('[storage-capacity]', JSON.stringify({ ...this.status(), rejected_bytes: bytes }));
57
+ this.warning = 'rejected';
58
+ throw this.full();
59
+ }
60
+ }
61
+ committed(serialized) { this.bytes = Buffer.byteLength(serialized); this.fingerprint = digest(serialized); this.report(); }
62
+ failed() {
63
+ try {
64
+ this.uncertain = digest(fs.readFileSync(this.file)) !== this.fingerprint;
65
+ }
66
+ catch (readError) {
67
+ this.uncertain = readError.code !== 'ENOENT' || this.fingerprint !== undefined;
68
+ }
69
+ }
70
+ /** Confirm the exact already-written document without overwriting an
71
+ * uncertain commit or another writer's state. Used by durable receipt replay. */
72
+ reconfirm(serialized) {
73
+ const expected = Buffer.from(serialized), descriptor = fs.openSync(this.file, 'r');
74
+ try {
75
+ if (!fs.readFileSync(descriptor).equals(expected))
76
+ throw new Error(`${this.label}: persisted state changed; reopen before confirming`);
77
+ fs.fsyncSync(descriptor);
78
+ }
79
+ finally {
80
+ fs.closeSync(descriptor);
81
+ }
82
+ const directory = fs.openSync(path.dirname(this.file), 'r');
83
+ try {
84
+ fs.fsyncSync(directory);
85
+ }
86
+ finally {
87
+ fs.closeSync(directory);
88
+ }
89
+ if (!fs.readFileSync(this.file).equals(expected))
90
+ throw new Error(`${this.label}: persisted state changed; reopen before confirming`);
91
+ this.committed(serialized);
92
+ this.uncertain = false;
93
+ }
94
+ write(serialized, control = false) {
95
+ this.admit(serialized, control);
96
+ try {
97
+ atomicIntegrationWrite(this.file, fd => fs.writeFileSync(fd, serialized));
98
+ }
99
+ catch (error) {
100
+ this.failed();
101
+ throw error;
102
+ }
103
+ this.committed(serialized);
104
+ }
105
+ }
@@ -28,6 +28,11 @@ export function isProcessRunning(pid) {
28
28
  return false;
29
29
  }
30
30
  }
31
+ export function readLocalApiCa(state, fallbackPath) {
32
+ if (state?.httpsCaPath)
33
+ return fs.readFileSync(state.httpsCaPath);
34
+ return fs.existsSync(fallbackPath) ? fs.readFileSync(fallbackPath) : undefined;
35
+ }
31
36
  export function readJsonFile(filePath) {
32
37
  try {
33
38
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));