mailwoman 7.3.0 → 7.5.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 CHANGED
@@ -76,6 +76,10 @@ mailwoman registry --sources config.json --out entities.geojson
76
76
 
77
77
  # Interactive TUI
78
78
  mailwoman parse --tui
79
+
80
+ # Diagnose the install — what works, what's missing, and the one command to fix each gap
81
+ mailwoman doctor # human-readable checklist (exit 1 if a core check fails)
82
+ mailwoman doctor --json # machine-readable { checks: [{ id, label, status, detail, fix?, core }], exitCode }
79
83
  ```
80
84
 
81
85
  ## Library API
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman doctor [--json]` — the out-of-box diagnostic. A consumer who just ran `npm i mailwoman`
7
+ * runs this and learns exactly what works, what's missing, and the ONE command that fixes each gap.
8
+ * Six checks: model weights, the fr-fr locale overlay (informational), the data root, the admin
9
+ * gazetteer, the POI layer, and the Node + ONNX runtime.
10
+ *
11
+ * Exit-code contract (meaning-of-zero, per `checks.ts`):
12
+ *
13
+ * - 0 when every CORE check (weights + runtime) is `ok` — parse is ready, even with no data layers.
14
+ * - 1 when any core check is not `ok`.
15
+ *
16
+ * Data layers (data root, gazetteer, POI) are OPTIONAL: a gap is reported as missing/degraded with a
17
+ * fix hint, never a hard failure — parse runs without them. All verdict logic is pure and unit-tested
18
+ * in `doctor/checks.ts`; the IO seams live in `doctor/runner.ts`.
19
+ */
20
+
21
+ import { Box, Text } from "ink"
22
+ import type * as React from "react"
23
+ import zod from "zod"
24
+
25
+ import { type CommandComponent, useCommandTask } from "../cli-kit/index.ts"
26
+ import { CheckStatus, type DoctorCheck, type DoctorReport } from "../doctor/checks.ts"
27
+ import { runDoctor } from "../doctor/runner.ts"
28
+
29
+ const OptionsSchema = zod.object({
30
+ json: zod
31
+ .boolean()
32
+ .optional()
33
+ .default(false)
34
+ .describe(
35
+ "Emit the report as JSON instead of a checklist: { checks: [{ id, label, status, detail, fix?, core }], exitCode } " +
36
+ "— a superset of { id, status, detail, fix? } (label + core aid machine consumers)."
37
+ ),
38
+ })
39
+
40
+ export { OptionsSchema as options }
41
+
42
+ /** The status glyph + ink color for a check outcome. */
43
+ function statusGlyph(status: CheckStatus): { glyph: string; color: string } {
44
+ switch (status) {
45
+ case CheckStatus.OK:
46
+ return { glyph: "✓", color: "green" }
47
+ case CheckStatus.Degraded:
48
+ return { glyph: "⚠", color: "yellow" }
49
+ case CheckStatus.Missing:
50
+ return { glyph: "✗", color: "red" }
51
+ }
52
+ }
53
+
54
+ /** One check row: the glyph + label + detail, plus an indented `fix:` line and an `(optional)` tag for non-core gaps. */
55
+ function CheckRow({ check }: { check: DoctorCheck }): React.ReactElement {
56
+ const { glyph, color } = statusGlyph(check.status)
57
+ const optional = !check.core && check.status !== CheckStatus.OK ? " (optional)" : ""
58
+
59
+ return (
60
+ <Box flexDirection="column">
61
+ <Text color={color}>
62
+ {glyph} {check.label}: {check.detail}
63
+ {optional}
64
+ </Text>
65
+ {check.fix ? <Text color="gray">{` fix: ${check.fix}`}</Text> : null}
66
+ </Box>
67
+ )
68
+ }
69
+
70
+ /** The rendered checklist + PASS/FAIL summary. */
71
+ function Report({ report }: { report: DoctorReport }): React.ReactElement {
72
+ const pass = report.exitCode === 0
73
+
74
+ return (
75
+ <Box flexDirection="column">
76
+ <Text bold>mailwoman doctor</Text>
77
+ <Text> </Text>
78
+ {report.checks.map((check) => (
79
+ <CheckRow key={check.id} check={check} />
80
+ ))}
81
+ <Text> </Text>
82
+ <Text color={pass ? "green" : "red"} bold>
83
+ {pass ? "PASS — core checks ok (weights + runtime); parse is ready" : "FAIL — a core check is not ok"}
84
+ </Text>
85
+ </Box>
86
+ )
87
+ }
88
+
89
+ const DoctorCommand: CommandComponent<typeof OptionsSchema> = ({ options }) => {
90
+ // `--json` writes the report straight to stdout via `console.log` (mirroring `mailwoman openapi`) and
91
+ // renders NOTHING through Ink — Ink's `<Text>` hard-wraps to the terminal width, which would inject
92
+ // newlines mid-string and corrupt the JSON when piped. The checklist form renders through Ink normally.
93
+ const state = useCommandTask<DoctorReport>(
94
+ async () => {
95
+ const report = await runDoctor()
96
+
97
+ if (options.json) {
98
+ console.log(JSON.stringify(report, null, 2))
99
+ }
100
+
101
+ return report
102
+ },
103
+ (report) => report.exitCode
104
+ )
105
+
106
+ if (state.status === "error") {
107
+ return <Text color="red">{state.message}</Text>
108
+ }
109
+
110
+ // JSON payload already emitted in the task; give Ink nothing to draw (transient frames would pollute stdout).
111
+ if (options.json) return null
112
+
113
+ if (state.status !== "done") {
114
+ return <Text color="gray">running diagnostics…</Text>
115
+ }
116
+
117
+ return <Report report={state.result} />
118
+ }
119
+
120
+ export default DoctorCommand
@@ -0,0 +1,363 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Pure decision logic for `mailwoman doctor` — the out-of-box diagnostic. Each `*Check` function
7
+ * takes a plain OBSERVATION object (facts already gathered from the filesystem/runtime by
8
+ * {@link ../doctor/runner.ts}) and returns a {@link DoctorCheck}. Keeping the verdict logic pure —
9
+ * no `fs`, no `import`, no env — is what makes it unit-testable without rendering Ink or standing up
10
+ * a data root: the runner injects the IO seams, this module owns only the ok/missing/degraded call.
11
+ *
12
+ * Meaning-of-zero discipline (memory: feedback-meaning-of-zero): a missing OPTIONAL layer reports as
13
+ * `missing`/`degraded` with a fix hint, never as a hard error. Only the CORE checks (weights +
14
+ * runtime) drive the process exit code — parse works without a data root, gazetteer, or POI layer.
15
+ */
16
+
17
+ /** A check's outcome. `ok` = works; `missing` = absent but fixable; `degraded` = present but impaired. */
18
+ export const CheckStatus = {
19
+ OK: "ok",
20
+ Missing: "missing",
21
+ Degraded: "degraded",
22
+ } as const
23
+
24
+ export type CheckStatus = (typeof CheckStatus)[keyof typeof CheckStatus]
25
+
26
+ /** One diagnostic line: a stable `id`, its `status`, a human `detail`, and (when not ok) the one command that fixes it. */
27
+ export interface DoctorCheck {
28
+ id: string
29
+ /** Human-facing label for the check (rendered in the checklist). */
30
+ label: string
31
+ status: CheckStatus
32
+ detail: string
33
+ /** The single command/URL that closes the gap. Present whenever `status !== "ok"`. */
34
+ fix?: string
35
+ /**
36
+ * Whether this check gates the exit code. Core checks (weights + runtime) must be `ok` for a `0` exit; optional
37
+ * data-layer checks report their gap but never fail the process (parse runs without them).
38
+ */
39
+ core: boolean
40
+ }
41
+
42
+ /** The full diagnostic report — the checklist plus the derived exit code. */
43
+ export interface DoctorReport {
44
+ checks: DoctorCheck[]
45
+ exitCode: number
46
+ }
47
+
48
+ /** A parsed `<major>.<minor>.<patch>` triple. */
49
+ export interface SemverTriple {
50
+ major: number
51
+ minor: number
52
+ patch: number
53
+ }
54
+
55
+ /**
56
+ * Parse the minimum version out of a package.json `engines.node` range (`">=24.18.0"`, `"24.18.0"`, `">= 24"`). Returns
57
+ * `undefined` when no `<major>[.<minor>[.<patch>]]` is findable. Only the floor matters for the doctor — a
58
+ * caret/tilde/comparator prefix is stripped and missing minor/patch default to 0.
59
+ */
60
+ export function parseVersionFloor(engines: string): SemverTriple | undefined {
61
+ const match = engines.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/u)
62
+
63
+ if (!match) return undefined
64
+
65
+ return { major: Number(match[1]), minor: Number(match[2] ?? 0), patch: Number(match[3] ?? 0) }
66
+ }
67
+
68
+ /** Parse a bare `<major>.<minor>.<patch>` runtime version (e.g. `process.versions.node`). `undefined` if unparseable. */
69
+ export function parseVersion(version: string): SemverTriple | undefined {
70
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)/u)
71
+
72
+ if (!match) return undefined
73
+
74
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }
75
+ }
76
+
77
+ /** `true` when `version` is at least `floor` under lexicographic major→minor→patch comparison. */
78
+ export function versionMeetsFloor(version: string, floor: string): boolean {
79
+ const v = parseVersion(version)
80
+ const f = parseVersionFloor(floor)
81
+
82
+ if (!v || !f) return false
83
+
84
+ if (v.major !== f.major) return v.major > f.major
85
+
86
+ if (v.minor !== f.minor) return v.minor > f.minor
87
+
88
+ return v.patch >= f.patch
89
+ }
90
+
91
+ /** Bytes → a compact `12.3 MB` / `640 KB` / `12 B` string. */
92
+ export function formatBytes(bytes: number): string {
93
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`
94
+
95
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} KB`
96
+
97
+ return `${bytes} B`
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Observations (facts the runner gathers) → checks (verdicts)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** Facts about the `@mailwoman/neural-weights-en-us` resolution. */
105
+ export interface WeightsObservation {
106
+ /** Resolved paths + source tag, or absent when resolution threw. */
107
+ resolved?: { source: string; modelPath: string; tokenizerPath: string }
108
+ /** Byte size of the resolved `model.onnx` (undefined if unresolved/unstattable). */
109
+ modelSize?: number
110
+ /** Byte size of the resolved `tokenizer.model`. */
111
+ tokenizerSize?: number
112
+ /** The resolution error message, when resolution failed. */
113
+ error?: string
114
+ }
115
+
116
+ const WEIGHTS_FIX = "npm install @mailwoman/neural-weights-en-us (or: mailwoman parse --download-weights)"
117
+
118
+ /** Check #1 — the trained model bundle. CORE: parse cannot run without it. */
119
+ export function weightsCheck(o: WeightsObservation): DoctorCheck {
120
+ const base = { id: "weights", label: "Model weights (en-us)", core: true }
121
+
122
+ if (!o.resolved) {
123
+ return {
124
+ ...base,
125
+ status: CheckStatus.Missing,
126
+ detail: o.error ? firstLine(o.error) : "@mailwoman/neural-weights-en-us is not resolvable",
127
+ fix: WEIGHTS_FIX,
128
+ }
129
+ }
130
+
131
+ if (!o.modelSize || !o.tokenizerSize) {
132
+ return {
133
+ ...base,
134
+ status: CheckStatus.Degraded,
135
+ detail: `resolved (${o.resolved.source}) but a weight file is empty — model.onnx ${formatBytes(o.modelSize ?? 0)}, tokenizer.model ${formatBytes(o.tokenizerSize ?? 0)}`,
136
+ fix: WEIGHTS_FIX,
137
+ }
138
+ }
139
+
140
+ return {
141
+ ...base,
142
+ status: CheckStatus.OK,
143
+ detail: `${o.resolved.source} · model.onnx ${formatBytes(o.modelSize)}, tokenizer.model ${formatBytes(o.tokenizerSize)}`,
144
+ }
145
+ }
146
+
147
+ /** Facts about an optional locale-overlay weights package (e.g. fr-fr). */
148
+ export interface LocaleOverlayObservation {
149
+ locale: string
150
+ packageName: string
151
+ resolved: boolean
152
+ source?: string
153
+ }
154
+
155
+ /** Check #2 — a locale overlay (fr-fr). Informational (never core): its absence is expected on an en-us-only install. */
156
+ export function localeOverlayCheck(o: LocaleOverlayObservation): DoctorCheck {
157
+ const base = { id: `locale-overlay-${o.locale}`, label: `Locale overlay (${o.locale})`, core: false }
158
+
159
+ if (o.resolved) {
160
+ return { ...base, status: CheckStatus.OK, detail: `${o.packageName} resolvable${o.source ? ` (${o.source})` : ""}` }
161
+ }
162
+
163
+ return {
164
+ ...base,
165
+ status: CheckStatus.Missing,
166
+ detail: `${o.packageName} not installed (optional — only needed for ${o.locale} parsing)`,
167
+ fix: `npm install ${o.packageName}`,
168
+ }
169
+ }
170
+
171
+ /** Facts about the resolved data root. */
172
+ export interface DataRootObservation {
173
+ /** The path from the blessed `@mailwoman/core/utils` helper — never re-derived here. */
174
+ path: string
175
+ exists: boolean
176
+ writable: boolean
177
+ /** Whether `$MAILWOMAN_DATA_ROOT` was set (vs. the built-in default). */
178
+ fromEnv: boolean
179
+ }
180
+
181
+ /** Check #3 — the data root. Optional: an unwritable/absent root only blocks build tooling, not parse. */
182
+ export function dataRootCheck(o: DataRootObservation): DoctorCheck {
183
+ const base = { id: "data-root", label: "Data root", core: false }
184
+ const source = o.fromEnv ? "$MAILWOMAN_DATA_ROOT" : "default"
185
+
186
+ if (!o.exists) {
187
+ return {
188
+ ...base,
189
+ status: CheckStatus.Missing,
190
+ detail: `${o.path} (${source}) does not exist`,
191
+ fix: `mkdir -p ${o.path} (or set $MAILWOMAN_DATA_ROOT to an existing dir)`,
192
+ }
193
+ }
194
+
195
+ if (!o.writable) {
196
+ return {
197
+ ...base,
198
+ status: CheckStatus.Degraded,
199
+ detail: `${o.path} (${source}) exists but is not writable`,
200
+ fix: `chmod u+w ${o.path} (or set $MAILWOMAN_DATA_ROOT to a writable dir)`,
201
+ }
202
+ }
203
+
204
+ return { ...base, status: CheckStatus.OK, detail: `${o.path} (${source}) — exists, writable` }
205
+ }
206
+
207
+ /**
208
+ * Facts about the admin gazetteer discovery, mirroring exactly what the TOOLS pick up. `mailwoman geocode` / `serve`
209
+ * resolve a candidate.db ONLY through `resolveCandidateDBPath` (explicit ?? `$MAILWOMAN_CANDIDATE_DB`) — there is no
210
+ * convention-path fallback — else they fall back to the WOF FTS shards. So a candidate.db sitting at the
211
+ * `<data-root>/wof/candidate.db` convention path while the env is UNSET is a TRAP: on disk, but the tools won't touch
212
+ * it.
213
+ */
214
+ export interface GazetteerObservation {
215
+ /** A candidate.db the tools would actually use — explicit/`$MAILWOMAN_CANDIDATE_DB`, on disk. Green. */
216
+ envCandidate?: { path: string; sizeBytes?: number }
217
+ /** A WOF admin shard on disk — the FTS backend the tools fall back to when no env candidate is set. Green. */
218
+ wofShard?: { path: string; sizeBytes?: number }
219
+ /** A candidate.db at the convention path while `$MAILWOMAN_CANDIDATE_DB` is UNSET — the trap. Degraded, not green. */
220
+ conventionCandidate?: string
221
+ /** The paths probed, for the not-found detail. */
222
+ probed: string[]
223
+ }
224
+
225
+ const CANDIDATE_URL = "https://public.sister.software/mailwoman/gazetteer/2026-07-07a/candidate.db"
226
+
227
+ /** Check #4 — the admin gazetteer. Optional: parse runs without it; only geocode/resolve need it. */
228
+ export function gazetteerCheck(o: GazetteerObservation): DoctorCheck {
229
+ const base = { id: "gazetteer", label: "Admin gazetteer", core: false }
230
+
231
+ if (o.envCandidate) {
232
+ const size = o.envCandidate.sizeBytes ? ` (${formatBytes(o.envCandidate.sizeBytes)})` : ""
233
+
234
+ return { ...base, status: CheckStatus.OK, detail: `candidate.db · ${o.envCandidate.path}${size}` }
235
+ }
236
+
237
+ if (o.wofShard) {
238
+ const size = o.wofShard.sizeBytes ? ` (${formatBytes(o.wofShard.sizeBytes)})` : ""
239
+
240
+ return { ...base, status: CheckStatus.OK, detail: `WOF admin shard · ${o.wofShard.path}${size}` }
241
+ }
242
+
243
+ // The trap: candidate.db is on disk at the convention path, but the tools resolve candidate only via the env — so
244
+ // they'd report "no gazetteer data found" while doctor could naively show green. Report degraded, not ok.
245
+ if (o.conventionCandidate) {
246
+ return {
247
+ ...base,
248
+ status: CheckStatus.Degraded,
249
+ detail: `candidate.db on disk (${o.conventionCandidate}) but $MAILWOMAN_CANDIDATE_DB unset — geocode/serve won't use it`,
250
+ fix: `export MAILWOMAN_CANDIDATE_DB=${o.conventionCandidate}`,
251
+ }
252
+ }
253
+
254
+ return {
255
+ ...base,
256
+ status: CheckStatus.Missing,
257
+ detail: `no candidate.db or WOF shard found (probed ${o.probed.length} path${o.probed.length === 1 ? "" : "s"})`,
258
+ fix: `curl -fSL ${CANDIDATE_URL} -o <data-root>/wof/candidate.db (then: export MAILWOMAN_CANDIDATE_DB=<data-root>/wof/candidate.db)`,
259
+ }
260
+ }
261
+
262
+ /** Facts about the POI layer (mirrors `gazetteer build poi`'s default output path). */
263
+ export interface POIObservation {
264
+ path: string
265
+ exists: boolean
266
+ /** The parsed layer manifest, when the db opened and validated. */
267
+ manifest?: { name: string; version: string; sourceVintage: string }
268
+ /** A read error, when the db exists but the manifest couldn't be read. */
269
+ error?: string
270
+ }
271
+
272
+ const POI_URL = "https://public.sister.software/mailwoman/poi/2026-07-20a/poi.db"
273
+
274
+ /** Check #5 — the POI layer. Optional: only POI-query execution needs it. */
275
+ export function checkPOI(o: POIObservation): DoctorCheck {
276
+ const base = { id: "poi-layer", label: "POI layer", core: false }
277
+ const fix = `mailwoman gazetteer build poi (or: curl -fSL ${POI_URL} -o ${o.path})`
278
+
279
+ if (!o.exists) {
280
+ return { ...base, status: CheckStatus.Missing, detail: `${o.path} not found`, fix }
281
+ }
282
+
283
+ if (!o.manifest) {
284
+ return {
285
+ ...base,
286
+ status: CheckStatus.Degraded,
287
+ detail: `${o.path} present but the layer manifest is unreadable${o.error ? `: ${firstLine(o.error)}` : ""}`,
288
+ fix,
289
+ }
290
+ }
291
+
292
+ return {
293
+ ...base,
294
+ status: CheckStatus.OK,
295
+ detail: `${o.manifest.name} v${o.manifest.version} · vintage ${o.manifest.sourceVintage} · ${o.path}`,
296
+ }
297
+ }
298
+
299
+ /** Facts about the Node runtime version vs. the package `engines` floor. */
300
+ export interface NodeRuntimeObservation {
301
+ nodeVersion: string
302
+ enginesFloor: string
303
+ }
304
+
305
+ /** Check #6a — the Node version floor. CORE. */
306
+ export function nodeVersionCheck(o: NodeRuntimeObservation): DoctorCheck {
307
+ const base = { id: "node-version", label: "Node runtime", core: true }
308
+
309
+ if (versionMeetsFloor(o.nodeVersion, o.enginesFloor)) {
310
+ return { ...base, status: CheckStatus.OK, detail: `node v${o.nodeVersion} (engines: ${o.enginesFloor})` }
311
+ }
312
+
313
+ return {
314
+ ...base,
315
+ status: CheckStatus.Degraded,
316
+ detail: `node v${o.nodeVersion} is below the required ${o.enginesFloor}`,
317
+ fix: `upgrade Node to satisfy ${o.enginesFloor}`,
318
+ }
319
+ }
320
+
321
+ /** Facts about the ONNX runtime binding. */
322
+ export interface OnnxRuntimeObservation {
323
+ loadable: boolean
324
+ error?: string
325
+ }
326
+
327
+ /** Check #6b — onnxruntime-node loadability. CORE: the neural runtime cannot infer without it. */
328
+ export function onnxRuntimeCheck(o: OnnxRuntimeObservation): DoctorCheck {
329
+ const base = { id: "onnxruntime", label: "ONNX runtime", core: true }
330
+
331
+ if (o.loadable) {
332
+ return { ...base, status: CheckStatus.OK, detail: "onnxruntime-node loadable" }
333
+ }
334
+
335
+ return {
336
+ ...base,
337
+ status: CheckStatus.Degraded,
338
+ detail: `onnxruntime-node failed to load${o.error ? `: ${firstLine(o.error)}` : ""}`,
339
+ fix: "npm install onnxruntime-node (or reinstall @mailwoman/neural)",
340
+ }
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Aggregate
345
+ // ---------------------------------------------------------------------------
346
+
347
+ /**
348
+ * Derive the process exit code: `0` when every CORE check is `ok`, else `1`. Optional data-layer checks report their
349
+ * gaps but never fail the process — the meaning-of-zero rule (a missing optional layer is not a hard error).
350
+ */
351
+ export function computeExitCode(checks: readonly DoctorCheck[]): number {
352
+ return checks.some((c) => c.core && c.status !== CheckStatus.OK) ? 1 : 0
353
+ }
354
+
355
+ /** Assemble the report + exit code from the ordered checks. */
356
+ export function assembleReport(checks: DoctorCheck[]): DoctorReport {
357
+ return { checks, exitCode: computeExitCode(checks) }
358
+ }
359
+
360
+ /** First line of a possibly-multiline error/stack, trimmed — keeps the checklist to one line per check. */
361
+ function firstLine(message: string): string {
362
+ return message.split("\n", 1)[0]!.trim()
363
+ }