zele 0.3.14 → 0.3.16
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 +25 -0
- package/dist/api-utils.d.ts +3 -0
- package/dist/api-utils.js +6 -0
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.js +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/calendar-time.js +6 -0
- package/dist/calendar-time.js.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +42 -2
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +1 -1
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +1 -1
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +2 -2
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/filter.d.ts +2 -0
- package/dist/commands/filter.js +59 -0
- package/dist/commands/filter.js.map +1 -0
- package/dist/commands/mail-actions.js +12 -2
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.js +176 -93
- package/dist/commands/mail.js.map +1 -1
- package/dist/db.js +24 -1
- package/dist/db.js.map +1 -1
- package/dist/gmail-client.d.ts +28 -0
- package/dist/gmail-client.js +168 -13
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.js +34 -9
- package/dist/mail-tui.js.map +1 -1
- package/dist/output.d.ts +2 -0
- package/dist/output.js +4 -0
- package/dist/output.js.map +1 -1
- package/package.json +8 -3
- package/skills/zele/SKILL.md +112 -0
- package/src/api-utils.ts +7 -0
- package/src/app.log +9 -0
- package/src/auth.ts +1 -1
- package/src/calendar-time.test.ts +35 -0
- package/src/calendar-time.ts +5 -0
- package/src/cli.ts +6 -1
- package/src/commands/attachment.ts +47 -2
- package/src/commands/auth-cmd.ts +1 -1
- package/src/commands/calendar.ts +1 -1
- package/src/commands/draft.ts +2 -2
- package/src/commands/filter.ts +68 -0
- package/src/commands/mail-actions.ts +14 -2
- package/src/commands/mail.ts +186 -98
- package/src/db.ts +26 -1
- package/src/gmail-client.ts +202 -20
- package/src/mail-tui.test.ts +170 -0
- package/src/mail-tui.tsx +56 -9
- package/src/output.ts +8 -1
- package/src/opentui-react.d.ts +0 -9
package/src/commands/mail.ts
CHANGED
|
@@ -16,6 +16,23 @@ import * as out from '../output.js'
|
|
|
16
16
|
import { handleCommandError } from '../output.js'
|
|
17
17
|
import pc from 'picocolors'
|
|
18
18
|
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Label formatting — filter out system labels already represented by flags
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const HIDDEN_LABELS = new Set([
|
|
24
|
+
'INBOX', 'SENT', 'TRASH', 'SPAM', 'DRAFT', 'UNREAD', 'STARRED',
|
|
25
|
+
'IMPORTANT', 'CHAT', 'CATEGORY_PERSONAL', 'CATEGORY_SOCIAL',
|
|
26
|
+
'CATEGORY_PROMOTIONS', 'CATEGORY_UPDATES', 'CATEGORY_FORUMS',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function formatLabels(labelIds: string[], labelMap?: Map<string, string>): string {
|
|
30
|
+
const visible = labelIds
|
|
31
|
+
.filter((id) => !HIDDEN_LABELS.has(id))
|
|
32
|
+
.map((id) => labelMap?.get(id) ?? id)
|
|
33
|
+
return visible.join(', ')
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
// ---------------------------------------------------------------------------
|
|
20
37
|
// Register commands
|
|
21
38
|
// ---------------------------------------------------------------------------
|
|
@@ -34,8 +51,9 @@ export function registerMailCommands(cli: Goke) {
|
|
|
34
51
|
.command('mail list', 'List email threads')
|
|
35
52
|
.option('--folder [folder]', 'Folder to list (inbox, sent, trash, spam, starred, drafts, archive, all) (default: inbox)')
|
|
36
53
|
.option('--max [max]', 'Max results per page (default: 20)')
|
|
37
|
-
.option('--page <page>', 'Pagination token')
|
|
54
|
+
.option('--page <page>', 'Pagination token (requires --account, only works for a single account)')
|
|
38
55
|
.option('--label <label>', 'Filter by label name')
|
|
56
|
+
.option('--filter <filter>', 'Gmail search filter (e.g. "is:unread", "from:github", "has:attachment")')
|
|
39
57
|
.action(async (options) => {
|
|
40
58
|
const folder = options.folder ?? 'inbox'
|
|
41
59
|
const max = options.max ? Number(options.max) : 20
|
|
@@ -46,17 +64,22 @@ export function registerMailCommands(cli: Goke) {
|
|
|
46
64
|
process.exit(1)
|
|
47
65
|
}
|
|
48
66
|
|
|
49
|
-
// Fetch from all accounts concurrently
|
|
67
|
+
// Fetch threads and labels from all accounts concurrently
|
|
50
68
|
const results = await Promise.all(
|
|
51
69
|
clients.map(async ({ email, client }) => {
|
|
52
|
-
const result = await
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
const [result, labelsResult] = await Promise.all([
|
|
71
|
+
client.listThreads({
|
|
72
|
+
folder,
|
|
73
|
+
maxResults: max,
|
|
74
|
+
labelIds: options.label ? [options.label] : undefined,
|
|
75
|
+
pageToken: options.page,
|
|
76
|
+
query: options.filter,
|
|
77
|
+
}),
|
|
78
|
+
client.listLabels(),
|
|
79
|
+
])
|
|
58
80
|
if (result instanceof Error) return result
|
|
59
|
-
|
|
81
|
+
const labelMap = labelsResult instanceof Error ? new Map<string, string>() : new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
|
|
82
|
+
return { email, result, labelMap }
|
|
60
83
|
}),
|
|
61
84
|
)
|
|
62
85
|
|
|
@@ -66,6 +89,10 @@ export function registerMailCommands(cli: Goke) {
|
|
|
66
89
|
return true
|
|
67
90
|
})
|
|
68
91
|
|
|
92
|
+
// Merge label maps from all accounts
|
|
93
|
+
const labelMap = new Map<string, string>()
|
|
94
|
+
for (const r of allResults) for (const [id, name] of r.labelMap) labelMap.set(id, name)
|
|
95
|
+
|
|
69
96
|
// Merge threads from all accounts, sorted by date descending, capped at max
|
|
70
97
|
const merged = allResults
|
|
71
98
|
.flatMap(({ email, result }) =>
|
|
@@ -81,16 +108,26 @@ export function registerMailCommands(cli: Goke) {
|
|
|
81
108
|
|
|
82
109
|
const showAccount = clients.length > 1
|
|
83
110
|
out.printList(
|
|
84
|
-
merged.map((t) =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
111
|
+
merged.map((t) => {
|
|
112
|
+
const to = t.to.map((s) => out.formatSender(s)).join(', ')
|
|
113
|
+
const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
|
|
114
|
+
const labels = formatLabels(t.labelIds, labelMap)
|
|
115
|
+
return {
|
|
116
|
+
...(showAccount ? { account: t.account } : {}),
|
|
117
|
+
id: t.id,
|
|
118
|
+
flags: out.formatFlags(t),
|
|
119
|
+
from: out.formatSender(t.from),
|
|
120
|
+
...(to ? { to } : {}),
|
|
121
|
+
...(cc ? { cc } : {}),
|
|
122
|
+
subject: t.subject,
|
|
123
|
+
snippet: t.snippet,
|
|
124
|
+
date: out.formatDate(t.date),
|
|
125
|
+
messages: t.messageCount,
|
|
126
|
+
...(labels ? { labels } : {}),
|
|
127
|
+
...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
{ summary: `${merged.length} threads (${folder})`, nextPage: allResults[0]?.result.nextPageToken },
|
|
94
131
|
)
|
|
95
132
|
})
|
|
96
133
|
|
|
@@ -101,7 +138,7 @@ export function registerMailCommands(cli: Goke) {
|
|
|
101
138
|
cli
|
|
102
139
|
.command('mail search <query>', 'Search email threads using Gmail query syntax (from:, to:, subject:, has:attachment, etc). See https://support.google.com/mail/answer/7190')
|
|
103
140
|
.option('--max [max]', 'Max results (default: 20)')
|
|
104
|
-
.option('--page <page>', 'Pagination token')
|
|
141
|
+
.option('--page <page>', 'Pagination token (requires --account, only works for a single account)')
|
|
105
142
|
.action(async (query, options) => {
|
|
106
143
|
const max = options.max ? Number(options.max) : 20
|
|
107
144
|
const clients = await getClients(options.account)
|
|
@@ -111,16 +148,20 @@ export function registerMailCommands(cli: Goke) {
|
|
|
111
148
|
process.exit(1)
|
|
112
149
|
}
|
|
113
150
|
|
|
114
|
-
// Search all accounts concurrently
|
|
151
|
+
// Search all accounts concurrently (fetch labels alongside for name resolution)
|
|
115
152
|
const results = await Promise.all(
|
|
116
153
|
clients.map(async ({ email, client }) => {
|
|
117
|
-
const result = await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
const [result, labelsResult] = await Promise.all([
|
|
155
|
+
client.listThreads({
|
|
156
|
+
query,
|
|
157
|
+
maxResults: max,
|
|
158
|
+
pageToken: options.page,
|
|
159
|
+
}),
|
|
160
|
+
client.listLabels(),
|
|
161
|
+
])
|
|
122
162
|
if (result instanceof Error) return result
|
|
123
|
-
|
|
163
|
+
const labelMap = labelsResult instanceof Error ? new Map<string, string>() : new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
|
|
164
|
+
return { email, result, labelMap }
|
|
124
165
|
}),
|
|
125
166
|
)
|
|
126
167
|
|
|
@@ -130,6 +171,10 @@ export function registerMailCommands(cli: Goke) {
|
|
|
130
171
|
return true
|
|
131
172
|
})
|
|
132
173
|
|
|
174
|
+
// Merge label maps from all accounts
|
|
175
|
+
const labelMap = new Map<string, string>()
|
|
176
|
+
for (const r of allResults) for (const [id, name] of r.labelMap) labelMap.set(id, name)
|
|
177
|
+
|
|
133
178
|
const merged = allResults
|
|
134
179
|
.flatMap(({ email, result }) =>
|
|
135
180
|
result.threads.map((t) => ({ ...t, account: email })),
|
|
@@ -144,16 +189,26 @@ export function registerMailCommands(cli: Goke) {
|
|
|
144
189
|
|
|
145
190
|
const showAccount = clients.length > 1
|
|
146
191
|
out.printList(
|
|
147
|
-
merged.map((t) =>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
192
|
+
merged.map((t) => {
|
|
193
|
+
const to = t.to.map((s) => out.formatSender(s)).join(', ')
|
|
194
|
+
const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
|
|
195
|
+
const labels = formatLabels(t.labelIds, labelMap)
|
|
196
|
+
return {
|
|
197
|
+
...(showAccount ? { account: t.account } : {}),
|
|
198
|
+
id: t.id,
|
|
199
|
+
flags: out.formatFlags(t),
|
|
200
|
+
from: out.formatSender(t.from),
|
|
201
|
+
...(to ? { to } : {}),
|
|
202
|
+
...(cc ? { cc } : {}),
|
|
203
|
+
subject: t.subject,
|
|
204
|
+
snippet: t.snippet,
|
|
205
|
+
date: out.formatDate(t.date),
|
|
206
|
+
messages: t.messageCount,
|
|
207
|
+
...(labels ? { labels } : {}),
|
|
208
|
+
...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
|
|
209
|
+
}
|
|
210
|
+
}),
|
|
211
|
+
{ summary: `${merged.length} results for "${query}"`, nextPage: allResults[0]?.result.nextPageToken },
|
|
157
212
|
)
|
|
158
213
|
})
|
|
159
214
|
|
|
@@ -162,10 +217,15 @@ export function registerMailCommands(cli: Goke) {
|
|
|
162
217
|
// =========================================================================
|
|
163
218
|
|
|
164
219
|
cli
|
|
165
|
-
.command('mail read
|
|
166
|
-
.option('--raw', 'Show raw message (first message only)')
|
|
220
|
+
.command('mail read [...threadIds]', 'Read full email threads (does not mark as read)')
|
|
221
|
+
.option('--raw', 'Show raw message (first message only, single thread)')
|
|
167
222
|
.option('--raw-html', 'Show raw HTML body per message (no markdown conversion)')
|
|
168
|
-
.action(async (
|
|
223
|
+
.action(async (threadIds, options) => {
|
|
224
|
+
if (threadIds.length === 0) {
|
|
225
|
+
out.error('No thread IDs provided')
|
|
226
|
+
process.exit(1)
|
|
227
|
+
}
|
|
228
|
+
|
|
169
229
|
const { client } = await getClient(options.account)
|
|
170
230
|
|
|
171
231
|
if (options.raw && options.rawHtml) {
|
|
@@ -174,7 +234,11 @@ export function registerMailCommands(cli: Goke) {
|
|
|
174
234
|
}
|
|
175
235
|
|
|
176
236
|
if (options.raw) {
|
|
177
|
-
|
|
237
|
+
if (threadIds.length > 1) {
|
|
238
|
+
out.error('--raw only supports a single thread ID')
|
|
239
|
+
process.exit(1)
|
|
240
|
+
}
|
|
241
|
+
const { parsed: thread } = await client.getThread({ threadId: threadIds[0]! })
|
|
178
242
|
if (thread.messages.length === 0) {
|
|
179
243
|
out.hint('No messages in thread')
|
|
180
244
|
return
|
|
@@ -185,72 +249,96 @@ export function registerMailCommands(cli: Goke) {
|
|
|
185
249
|
return
|
|
186
250
|
}
|
|
187
251
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (options.rawHtml) {
|
|
196
|
-
thread.messages.forEach((msg, index) => {
|
|
197
|
-
console.log(msg.body)
|
|
198
|
-
if (index < thread.messages.length - 1) {
|
|
199
|
-
console.log('\n<!-- ZELE_MESSAGE_SEPARATOR -->\n')
|
|
200
|
-
}
|
|
201
|
-
})
|
|
202
|
-
return
|
|
203
|
-
}
|
|
252
|
+
// Fetch all threads concurrently, tolerating individual failures
|
|
253
|
+
const settled = await Promise.allSettled(
|
|
254
|
+
threadIds.map((id) => client.getThread({ threadId: id })),
|
|
255
|
+
)
|
|
204
256
|
|
|
205
257
|
const w = Math.min(process.stdout.columns || 72, 72)
|
|
206
258
|
const rule = pc.dim('─'.repeat(w))
|
|
259
|
+
const multi = threadIds.length > 1
|
|
207
260
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const fromStr = out.formatSender(msg.from)
|
|
224
|
-
const dateStr = out.formatDate(msg.date)
|
|
225
|
-
|
|
226
|
-
// Flags as dim tags
|
|
227
|
-
const flagParts: string[] = []
|
|
228
|
-
if (msg.unread) flagParts.push(pc.yellow('[unread]'))
|
|
229
|
-
if (msg.starred) flagParts.push(pc.yellow('[starred]'))
|
|
230
|
-
const flagStr = flagParts.length > 0 ? ' ' + flagParts.join(' ') : ''
|
|
231
|
-
|
|
232
|
-
console.log(pc.bold(`From: `) + fromStr + flagStr)
|
|
233
|
-
console.log(pc.dim(` To: ${msg.to.map((t) => t.email).join(', ')}`))
|
|
234
|
-
if (msg.cc && msg.cc.length > 0) {
|
|
235
|
-
console.log(pc.dim(` Cc: ${msg.cc.map((c) => c.email).join(', ')}`))
|
|
261
|
+
for (let i = 0; i < settled.length; i++) {
|
|
262
|
+
const result = settled[i]!
|
|
263
|
+
|
|
264
|
+
if (multi) {
|
|
265
|
+
const doubleRule = pc.bold('━'.repeat(w))
|
|
266
|
+
console.log(doubleRule)
|
|
267
|
+
console.log(pc.bold(`Thread ${i + 1}/${settled.length}`) + pc.dim(` · ${threadIds[i]}`))
|
|
268
|
+
console.log(doubleRule)
|
|
269
|
+
console.log()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (result.status === 'rejected') {
|
|
273
|
+
out.error(`Failed to read thread ${threadIds[i]}: ${String(result.reason)}`)
|
|
274
|
+
if (multi) console.log()
|
|
275
|
+
continue
|
|
236
276
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
277
|
+
|
|
278
|
+
const { parsed: thread } = result.value
|
|
279
|
+
|
|
280
|
+
if (thread.messages.length === 0) {
|
|
281
|
+
out.hint('No messages in thread')
|
|
282
|
+
if (multi) console.log()
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (options.rawHtml) {
|
|
287
|
+
thread.messages.forEach((msg, index) => {
|
|
288
|
+
console.log(msg.body)
|
|
289
|
+
if (index < thread.messages.length - 1) {
|
|
290
|
+
console.log('\n<!-- ZELE_MESSAGE_SEPARATOR -->\n')
|
|
291
|
+
}
|
|
245
292
|
})
|
|
246
|
-
console.log(
|
|
293
|
+
if (multi) console.log()
|
|
294
|
+
continue
|
|
247
295
|
}
|
|
248
296
|
|
|
249
|
-
|
|
297
|
+
// Render thread header
|
|
298
|
+
console.log(pc.bold(thread.subject))
|
|
299
|
+
const participants = new Map<string, string>()
|
|
300
|
+
for (const msg of thread.messages) {
|
|
301
|
+
participants.set(msg.from.email, msg.from.name || msg.from.email)
|
|
302
|
+
for (const r of msg.to) participants.set(r.email, r.name || r.email)
|
|
303
|
+
}
|
|
304
|
+
const participantStr = [...participants.values()].join(', ')
|
|
305
|
+
console.log(pc.dim(`${thread.messageCount} message(s) · ${participantStr}`))
|
|
306
|
+
console.log(pc.dim(`ID: ${thread.id}`))
|
|
307
|
+
console.log(rule + '\n')
|
|
308
|
+
|
|
309
|
+
// Render each message
|
|
310
|
+
for (const msg of thread.messages) {
|
|
311
|
+
const fromStr = out.formatSender(msg.from)
|
|
312
|
+
const dateStr = out.formatDate(msg.date)
|
|
313
|
+
|
|
314
|
+
const flagParts: string[] = []
|
|
315
|
+
if (msg.unread) flagParts.push(pc.yellow('[unread]'))
|
|
316
|
+
if (msg.starred) flagParts.push(pc.yellow('[starred]'))
|
|
317
|
+
const flagStr = flagParts.length > 0 ? ' ' + flagParts.join(' ') : ''
|
|
318
|
+
|
|
319
|
+
console.log(pc.bold(`From: `) + fromStr + flagStr)
|
|
320
|
+
console.log(pc.dim(` To: ${msg.to.map((t) => t.email).join(', ')}`))
|
|
321
|
+
if (msg.cc && msg.cc.length > 0) {
|
|
322
|
+
console.log(pc.dim(` Cc: ${msg.cc.map((c) => c.email).join(', ')}`))
|
|
323
|
+
}
|
|
324
|
+
console.log(pc.dim(`Date: ${dateStr}`))
|
|
325
|
+
|
|
326
|
+
if (msg.attachments.length > 0) {
|
|
327
|
+
const attList = msg.attachments.map((a) => {
|
|
328
|
+
const size = a.size < 1024 ? `${a.size} B`
|
|
329
|
+
: a.size < 1048576 ? `${(a.size / 1024).toFixed(1)} KB`
|
|
330
|
+
: `${(a.size / 1048576).toFixed(1)} MB`
|
|
331
|
+
return `${a.filename} (${size})`
|
|
332
|
+
})
|
|
333
|
+
console.log(pc.dim(`Attachments: ${attList.join(', ')}`))
|
|
334
|
+
}
|
|
250
335
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
336
|
+
console.log()
|
|
337
|
+
|
|
338
|
+
const body = out.renderEmailBody(msg.body, msg.mimeType)
|
|
339
|
+
console.log(body)
|
|
340
|
+
console.log('\n' + rule + '\n')
|
|
341
|
+
}
|
|
254
342
|
}
|
|
255
343
|
})
|
|
256
344
|
|
package/src/db.ts
CHANGED
|
@@ -37,8 +37,12 @@ export function getPrisma(): Promise<PrismaClient> {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async function initializePrisma(): Promise<PrismaClient> {
|
|
40
|
+
// Create directory with restrictive permissions (owner only)
|
|
40
41
|
if (!fs.existsSync(ZELE_DIR)) {
|
|
41
|
-
fs.mkdirSync(ZELE_DIR, { recursive: true })
|
|
42
|
+
fs.mkdirSync(ZELE_DIR, { recursive: true, mode: 0o700 })
|
|
43
|
+
} else {
|
|
44
|
+
// Ensure existing directory has correct permissions
|
|
45
|
+
fs.chmodSync(ZELE_DIR, 0o700)
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
const adapter = new PrismaLibSql({ url: `file:${DB_PATH}` })
|
|
@@ -54,6 +58,9 @@ async function initializePrisma(): Promise<PrismaClient> {
|
|
|
54
58
|
// Run schema.sql — uses CREATE TABLE IF NOT EXISTS so it's idempotent
|
|
55
59
|
await applySchema(prisma)
|
|
56
60
|
|
|
61
|
+
// Secure database files (owner read/write only)
|
|
62
|
+
secureDatabase()
|
|
63
|
+
|
|
57
64
|
prismaInstance = prisma
|
|
58
65
|
return prisma
|
|
59
66
|
}
|
|
@@ -86,6 +93,24 @@ async function applySchema(prisma: PrismaClient): Promise<void> {
|
|
|
86
93
|
}
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Set restrictive permissions on database files.
|
|
98
|
+
* SQLite WAL mode creates additional -wal and -shm files that also need protection.
|
|
99
|
+
*/
|
|
100
|
+
function secureDatabase(): void {
|
|
101
|
+
const filesToSecure = [
|
|
102
|
+
DB_PATH,
|
|
103
|
+
`${DB_PATH}-wal`,
|
|
104
|
+
`${DB_PATH}-shm`,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
for (const filePath of filesToSecure) {
|
|
108
|
+
if (fs.existsSync(filePath)) {
|
|
109
|
+
fs.chmodSync(filePath, 0o600)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
89
114
|
/**
|
|
90
115
|
* Close the Prisma connection.
|
|
91
116
|
*/
|