Initial Commit
This commit is contained in:
233
openspec/changes/enterprise-macos-contacts/proposal.md
Normal file
233
openspec/changes/enterprise-macos-contacts/proposal.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# 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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```text
|
||||
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.
|
||||
112
openspec/changes/enterprise-macos-contacts/tasks.md
Normal file
112
openspec/changes/enterprise-macos-contacts/tasks.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Tasks: enterprise-macos-contacts
|
||||
|
||||
**Change ID**: enterprise-macos-contacts
|
||||
**Package**: `@suaveplan/macos-contacts`
|
||||
**Target path**: `universal/platform/macos-contacts`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/logger`, `@suaveplan/retry`
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] Create `universal/platform/macos-contacts/` with `src/`, `tests/`, and feature subdirectories per the layout in `proposal.md`.
|
||||
- [ ] Write `package.json` at version `0.1.0`:
|
||||
- `"type": "module"`, `"sideEffects": false`
|
||||
- `exports` map: `"."` barrel + `"./core/contacts-client/contacts-client"`, `"./core/process-runner/process-runner"`, `"./utils/parse-contact"`, `"./types/common"`
|
||||
- `dependencies`: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/logger workspace:^`, `@suaveplan/retry workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`
|
||||
- `devDependencies`: `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `@suaveplan/testing workspace:^`
|
||||
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [ ] Write `vitest.config.ts` using `createTestConfig()`. Add platform guard: skip osascript tests when `process.platform !== 'darwin'`.
|
||||
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types and error codes
|
||||
|
||||
- [ ] Define all public types in `src/types/common.ts`: `MacosContact`, `MacosAddress`, `MacosGroup`, `CreateContactData`, `ListContactsOptions`, `SearchContactsOptions`, `ContactsClientOptions`, `ProcessRunner`, `OsascriptRunnerOptions`.
|
||||
- [ ] Write `src/types/common.md` (≥200 words, simple + advanced examples).
|
||||
- [ ] Define error codes in `src/error-codes.ts`: `MacosContactsErrorCode` union, metadata, `registerMacosContactsErrorCodes()`.
|
||||
- [ ] Write Zod schemas in `src/types/schemas.ts` + `src/types/schemas.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Process runner (`src/core/process-runner/`)
|
||||
|
||||
- [ ] Implement `createOsascriptRunner(options?)` — wraps `Bun.spawn` (or Node `execFile` fallback — never `exec`, to prevent shell injection) to run AppleScript via `osascript` with `-e` as a separate argument (not interpolated into a shell string).
|
||||
- [ ] Implement default `timeout` of 10 000 ms; throw `MacosContactsError(CONTACTS_SCRIPT_FAILED)` on non-zero exit.
|
||||
- [ ] Expose the `ProcessRunner` interface so tests can inject a fake runner without module-level mocking.
|
||||
- [ ] Write `process-runner.test.ts`: fake runner happy path, timeout, non-zero exit code.
|
||||
- [ ] Write `process-runner.md` (≥200 words).
|
||||
|
||||
---
|
||||
|
||||
## 4. Contact parsing (`src/utils/parse-contact.ts`)
|
||||
|
||||
- [ ] Parse the tab-delimited or structured text output returned by AppleScript contact scripts into `MacosContact` objects.
|
||||
- [ ] Implement `parseContact(raw: string): MacosContact` and `parseContacts(raw: string): MacosContact[]`.
|
||||
- [ ] Validate output with `MacosContactSchema`; throw `MacosContactsError(CONTACTS_PARSE_FAILED)` on schema error.
|
||||
- [ ] Write `parse-contact.test.ts` (≤500 lines): full contact, empty fields, multi-email, no phone.
|
||||
- [ ] Write `parse-contact.md` (≥200 words).
|
||||
|
||||
---
|
||||
|
||||
## 5. AppleScript templates (`src/scripts/`)
|
||||
|
||||
- [ ] Write `list-contacts.applescript`: returns all contacts with id, name, emails, phones.
|
||||
- [ ] Write `get-contact.applescript`: looks up by id, returns full contact record.
|
||||
- [ ] Write `search-contacts.applescript`: filters by query string across specified fields.
|
||||
- [ ] Write `create-contact.applescript`: creates new contact from JSON-encoded data passed as argument.
|
||||
- [ ] Write `update-contact.applescript`: updates existing contact fields.
|
||||
- [ ] Write `delete-contact.applescript`: deletes contact by id.
|
||||
- [ ] Write `list-groups.applescript`: returns all groups.
|
||||
|
||||
---
|
||||
|
||||
## 6. Contacts client (`src/core/contacts-client/`)
|
||||
|
||||
- [ ] Implement `createContactsClient(options?)` — wires process runner (defaults to `createOsascriptRunner()`), logger, retry.
|
||||
- [ ] Implement `listContacts(options?, signal?)` — calls list script, parses output; OTel span `macos_contacts.list`.
|
||||
- [ ] Implement `getContact(id, signal?)` — OTel span `macos_contacts.get`.
|
||||
- [ ] Implement `searchContacts(query, options?, signal?)` — OTel span `macos_contacts.search`.
|
||||
- [ ] Implement `createContact(data, signal?)` — validates with `CreateContactDataSchema`; OTel span `macos_contacts.create`.
|
||||
- [ ] Implement `updateContact(id, data, signal?)` — OTel span `macos_contacts.update`.
|
||||
- [ ] Implement `deleteContact(id, signal?)` — OTel span `macos_contacts.delete`.
|
||||
- [ ] Implement `listGroups(signal?)`, `addToGroup(...)`, `removeFromGroup(...)`.
|
||||
- [ ] All async methods check `signal.aborted` before invoking runner.
|
||||
- [ ] Wrap runner failures with retry via `@suaveplan/retry`.
|
||||
- [ ] Write `contacts-client.test.ts` (≤500 lines) using injected fake `ProcessRunner`. No osascript calls in unit tests.
|
||||
- [ ] Write `contacts-client.md` (≥200 words).
|
||||
|
||||
---
|
||||
|
||||
## 7. Property-based tests (`tests/property.test.ts`)
|
||||
|
||||
- [ ] `parseContacts` round-trip: for valid `MacosContact` objects, serialise to AppleScript output format and re-parse; result equals original.
|
||||
- [ ] Schema idempotency: `MacosContactSchema.parse(MacosContactSchema.parse(c))` equals `parse(c)`.
|
||||
- [ ] Search filtering: contacts matching query string are always a subset of all contacts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Docs
|
||||
|
||||
- [ ] All per-file `.md` files: `common.md`, `schemas.md`, `process-runner.md`, `parse-contact.md`, `contacts-client.md`.
|
||||
- [ ] `universal/platform/macos-contacts/README.md`: purpose, macOS requirement note, installation, quick-start, API section, dependency table.
|
||||
|
||||
---
|
||||
|
||||
## 9. Verify and publish
|
||||
|
||||
- [ ] `bunx turbo lint --filter=@suaveplan/macos-contacts` — zero errors.
|
||||
- [ ] `bunx turbo typecheck --filter=@suaveplan/macos-contacts` — zero errors.
|
||||
- [ ] `bunx turbo test --filter=@suaveplan/macos-contacts` — all tests pass (unit tests with fake runner).
|
||||
- [ ] `bunx turbo test:coverage --filter=@suaveplan/macos-contacts` — 100% on all touched modules.
|
||||
- [ ] `bun run docs:check --filter=@suaveplan/macos-contacts` — zero errors.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/macos-contacts`.
|
||||
- [ ] Update `CLAUDE.md` package listing.
|
||||
- [ ] Archive: move `openspec/changes/enterprise-macos-contacts/` to `openspec/archive/2026-04-24-enterprise-macos-contacts/`.
|
||||
Reference in New Issue
Block a user