11 KiB
Migrate @suaveplan/macos-contacts
Package: @suaveplan/macos-contacts
Status: PROPOSED
Type: new-package
Design: (none — see specs/macos-contacts/spec.md for normative contract)
Spec delta: ./specs/macos-contacts/spec.md
Why
@suaveplan/macos-contacts is the bridge between macOS Contacts.app and the genesis contacts pipeline (@suaveplan/contacts, @suaveplan/api-plugin-contacts, @suaveplan/react-contacts). Upstream lives at ../suaveplan/packages/universal/macos-contacts/ and is functional but enterprise-incomplete: bare HttpError with unknown as HttpErrorCode casts, four @deprecated static script-accessor methods, a static-only ContactParser class with a biome-ignore suppression, zero OTel instrumentation, hard-coded shell invocation (untestable without macOS), inline new Date() calls. This migration brings the package across at full enterprise quality with structured MacOSContactsError + MACOS_CONTACTS_* code registry, an injectable AppleScriptRunner seam (lets tests run on any platform without macOS), free-function ContactParser module, injectable Clock for sync timestamps, OTel counters and histograms across every public op, and Zod input validation at the boundary.
What changes
- ADDED: 9 public exports —
MacOSContactsBridge,parseContacts/parseGroups/normalizePhone(free-function ContactParser surface),getAllContactsScript/searchContactsScript/getGroupsScript/getGroupMembersScript(standalone AppleScript template builders),MACOS_CONTACTS_CODES,MacOSContactsError,MacOSContactsErrorCode,MacOSContactsErrorOptions, the OTel instruments bundle (getMacOSContactsInstruments/resetMacOSContactsInstruments), Zod schemas (macOSContactsConfigSchema,searchQuerySchema,groupNameSchema,contactIdSchema,parseOrThrow), the entity types (MacOSContact,ContactPhone,ContactEmail,ContactAddress,ContactGroup,SyncResult,SyncError,MacOSContactsConfig), and the runner seam (AppleScriptRunner,createDefaultAppleScriptRunner). - ADDED:
MACOS_CONTACTS_CODESregistry viadefineCodes("MACOS_CONTACTS", [...])with six stable codes —INVALID_INPUT,CONTACTS_NOT_FOUND,CONTACTS_PERMISSION_DENIED,CONTACTS_ACCESS_FAILED,CONTACTS_PARSE_FAILED,CONTACTS_UNAVAILABLE. - ADDED:
MacOSContactsError extends SuaveplanError<MacOSContactsErrorCode>with optionalquery/groupName/contactIddiscriminants. Replaces upstream'sHttpError(... code as unknown as HttpErrorCode)cast. - ADDED: OTel instruments bundle exposing six counters (
macos_contacts.list_total,macos_contacts.search_total,macos_contacts.get_total,macos_contacts.groups_list_total,macos_contacts.group_members_total,macos_contacts.sync_total) and two histograms (macos_contacts.applescript_duration_ms,macos_contacts.sync_duration_ms); every counter partitions byoutcome(success|permission_denied|access_failed). - ADDED:
AppleScriptRunnerinterface ({ run(script: string, signal?: AbortSignal): Promise<string> }) with a defaultexecFile-based impl. Tests inject a fake runner that returns fixture strings, no macOS / no mocking framework required. - ADDED: Injectable
Clock(from@suaveplan/types) — replaces upstreamnew Date()inSyncResult.syncedAt. - MODIFIED:
ContactParserlowered from astatic-only class with a biome suppression to a module of pure free functions (parseContacts,parseGroups,normalizePhone). Drops the// biome-ignore lint/complexity/noStaticOnlyClasscomment outright. - REMOVED: Upstream's four
@deprecatedMacOSContactsBridge.*Script()static methods. Callers use the standalone template-builder exports directly (already exported from upstream — verified in source).
Out of scope
- Write-side operations (create / update / delete a contact via AppleScript). Upstream is read-only; this migration matches.
- macOS Contacts framework via native bindings (e.g.
node-mac-contacts). Out of scope per upstream design —osascriptis the supported path. - A streaming / cursor interface for very large address books. Upstream returns the full list; this migration preserves the same surface.
Source mapping
This change derives from ../suaveplan/enterprise/macos-contacts.md and the upstream source under ../suaveplan/packages/universal/macos-contacts/src/.
| Requirement | Source lines | Notes |
|---|---|---|
| Public surface preserved (bridge + parser + templates + types + error codes) | src/index.ts:1-32 |
verbatim; ContactParser static class lowered to free functions |
MacOSContact, ContactPhone, ContactEmail, ContactAddress, ContactGroup, MacOSContactsConfig, SyncResult, SyncError types |
src/types/macos-contacts.types.ts:1-86 |
verbatim shape; CommandExecutor replaced by typed AppleScriptRunner |
MACOS_CONTACTS_* code registry (5 codes) |
src/error-codes.ts:19-75 |
preserved; one new code (INVALID_INPUT) added for Zod boundary failures |
| Parse single contact block / multi-contact stream / empty input / malformed block | src/core/contact-parser/contact-parser.test.ts:6-87 |
preserved; tests rewritten against free functions |
| Parse groups + skip malformed lines + empty input | src/core/contact-parser/contact-parser.test.ts:90-111 |
verbatim |
normalizePhone — strip non-digit, preserve +, NANP default, empty/non-digit guard |
src/core/contact-parser/contact-parser.test.ts:114-147 |
verbatim |
AppleScript builders — getAllContactsScript, searchContactsScript (with " escape), getGroupsScript, getGroupMembersScript (with " escape) |
src/core/contacts-bridge/applescript-templates.ts:48-101 |
verbatim |
MacOSContactsBridge.getAllContacts — parse osascript output to MacOSContact[] |
src/core/contacts-bridge/contacts-bridge.ts:106-115 |
verbatim semantics; raw invocation via injectable runner |
MacOSContactsBridge.searchContacts(query) — empty-query guard, errors mapped with query discriminant |
src/core/contacts-bridge/contacts-bridge.ts:119-132 |
verbatim semantics; error becomes MacOSContactsError with query discriminant |
MacOSContactsBridge.getContactById(id) — empty-id guard, returns null when unknown |
src/core/contacts-bridge/contacts-bridge.ts:136-143 |
verbatim |
MacOSContactsBridge.getGroups() — list contact groups |
src/core/contacts-bridge/contacts-bridge.ts:147-157 |
verbatim |
MacOSContactsBridge.getGroupMembers(groupName) — empty-name guard, members of named group |
src/core/contacts-bridge/contacts-bridge.ts:161-174 |
verbatim |
MacOSContactsBridge.sync() — capture errors in SyncResult.errors, never throw |
src/core/contacts-bridge/contacts-bridge.ts:178-207 |
verbatim semantics; uses injectable Clock for syncedAt |
MacOSContactsBridge.isAvailable() — probe osascript via System Events, never throw |
src/core/contacts-bridge/contacts-bridge.ts:92-101 |
verbatim |
Error mapping — permission patterns → CONTACTS_PERMISSION_DENIED, everything else → CONTACTS_ACCESS_FAILED |
src/core/contacts-bridge/contacts-bridge.ts:26-50 |
verbatim regex; raises MacOSContactsError instead of HttpError |
| Universal-tier purity in everything except the default runner (which is a server-tier seam, isolated in its own module) | enterprise/macos-contacts.md:L37 |
verbatim |
| Zod schemas at every public input boundary | enterprise/macos-contacts.md:L38 |
verbatim |
Deterministic behaviour — injectable Clock, no new Date() at module scope |
enterprise/macos-contacts.md:L40 |
verbatim |
OTel instrumentation via @suaveplan/opentelemetry for all public entrypoints |
enterprise/macos-contacts.md:L41 |
verbatim |
Structured errors extending @suaveplan/error with stable codes |
enterprise/macos-contacts.md:L42 |
verbatim |
100% Istanbul coverage, no it.skip, no any, no ts-ignore |
enterprise/macos-contacts.md:L50-L60 |
verbatim |
Requirements with no source line
| Requirement | Justification |
|---|---|
INVALID_INPUT code |
Genesis-wide policy: Zod boundary failures raise a typed MacOSContactsError, never throw raw ZodError. Mirrors @suaveplan/contacts. |
AppleScriptRunner injectable interface |
Upstream's CommandExecutor = (string) => Promise<string> is a bare function alias; the genesis seam is a typed interface with a run(script, signal?) signature so future implementations (e.g. node-mac-contacts, FFI bindings) can plug in without surface drift. Also lets the bridge thread AbortSignal per genesis policy. |
OTel counters partition by outcome |
Genesis-wide observability rule: counters that wrap IO must split success vs typed failure modes so dashboards can distinguish "permission revoked in System Settings" from "transient osascript failure" without scraping logs. |
MacOSContactsError carries query / groupName / contactId discriminants |
Lets consumers dispatch on which validation failed without parsing messages. |
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/macos-contacts to enumerate. The package is freshly migrated and has no in-repo consumers yet:
- No dependents (verified by
bunx turbo ls --filter=...@suaveplan/macos-contactsreturning only@suaveplan/macos-contactsitself).
Future consumers (planned but not yet migrated): @suaveplan/api-plugin-contacts, @suaveplan/react-contacts. They will be migrated under their own OpenSpec changes and will adopt the new MacOSContactsError / AppleScriptRunner surface at that time.
Verification approach
src/error-codes/error-codes.test.ts— registry shape, every code constructible, discriminants flow throughsrc/schemas/schemas.test.ts— every schema accepts canonical input, rejects malformed input viaMacOSContactsError(INVALID_INPUT)src/otel/instruments.test.ts— instruments observable via in-memoryMeterProvider; every counter + histogram fires from a real bridge callsrc/parser/parser.test.ts— every parser branch (parseContacts,parseGroups,normalizePhone) including malformed-skip and empty-input guardssrc/applescript/applescript.test.ts— every template builder including"escapingsrc/executor/executor.test.ts—createDefaultAppleScriptRunnerend-to-end against the real platform binary when available, asserting the failure path everywhere via a deliberately-failing scriptsrc/bridge/bridge.test.ts— everyMacOSContactsBridgemethod with an injected fake runner; permission-pattern →CONTACTS_PERMISSION_DENIED, generic failure →CONTACTS_ACCESS_FAILED, sync swallows errors, isAvailable swallows errors; OTel counter + histogram emission asserted via realMeterProvidersrc/macos-contacts.spec-conformance.test.ts— pins barrel shape (§6a), subpath exports, error-code wiring, no directvitestimports (§7)