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

6.8 KiB

OpenSpec Change: enterprise-macos-contacts

  • Change ID: enterprise-macos-contacts
  • Package: @suaveplan/macos-contacts
  • Category: universal/platform
  • Target path: universal/platform/macos-contacts
  • Status: PROPOSED
  • Date: 2026-04-24
  • Reference spec: /mnt/s/web/suaveplan/enterprise/macos-contacts.md

Summary

Deliver @suaveplan/macos-contacts on the genesis branch from scratch at enterprise quality. The package provides a macOS Contacts.app bridge via AppleScript and the osascript subprocess, enabling programmatic reading, searching, and writing of contacts without native bindings. Genesis adds structured errors via @suaveplan/error, Zod schemas for all contact field types, OTel instrumentation on every I/O call, AbortSignal on all async operations, injectable ProcessRunner and Clock for testability, retry with backoff on AppleScript failures, and property-based tests for data-parsing logic.


Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build.


Available workspace deps on genesis

Package Role
@suaveplan/error (workspace:^) Base class for MacosContactsError; stable error codes
@suaveplan/types (workspace:^) LoggerLike, Clock injectable interfaces
@suaveplan/logger (workspace:^) Structured logging with OTel trace correlation
@suaveplan/retry (workspace:^) Retry with exponential backoff for osascript subprocess calls
zod (catalog:forms) Zod schemas for contact types and options
@opentelemetry/api (catalog:observability) Spans + counters on contact read/write/search
tslib (catalog:build) Runtime helpers

Dev deps: vitest, @vitest/coverage-istanbul, fast-check (all catalog:testing).


Public API surface

Core types

interface MacosContact {
  readonly id: string
  readonly firstName?: string
  readonly lastName?: string
  readonly fullName: string
  readonly emails: ReadonlyArray<{ label: string; value: string }>
  readonly phones: ReadonlyArray<{ label: string; value: string }>
  readonly organization?: string
  readonly jobTitle?: string
  readonly note?: string
  readonly birthday?: string
  readonly addresses: ReadonlyArray<MacosAddress>
  readonly groups: ReadonlyArray<string>
  readonly imageData?: string
  readonly creationDate?: string
  readonly modificationDate?: string
}

interface MacosAddress {
  label: string
  street?: string
  city?: string
  state?: string
  zip?: string
  country?: string
  countryCode?: string
}

interface MacosGroup {
  readonly id: string
  readonly name: string
}

Client

function createContactsClient(options?: ContactsClientOptions): ContactsClient

interface ContactsClient {
  listContacts(options?: ListContactsOptions, signal?: AbortSignal): Promise<MacosContact[]>
  getContact(id: string, signal?: AbortSignal): Promise<MacosContact | null>
  searchContacts(query: string, options?: SearchContactsOptions, signal?: AbortSignal): Promise<MacosContact[]>
  createContact(data: CreateContactData, signal?: AbortSignal): Promise<MacosContact>
  updateContact(id: string, data: Partial<CreateContactData>, signal?: AbortSignal): Promise<MacosContact>
  deleteContact(id: string, signal?: AbortSignal): Promise<void>
  listGroups(signal?: AbortSignal): Promise<MacosGroup[]>
  addToGroup(contactId: string, groupName: string, signal?: AbortSignal): Promise<void>
  removeFromGroup(contactId: string, groupName: string, signal?: AbortSignal): Promise<void>
}

interface ContactsClientOptions {
  logger?: LoggerLike
  clock?: Clock
  processRunner?: ProcessRunner
  retryOptions?: RetryOptions
}

interface ListContactsOptions {
  groupName?: string
  limit?: number
  offset?: number
}

interface SearchContactsOptions {
  fields?: Array<'name' | 'email' | 'phone' | 'organization'>
  limit?: number
}

type CreateContactData = {
  firstName?: string
  lastName?: string
  emails?: Array<{ label: string; value: string }>
  phones?: Array<{ label: string; value: string }>
  organization?: string
  jobTitle?: string
  note?: string
  addresses?: Array<MacosAddress>
}

Process runner abstraction

interface ProcessRunner {
  run(script: string, signal?: AbortSignal): Promise<{ stdout: string; stderr: string; exitCode: number }>
}

function createOsascriptRunner(options?: OsascriptRunnerOptions): ProcessRunner

interface OsascriptRunnerOptions {
  timeout?: number
}

Error codes

type MacosContactsErrorCode =
  | 'CONTACTS_PERMISSION_DENIED'
  | 'CONTACTS_NOT_FOUND'
  | 'CONTACTS_SCRIPT_FAILED'
  | 'CONTACTS_PARSE_FAILED'
  | 'CONTACTS_CREATE_FAILED'
  | 'CONTACTS_UPDATE_FAILED'
  | 'CONTACTS_DELETE_FAILED'
  | 'CONTACTS_ABORT'

function registerMacosContactsErrorCodes(): void

Zod schemas

MacosContactSchema
MacosAddressSchema
MacosGroupSchema
CreateContactDataSchema
ContactsClientOptionsSchema
ListContactsOptionsSchema
SearchContactsOptionsSchema

Genesis additions

  1. ProcessRunner injection — decouples osascript execution for testing without mocks
  2. Structured MacosContactsError extending SuaveplanError with stable CONTACTS.* codes
  3. Full Zod schemas for every public type
  4. Retry via @suaveplan/retry on ProcessRunner.run failures
  5. AbortSignal threading on all async operations
  6. OTel instrumentation — spans on listContacts, searchContacts, createContact, updateContact, deleteContact; histogram on osascript execution duration
  7. Property-based tests for contact parsing, search filtering, address normalization

File layout

universal/platform/macos-contacts/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts
    error-codes.ts
    constants/
      index.ts
    types/
      common.ts
      common.md
    core/
      contacts-client/
        contacts-client.ts
        contacts-client.md
        contacts-client.types.ts
        contacts-client.test.ts
        index.ts
      process-runner/
        process-runner.ts
        process-runner.md
        process-runner.test.ts
        index.ts
    scripts/
      list-contacts.applescript
      get-contact.applescript
      search-contacts.applescript
      create-contact.applescript
      update-contact.applescript
      delete-contact.applescript
      list-groups.applescript
    utils/
      parse-contact.ts
      parse-contact.md
      parse-contact.test.ts
  tests/
    integration.test.ts
    property.test.ts

Impact

Platform constraint: This package requires macOS with Contacts.app and AppleScript available. Tests that call osascript are guarded by a platform check and skipped on non-macOS hosts.

Workspace dep prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/logger, @suaveplan/retry must be published to Verdaccio before this package can be built.