@dumbmice/pi-mailbridge 0.0.0-stage → 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/CHANGELOG.md +824 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- package/src/store/uid-cursor.ts +135 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Inbound attachments.
|
|
2
|
+
//
|
|
3
|
+
// The point: reply to a pi notification from your phone with an image or a log
|
|
4
|
+
// and have the agent actually see it.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately restrictive. An attachment that reaches the model is untrusted
|
|
7
|
+
// input from a mail server, so:
|
|
8
|
+
// - MIME allowlist, not a denylist
|
|
9
|
+
// - hard caps on count and total bytes, enforced before anything is stored
|
|
10
|
+
// - anything dropped is reported with a reason rather than silently ignored
|
|
11
|
+
|
|
12
|
+
/** Images the model can actually consume. */
|
|
13
|
+
export const ALLOWED_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
|
|
14
|
+
/** Text-like attachments we inline as text. */
|
|
15
|
+
export const ALLOWED_TEXT_MIMES = ["text/plain", "text/markdown", "application/json"] as const
|
|
16
|
+
|
|
17
|
+
export const MAX_ATTACHMENTS = 4
|
|
18
|
+
export const MAX_TOTAL_BYTES = 1_000_000
|
|
19
|
+
|
|
20
|
+
export type UserContent =
|
|
21
|
+
| { type: "text"; text: string }
|
|
22
|
+
| { type: "image"; data: string; mimeType: string }
|
|
23
|
+
|
|
24
|
+
/** What the MIME parser hands us. */
|
|
25
|
+
export interface RawAttachment {
|
|
26
|
+
filename?: string
|
|
27
|
+
contentType: string
|
|
28
|
+
content: Buffer
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What we store in the pending record (base64, so JSON keeps it). */
|
|
32
|
+
export interface InboundAttachment {
|
|
33
|
+
name: string
|
|
34
|
+
mimeType: string
|
|
35
|
+
size: number
|
|
36
|
+
data: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DroppedAttachment {
|
|
40
|
+
name: string
|
|
41
|
+
reason: "mime-not-allowed" | "too-many" | "too-large" | "empty"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SelectResult {
|
|
45
|
+
accepted: InboundAttachment[]
|
|
46
|
+
dropped: DroppedAttachment[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isImage(mime: string): boolean {
|
|
50
|
+
return (ALLOWED_IMAGE_MIMES as readonly string[]).includes(mime)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isText(mime: string): boolean {
|
|
54
|
+
return (ALLOWED_TEXT_MIMES as readonly string[]).includes(mime)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Lowercase, strip parameters (`text/plain; charset=utf-8` → `text/plain`). */
|
|
58
|
+
export function normalizeMime(contentType: string | undefined): string {
|
|
59
|
+
return (contentType ?? "").split(";")[0]!.trim().toLowerCase()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply the allowlist and the caps.
|
|
64
|
+
* Order matters: mime first (cheap), then count, then size.
|
|
65
|
+
*/
|
|
66
|
+
export function selectAttachments(
|
|
67
|
+
raw: RawAttachment[],
|
|
68
|
+
limits: { maxCount?: number; maxTotalBytes?: number } = {},
|
|
69
|
+
): SelectResult {
|
|
70
|
+
const maxCount = limits.maxCount ?? MAX_ATTACHMENTS
|
|
71
|
+
const maxTotalBytes = limits.maxTotalBytes ?? MAX_TOTAL_BYTES
|
|
72
|
+
|
|
73
|
+
const accepted: InboundAttachment[] = []
|
|
74
|
+
const dropped: DroppedAttachment[] = []
|
|
75
|
+
let total = 0
|
|
76
|
+
|
|
77
|
+
for (const item of raw) {
|
|
78
|
+
const mimeType = normalizeMime(item.contentType)
|
|
79
|
+
const name = (item.filename ?? "").trim() || `attachment-${accepted.length + dropped.length + 1}`
|
|
80
|
+
|
|
81
|
+
if (!isImage(mimeType) && !isText(mimeType)) {
|
|
82
|
+
dropped.push({ name, reason: "mime-not-allowed" })
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (accepted.length >= maxCount) {
|
|
86
|
+
dropped.push({ name, reason: "too-many" })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (item.content.length === 0) {
|
|
90
|
+
dropped.push({ name, reason: "empty" })
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (total + item.content.length > maxTotalBytes) {
|
|
94
|
+
dropped.push({ name, reason: "too-large" })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
total += item.content.length
|
|
99
|
+
accepted.push({
|
|
100
|
+
name,
|
|
101
|
+
mimeType,
|
|
102
|
+
size: item.content.length,
|
|
103
|
+
data: item.content.toString("base64"),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { accepted, dropped }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build the payload for `pi.sendUserMessage`.
|
|
112
|
+
*
|
|
113
|
+
* Returns a plain string when there is nothing to attach: that keeps the common
|
|
114
|
+
* case identical to a typed message, so no client special-cases it.
|
|
115
|
+
*/
|
|
116
|
+
export function toUserContent(text: string, attachments: InboundAttachment[] = []): string | UserContent[] {
|
|
117
|
+
if (attachments.length === 0) return text
|
|
118
|
+
// An attachment-only reply has no prose; an empty text block ahead of the image is noise
|
|
119
|
+
// the model has to read past.
|
|
120
|
+
const content: UserContent[] = text ? [{ type: "text", text }] : []
|
|
121
|
+
for (const attachment of attachments) {
|
|
122
|
+
if (isImage(attachment.mimeType)) {
|
|
123
|
+
content.push({ type: "image", data: attachment.data, mimeType: attachment.mimeType })
|
|
124
|
+
} else {
|
|
125
|
+
content.push({ type: "text", text: Buffer.from(attachment.data, "base64").toString("utf8") })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return content
|
|
129
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Authorization for inbound mail.
|
|
2
|
+
//
|
|
3
|
+
// Order matters and is fail-closed at every step:
|
|
4
|
+
// 1. loop guard — never act on our own outbound address
|
|
5
|
+
// 2. auto-generated — no replies to robots (that is how loops start)
|
|
6
|
+
// 3. allowlist — exact addr-spec after RFC 5322-ish normalization
|
|
7
|
+
// 4. DMARC — dmarc=pass; authserv-id pinned only when configured
|
|
8
|
+
//
|
|
9
|
+
// The routing token is NOT consulted here. It routes; it does not authorize.
|
|
10
|
+
// See docs/SECURITY.md §2.2.
|
|
11
|
+
|
|
12
|
+
import type { ResolvedConfig } from "./config.ts"
|
|
13
|
+
import { dmarcPasses, findTrustedAuthResults, isAutoGenerated, normalizeAddress, type HeaderLike } from "./reply-parse.ts"
|
|
14
|
+
|
|
15
|
+
export type AuthzDenial =
|
|
16
|
+
| "no-from"
|
|
17
|
+
| "self"
|
|
18
|
+
| "self-echo"
|
|
19
|
+
| "no-reply-evidence"
|
|
20
|
+
| "auto-generated"
|
|
21
|
+
| "not-allowlisted"
|
|
22
|
+
| "allowlist-empty"
|
|
23
|
+
| "dmarc-missing"
|
|
24
|
+
| "dmarc-failed"
|
|
25
|
+
|
|
26
|
+
export type AuthzResult =
|
|
27
|
+
| { ok: true; from: string }
|
|
28
|
+
| { ok: false; reason: AuthzDenial }
|
|
29
|
+
|
|
30
|
+
export interface AuthzInput {
|
|
31
|
+
/** Raw `From:` header value, display name included. */
|
|
32
|
+
from: string | undefined
|
|
33
|
+
headers: HeaderLike
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function authorize(input: AuthzInput, config: ResolvedConfig): AuthzResult {
|
|
37
|
+
const from = normalizeAddress(input.from)
|
|
38
|
+
if (!from) return { ok: false, reason: "no-from" }
|
|
39
|
+
|
|
40
|
+
// 1. Loop guard: the pi account must never be treated as the human.
|
|
41
|
+
// `mail.to` is the HUMAN's address, so it must not be used here — doing so
|
|
42
|
+
// rejected every genuine reply (the human is the one writing to us).
|
|
43
|
+
//
|
|
44
|
+
// Single-mailbox mode (`security.allowSelfReplies`): one address is both the
|
|
45
|
+
// mailbox and the human, which is the normal case for a personal Hotmail or
|
|
46
|
+
// Gmail. Then `from === self` is expected — but we still must not treat our
|
|
47
|
+
// OWN outbound notification as a reply. The discriminator is `In-Reply-To`:
|
|
48
|
+
// a reply carries it - but so does every threaded notification we send, so this
|
|
49
|
+
// header alone is not enough. process.ts rejects our own Message-IDs first, using
|
|
50
|
+
// the outbound registry; this check remains as the cheap early gate.
|
|
51
|
+
const self = normalizeAddress(config.mail.smtp.user) ?? normalizeAddress(config.mail.imap.user)
|
|
52
|
+
if (self && from === self) {
|
|
53
|
+
if (!config.security.allowSelfReplies) return { ok: false, reason: "self" }
|
|
54
|
+
// Reply evidence is either header the router accepts. Requiring only In-Reply-To
|
|
55
|
+
// killed genuine replies from clients that set References alone (or a
|
|
56
|
+
// forward-then-reply), because the router would have routed them by chain.
|
|
57
|
+
const threaded =
|
|
58
|
+
Boolean(input.headers.inReplyTo?.trim()) || Boolean(input.headers.references?.some((r) => r.trim()))
|
|
59
|
+
// Distinct from the self-echo guards (marker header, outbound Message-ID): this is a
|
|
60
|
+
// message from your own address with no reply evidence at all. The two are different
|
|
61
|
+
// problems for the user, so they get different names.
|
|
62
|
+
if (!threaded) return { ok: false, reason: "no-reply-evidence" }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. Never answer robots, and never act on a bounce: a null sender or a postmaster
|
|
66
|
+
// address is a delivery failure report, not a human.
|
|
67
|
+
if (!from.includes("@") || /^(mailer-daemon|postmaster|mail-daemon)$/i.test(from.split("@")[0] ?? "")) {
|
|
68
|
+
return { ok: false, reason: "auto-generated" }
|
|
69
|
+
}
|
|
70
|
+
if (isAutoGenerated(input.headers)) return { ok: false, reason: "auto-generated" }
|
|
71
|
+
|
|
72
|
+
// 3. Allowlist. An empty list denies everything.
|
|
73
|
+
const allow = config.security.allowFrom.map((a) => normalizeAddress(a)).filter((a): a is string => Boolean(a))
|
|
74
|
+
if (allow.length === 0) return { ok: false, reason: "allowlist-empty" }
|
|
75
|
+
if (!allow.includes(from)) return { ok: false, reason: "not-allowlisted" }
|
|
76
|
+
|
|
77
|
+
// 4. DMARC pinned to our relay.
|
|
78
|
+
if (config.security.requireDmarcPass) {
|
|
79
|
+
// Unset means "trust the topmost header, i.e. the delivering relay's".
|
|
80
|
+
// Pinned means "only that authserv-id".
|
|
81
|
+
const ar = findTrustedAuthResults(input.headers, config.security.trustedAuthservId)
|
|
82
|
+
if (!ar) return { ok: false, reason: "dmarc-missing" }
|
|
83
|
+
if (!dmarcPasses(ar)) return { ok: false, reason: "dmarc-failed" }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ok: true, from }
|
|
87
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `/mailbridge` argument routing.
|
|
2
|
+
//
|
|
3
|
+
// Pure, so the command surface is testable without pi. Keeping the parse separate
|
|
4
|
+
// from the handlers is also what lets the same subcommand set back both the in-pi
|
|
5
|
+
// command and (where it makes sense) the headless entry point.
|
|
6
|
+
|
|
7
|
+
export type MailbridgeSubcommand =
|
|
8
|
+
| "on"
|
|
9
|
+
| "off"
|
|
10
|
+
| "status"
|
|
11
|
+
| "setup"
|
|
12
|
+
| "check"
|
|
13
|
+
| "auth"
|
|
14
|
+
| "smoke"
|
|
15
|
+
| "prime"
|
|
16
|
+
| "restart"
|
|
17
|
+
| "help"
|
|
18
|
+
|
|
19
|
+
export interface ParsedCommand {
|
|
20
|
+
sub: MailbridgeSubcommand
|
|
21
|
+
/** Setup: the pi address. */
|
|
22
|
+
address?: string
|
|
23
|
+
/** Setup: where the human reads mail. */
|
|
24
|
+
to?: string
|
|
25
|
+
/** Setup: overwrite an existing config. */
|
|
26
|
+
force: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const USAGE = [
|
|
30
|
+
"/mailbridge status",
|
|
31
|
+
"/mailbridge on email mode on: decisions mail you",
|
|
32
|
+
"/mailbridge off email mode off: decisions ask in this terminal",
|
|
33
|
+
"/mailbridge setup <pi-address> [to <your-address>] [force]",
|
|
34
|
+
"/mailbridge check validate config, and that credentials are present",
|
|
35
|
+
"/mailbridge auth approve OAuth2 device login",
|
|
36
|
+
"/mailbridge smoke send one real mail, then read the folder back",
|
|
37
|
+
"/mailbridge prime start watching from now, ignoring existing mail",
|
|
38
|
+
"/mailbridge restart replace the daemon process (picks up new code, not just config)",
|
|
39
|
+
"/mailbridge help this list",
|
|
40
|
+
].join("\n")
|
|
41
|
+
|
|
42
|
+
const SUBCOMMANDS: readonly MailbridgeSubcommand[] = [
|
|
43
|
+
"on",
|
|
44
|
+
"off",
|
|
45
|
+
"status",
|
|
46
|
+
"setup",
|
|
47
|
+
"check",
|
|
48
|
+
"auth",
|
|
49
|
+
"smoke",
|
|
50
|
+
"prime",
|
|
51
|
+
"restart",
|
|
52
|
+
"help",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
export function isSubcommand(value: string): value is MailbridgeSubcommand {
|
|
56
|
+
return (SUBCOMMANDS as readonly string[]).includes(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse the text after `/mailbridge`.
|
|
61
|
+
*
|
|
62
|
+
* Accepts both `--to` and bare `to` because a TUI prompt is not a shell and people
|
|
63
|
+
* type either. Unknown words that are not addresses are ignored rather than
|
|
64
|
+
* treated as errors, so a stray flag cannot silently change the target address.
|
|
65
|
+
*/
|
|
66
|
+
export function parseMailbridgeCommand(args: string): ParsedCommand {
|
|
67
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
68
|
+
const first = tokens[0]?.toLowerCase()
|
|
69
|
+
|
|
70
|
+
if (!first || !isSubcommand(first)) {
|
|
71
|
+
return { sub: first ? "help" : "status", force: false }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parsed: ParsedCommand = { sub: first, force: false }
|
|
75
|
+
if (first !== "setup") return parsed
|
|
76
|
+
|
|
77
|
+
const rest = tokens.slice(1)
|
|
78
|
+
const addresses: string[] = []
|
|
79
|
+
for (let i = 0; i < rest.length; i++) {
|
|
80
|
+
const token = rest[i]!
|
|
81
|
+
const lower = token.toLowerCase()
|
|
82
|
+
if (lower === "force" || lower === "--force") {
|
|
83
|
+
parsed.force = true
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (lower === "to" || lower === "--to") {
|
|
87
|
+
const next = rest[i + 1]
|
|
88
|
+
if (next && !next.startsWith("-") && next.toLowerCase() !== "force") {
|
|
89
|
+
parsed.to = next
|
|
90
|
+
i++
|
|
91
|
+
}
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (token.includes("@")) addresses.push(token)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (addresses[0]) parsed.address = addresses[0]
|
|
98
|
+
return parsed
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Should a session tell the running daemon to re-read its config?
|
|
2
|
+
//
|
|
3
|
+
// A session that attaches to an already-running daemon inherits whatever config that daemon
|
|
4
|
+
// read - which may predate a hand-edited config.json or a headless `--setup`. Sending mail
|
|
5
|
+
// with a replaced address is the failure this exists to prevent.
|
|
6
|
+
//
|
|
7
|
+
// The daemon reports the *mtime of the file it is running on*, and the extension compares it
|
|
8
|
+
// with the file's current mtime. mtime-to-mtime deliberately: a clock reading would make a
|
|
9
|
+
// preserved/backdated mtime reload forever, and would hide a write that landed between the
|
|
10
|
+
// read and the timestamp.
|
|
11
|
+
|
|
12
|
+
export interface Freshness {
|
|
13
|
+
/**
|
|
14
|
+
* The mtime of the config file the daemon is running on (NOT the time it read it), or 0
|
|
15
|
+
* when it loaded with no file, or null/undefined if it did not report one.
|
|
16
|
+
*/
|
|
17
|
+
configLoadedAt: number | null | undefined
|
|
18
|
+
/** The config file's mtime, or null when there is no file (built-in defaults). */
|
|
19
|
+
configMtimeMs: number | null | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shouldReloadConfig({ configLoadedAt, configMtimeMs }: Freshness): boolean {
|
|
23
|
+
// No file: nothing changed, leave the daemon alone.
|
|
24
|
+
if (!configMtimeMs) return false
|
|
25
|
+
// The daemon reports 0 when it loaded while there was no config file. That is not
|
|
26
|
+
// "nothing to compare" - it is a daemon running on defaults, and a file that has since
|
|
27
|
+
// appeared must be picked up. Treating 0 as absent made this state permanent.
|
|
28
|
+
if (!configLoadedAt) return true
|
|
29
|
+
// Strictly newer. No slack: a re-trigger is impossible when both sides are the same
|
|
30
|
+
// file's mtime, and a tolerance would permanently miss a save that lands within it
|
|
31
|
+
// (an editor that writes twice), which is the failure this check exists to prevent.
|
|
32
|
+
return configMtimeMs > configLoadedAt
|
|
33
|
+
}
|