# 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` 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 }`) 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` 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)