Files
2026-05-27 23:29:53 +01:00

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_CODES registry via defineCodes("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 optional query / groupName / contactId discriminants. Replaces upstream's HttpError(... 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 by outcome (success | permission_denied | access_failed).
  • ADDED: AppleScriptRunner interface ({ run(script: string, signal?: AbortSignal): Promise<string> }) with a default execFile-based impl. Tests inject a fake runner that returns fixture strings, no macOS / no mocking framework required.
  • ADDED: Injectable Clock (from @suaveplan/types) — replaces upstream new Date() in SyncResult.syncedAt.
  • MODIFIED: ContactParser lowered from a static-only class with a biome suppression to a module of pure free functions (parseContacts, parseGroups, normalizePhone). Drops the // biome-ignore lint/complexity/noStaticOnlyClass comment outright.
  • REMOVED: Upstream's four @deprecated MacOSContactsBridge.*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 — osascript is 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-contacts returning only @suaveplan/macos-contacts itself).

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 through
  • src/schemas/schemas.test.ts — every schema accepts canonical input, rejects malformed input via MacOSContactsError(INVALID_INPUT)
  • src/otel/instruments.test.ts — instruments observable via in-memory MeterProvider; every counter + histogram fires from a real bridge call
  • src/parser/parser.test.ts — every parser branch (parseContacts, parseGroups, normalizePhone) including malformed-skip and empty-input guards
  • src/applescript/applescript.test.ts — every template builder including " escaping
  • src/executor/executor.test.ts — createDefaultAppleScriptRunner end-to-end against the real platform binary when available, asserting the failure path everywhere via a deliberately-failing script
  • src/bridge/bridge.test.ts — every MacOSContactsBridge method 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 real MeterProvider
  • src/macos-contacts.spec-conformance.test.ts — pins barrel shape (§6a), subpath exports, error-code wiring, no direct vitest imports (§7)