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
ProcessRunnerinjection — decouplesosascriptexecution for testing without mocks- Structured
MacosContactsErrorextendingSuaveplanErrorwith stableCONTACTS.*codes - Full Zod schemas for every public type
- Retry via
@suaveplan/retryonProcessRunner.runfailures AbortSignalthreading on all async operations- OTel instrumentation — spans on
listContacts,searchContacts,createContact,updateContact,deleteContact; histogram onosascriptexecution duration - 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.