first commit
This commit is contained in:
61
docs/commerce/contacts/macos-contacts/README.md
Normal file
61
docs/commerce/contacts/macos-contacts/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# @suaveplan/macos-contacts - Universal macOS Contacts.app bridge via AppleScript: injectable AppleScriptRunner seam (run tests on any platform without macOS), free-function ContactParser surface, injectable Clock for sync timestamps, six OTel counters + two histograms with outcome-partitioned attributes, typed MacOSContactsError hierarchy with stable MACOS_CONTACTS.* code registry, and Zod input validation at every boundary.
|
||||
|
||||
Universal bridge to macOS Contacts.app via AppleScript. Reads contacts and groups through the platform `osascript` binary, parses the output into typed records, and exposes the result behind an injectable runner seam so tests run on any platform without macOS.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [applescript](./src/applescript/applescript.md) — AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suit…
|
||||
- [applescript](./src/applescript/applescript.md) — AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suit…
|
||||
- [bridge](./src/bridge/bridge.md) — `MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building bloc…
|
||||
- [bridge](./src/bridge/bridge.md) — `MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building bloc…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `MacOSContactsError` class for `@suaveplan/macos-contacts`. Every fail…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `MacOSContactsError` class for `@suaveplan/macos-contacts`. Every fail…
|
||||
- [executor](./src/executor/executor.md) — Defines the `AppleScriptRunner` injectable seam that `MacOSContactsBridge` depends on, plus a default implementation ba…
|
||||
- [executor](./src/executor/executor.md) — Defines the `AppleScriptRunner` injectable seam that `MacOSContactsBridge` depends on, plus a default implementation ba…
|
||||
- [otel](./src/otel/instruments.md) — Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`. The accessor `getMacOSContactsInstruments()…
|
||||
- [instruments](./src/otel/instruments.md) — Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`. The accessor `getMacOSContactsInstruments()…
|
||||
- [parser](./src/parser/parser.md) — Pure free-function parser for the raw text stream emitted by the AppleScript templates that drive `MacOSContactsBridge`…
|
||||
- [parser](./src/parser/parser.md) — Pure free-function parser for the raw text stream emitted by the AppleScript templates that drive `MacOSContactsBridge`…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas guarding every public input boundary of `@suaveplan/macos-contacts`. Validation runs before any side effect…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas guarding every public input boundary of `@suaveplan/macos-contacts`. Validation runs before any side effect…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/macos-contacts
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { MacOSContactsBridge } from "@suaveplan/macos-contacts";
|
||||
|
||||
const bridge = new MacOSContactsBridge({ timeoutMs: 30_000 });
|
||||
if (await bridge.isAvailable()) {
|
||||
const contacts = await bridge.getAllContacts();
|
||||
console.log(`${contacts.length} contacts`);
|
||||
const family = await bridge.getGroupMembers("Family");
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `AppleScriptRunner` seam — tests run on any platform without macOS by passing a fake runner that returns fixture strings.
|
||||
- Free-function parser surface (`parseContacts`, `parseGroups`, `normalizePhone`) — no static-only class, tree-shakeable at function granularity.
|
||||
- Typed `MacOSContactsError` hierarchy with the `MACOS_CONTACTS.*` code registry (`INVALID_INPUT`, `CONTACTS_NOT_FOUND`, `CONTACTS_PERMISSION_DENIED`, `CONTACTS_ACCESS_FAILED`, `CONTACTS_PARSE_FAILED`, `CONTACTS_UNAVAILABLE`).
|
||||
- Six OTel counters (partitioned by `outcome`) and two histograms wired through `@suaveplan/opentelemetry`.
|
||||
- Zod input validation at every public boundary; injectable `Clock` for sync timestamps; no `Date.now()` / `new Date()` in the production hot path.
|
||||
- `sync()` and `isAvailable()` never throw — failures surface through structured result fields.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes/error-codes.md) — stable code registry + typed `MacOSContactsError`.
|
||||
- [`schemas`](./src/schemas/schemas.md) — Zod schemas + `parseOrThrow` boundary helper.
|
||||
- [`otel/instruments`](./src/otel/instruments.md) — OpenTelemetry counter + histogram bundle.
|
||||
- [`parser`](./src/parser/parser.md) — free-function parser for raw AppleScript output.
|
||||
- [`applescript`](./src/applescript/applescript.md) — AppleScript template builders.
|
||||
- [`executor`](./src/executor/executor.md) — `AppleScriptRunner` interface + default platform implementation.
|
||||
- [`bridge`](./src/bridge/bridge.md) — `MacOSContactsBridge` orchestrator.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# applescript
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suitable for handing to the platform `osascript` binary via the `AppleScriptRunner` seam. Templates are pure — no side effects, no IO — so consumers can preview or modify the script before invoking it (e.g. for logging, custom escaping, or wrapping in additional `tell` blocks).
|
||||
|
||||
## Features
|
||||
|
||||
- `getAllContactsScript()` — every person, full detail (id, names, organisation, birthday, notes, image flag, phones, emails, addresses).
|
||||
- `searchContactsScript(query)` — every person whose name contains the supplied query, full detail. Escapes embedded `"` characters before splicing the query into the script.
|
||||
- `getGroupsScript()` — every group with member counts.
|
||||
- `getGroupMembersScript(groupName)` — every person in a named group, full detail. Escapes embedded `"` characters before splicing the name into the script.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getAllContactsScript } from "@suaveplan/macos-contacts";
|
||||
|
||||
const script = getAllContactsScript();
|
||||
// Use with any AppleScriptRunner, including the bundled default:
|
||||
// const runner = createDefaultAppleScriptRunner({ timeoutMs: 30_000 });
|
||||
// const raw = await runner.run(script);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDefaultAppleScriptRunner,
|
||||
parseContacts,
|
||||
searchContactsScript,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
async function findByName(query: string) {
|
||||
const runner = createDefaultAppleScriptRunner({ timeoutMs: 10_000 });
|
||||
const script = searchContactsScript(query);
|
||||
const raw = await runner.run(script);
|
||||
return parseContacts(raw);
|
||||
}
|
||||
|
||||
// Embedded quotes are escaped automatically:
|
||||
const safe = searchContactsScript('John "Jack" Doe');
|
||||
// safe contains name contains "John \"Jack\" Doe" in valid AppleScript form.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getAllContactsScript(): string`
|
||||
|
||||
Returns a complete AppleScript that emits one `---CONTACT---`-delimited block per person. Output format matches the contract parsed by `parseContacts`.
|
||||
|
||||
### `searchContactsScript(query: string): string`
|
||||
|
||||
Same output format as `getAllContactsScript`, restricted to people whose `name contains` the supplied query. The query is splice-escaped so embedded `"` characters do not break the script.
|
||||
|
||||
### `getGroupsScript(): string`
|
||||
|
||||
Returns a complete AppleScript that emits one line per group in `id|||name|||memberCount` form, suitable for `parseGroups`.
|
||||
|
||||
### `getGroupMembersScript(groupName: string): string`
|
||||
|
||||
Returns a complete AppleScript that resolves `first group whose name is "<escaped groupName>"` and emits the contact loop over its members. Output format matches `parseContacts`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Templates are built from shared sub-strings (`CONTACT_PROPERTIES`, `CONTACT_DETAILS`, `contactLoop`) so the four public builders stay short and reviewable. The output schema is deliberately string-stable: any change to the field order or delimiters in this module must be matched by a change in `parser.ts`, and both must be covered by the bridge's end-to-end fixture tests.
|
||||
|
||||
Quote-escaping is the bare minimum needed to keep the splice safe — replace `"` with `\"`. AppleScript does not interpret backslashes inside string literals the way C does, but `osascript -e` accepts the doubled form, and macOS' AppleScript editor treats it identically. If a future code path needs richer escaping (e.g. for embedded `\\` or `\n`), extend the helpers here rather than open-coding in the bridge.
|
||||
|
||||
The bridge depends on these builders directly; consumers that want the raw scripts (for logging, dry-run preview, or custom transports) can import them from the package root or the `./applescript/applescript` subpath.
|
||||
115
docs/commerce/contacts/macos-contacts/src/bridge/bridge.md
Normal file
115
docs/commerce/contacts/macos-contacts/src/bridge/bridge.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# bridge
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building blocks — `AppleScriptRunner` (IO), the AppleScript template builders (script generation), the parser free functions (output → typed records), and the typed error registry — into a single object with `getAllContacts` / `searchContacts` / `getContactById` / `getGroups` / `getGroupMembers` / `sync` / `isAvailable`. Every public method validates its input via Zod, wraps the runner with typed `MacOSContactsError` mapping, and emits the corresponding OTel counter (with `outcome` attribute) and the shared `applescript_duration_ms` histogram.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `AppleScriptRunner` — tests run on any platform without macOS; production wires `createDefaultAppleScriptRunner`.
|
||||
- Injectable `Clock` (from `@suaveplan/types`) — `SyncResult.syncedAt` and the duration histograms use the injected clock; no `Date.now()` or `new Date()` in the hot path.
|
||||
- Zod-validated config — `timeoutMs` must be a positive integer; invalid config raises `MacOSContactsError(INVALID_INPUT)` at construction time.
|
||||
- Typed error mapping — runner rejections matching `/not authorized|permission denied|access denied|user denied/i` map to `CONTACTS_PERMISSION_DENIED`; everything else maps to `CONTACTS_ACCESS_FAILED`. Discriminants (`query`, `groupName`, `contactId`) flow onto the raised error so consumers can dispatch without parsing the message.
|
||||
- OTel emission on every public op — six counters partitioned by `outcome` (`success` | `permission_denied` | `access_failed`) and two histograms.
|
||||
- `sync()` never throws — failures land in `SyncResult.errors`.
|
||||
- `isAvailable()` never throws — runner exceptions are swallowed and reported as `false`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MacOSContactsBridge } from "@suaveplan/macos-contacts";
|
||||
|
||||
const bridge = new MacOSContactsBridge({ timeoutMs: 30_000 });
|
||||
if (await bridge.isAvailable()) {
|
||||
const contacts = await bridge.getAllContacts();
|
||||
console.log(`${contacts.length} contacts`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
MacOSContactsBridge,
|
||||
MacOSContactsError,
|
||||
type AppleScriptRunner,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
// Inject a fake runner for tests / non-macOS environments.
|
||||
const fakeRunner: AppleScriptRunner = {
|
||||
async run() {
|
||||
return "id|||A|||B||| ||| ||| |||false\nPHONES:\nEMAILS:\nADDRESSES:";
|
||||
},
|
||||
};
|
||||
|
||||
const bridge = new MacOSContactsBridge(
|
||||
{ timeoutMs: 5_000 },
|
||||
{ runner: fakeRunner, clock: () => 1_700_000_000_000 },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await bridge.sync();
|
||||
if (result.errors.length > 0) {
|
||||
for (const err of result.errors) {
|
||||
if (err.code === "MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED") {
|
||||
showPermissionPrompt();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof MacOSContactsError) {
|
||||
// sync itself never throws, but other methods may.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new MacOSContactsBridge(config?, deps?)`
|
||||
|
||||
- `config?.timeoutMs?: number` — per-invocation timeout in ms. Defaults to 30 000. Must be a positive integer when supplied.
|
||||
- `config?.batchSize?: number` — reserved for future batched paths. Defaults to 500.
|
||||
- `deps?.runner?: AppleScriptRunner` — injectable runner seam. Defaults to `createDefaultAppleScriptRunner({ timeoutMs })`.
|
||||
- `deps?.clock?: Clock` — injectable time source. Defaults to `() => Date.now()`.
|
||||
|
||||
### `bridge.isAvailable(): Promise<boolean>`
|
||||
|
||||
Probes the runner with a harmless `System Events` script. Returns `true` when stdout is non-empty, `false` otherwise. Never throws.
|
||||
|
||||
### `bridge.getAllContacts(): Promise<MacOSContact[]>`
|
||||
|
||||
Invokes `getAllContactsScript()`, parses stdout via `parseContacts`. Runner failures raise `MacOSContactsError`.
|
||||
|
||||
### `bridge.searchContacts(query): Promise<MacOSContact[]>`
|
||||
|
||||
Empty / whitespace `query` short-circuits to `[]`. Otherwise invokes `searchContactsScript(query)`. Failures attach the `query` discriminant.
|
||||
|
||||
### `bridge.getContactById(id): Promise<MacOSContact | null>`
|
||||
|
||||
Empty / whitespace `id` short-circuits to `null`. Otherwise calls `getAllContacts()` and returns the first match or `null`. Failures attach the `contactId` discriminant.
|
||||
|
||||
### `bridge.getGroups(): Promise<ContactGroup[]>`
|
||||
|
||||
Invokes `getGroupsScript()`, parses stdout via `parseGroups`.
|
||||
|
||||
### `bridge.getGroupMembers(groupName): Promise<MacOSContact[]>`
|
||||
|
||||
Empty / whitespace `groupName` short-circuits to `[]`. Failures attach the `groupName` discriminant.
|
||||
|
||||
### `bridge.sync(): Promise<SyncResult>`
|
||||
|
||||
Calls `getAllContacts()` and captures any failure in `SyncResult.errors`. `syncedAt` is built from the injected clock; the call also records to the `macos_contacts.sync_duration_ms` histogram.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bridge intentionally keeps the runner ignorant of `MacOSContactsError`. The runner contract is "run this AppleScript, resolve with stdout, reject with `Error` on any failure"; the bridge layers the typed error mapping on top so the test surface is small (one fake runner per test, no error-mapping logic to mock).
|
||||
|
||||
Error-mapping uses a single shared regex (`PERMISSION_PATTERN`) so the policy lives in one place. `classify(cause)` returns the `outcome` attribute for OTel; `toBridgeError(cause, discriminants)` constructs the typed error. Both call sites stay synchronous so the catch arms don't accidentally swallow rejections from instrumentation.
|
||||
|
||||
`isAvailable()` and `sync()` are the only methods that catch and swallow exceptions. Every other method propagates failures through the typed error so callers can dispatch on `error.code` without inspecting strings.
|
||||
|
||||
The histogram contract is "duration of the script invocation, in milliseconds". The bridge wraps the runner call with `runWithHistogram` so the value is recorded even if the runner rejects — observability survives even when the underlying call fails. `Math.max(0, …)` guards against clock skew producing negative samples.
|
||||
@@ -0,0 +1,89 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry and typed `MacOSContactsError` class for `@suaveplan/macos-contacts`. Every failure path the package exposes raises a `MacOSContactsError` carrying one of six registered codes (`INVALID_INPUT`, `CONTACTS_NOT_FOUND`, `CONTACTS_PERMISSION_DENIED`, `CONTACTS_ACCESS_FAILED`, `CONTACTS_PARSE_FAILED`, `CONTACTS_UNAVAILABLE`). Codes are namespaced strings (`"MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED"`) so consumers can dispatch programmatically without parsing messages, and the typed error narrows the `query` / `groupName` / `contactId` discriminants for diagnostics.
|
||||
|
||||
## Features
|
||||
|
||||
- `MACOS_CONTACTS_CODES` — `defineCodes("MACOS_CONTACTS", [...])` registry; the canonical source of truth for every code emitted by the package.
|
||||
- `MacOSContactsError extends SuaveplanError<MacOSContactsErrorCode>` — typed error class. `instanceof` narrows `code` to a literal-string union.
|
||||
- `MacOSContactsErrorCode` — union of every registered code.
|
||||
- `MacOSContactsErrorOptions` — options bag extending `SuaveplanErrorOptions` with `query`, `groupName`, `contactId` discriminants and standard `cause` chaining.
|
||||
- Replaces upstream's `HttpError(... code as unknown as HttpErrorCode)` cast: codes flow through the type system end-to-end, with no `unknown` escape hatches.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
MACOS_CONTACTS_CODES,
|
||||
MacOSContactsError,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
function ensurePermission(stderr: string) {
|
||||
if (/not authorized/i.test(stderr)) {
|
||||
throw new MacOSContactsError(
|
||||
MACOS_CONTACTS_CODES.CONTACTS_PERMISSION_DENIED,
|
||||
"macOS denied access to Contacts.app",
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
MACOS_CONTACTS_CODES,
|
||||
MacOSContactsError,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
try {
|
||||
await bridge.searchContacts(query);
|
||||
} catch (err) {
|
||||
if (err instanceof MacOSContactsError) {
|
||||
switch (err.code) {
|
||||
case MACOS_CONTACTS_CODES.CONTACTS_PERMISSION_DENIED:
|
||||
return openSystemSettingsDialog();
|
||||
case MACOS_CONTACTS_CODES.INVALID_INPUT:
|
||||
return showValidationToast(err.message);
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MACOS_CONTACTS_CODES`
|
||||
|
||||
A frozen registry produced by `defineCodes("MACOS_CONTACTS", [...] as const)`. Each entry maps a bare key (e.g. `CONTACTS_PERMISSION_DENIED`) to its namespaced string (`"MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED"`).
|
||||
|
||||
### `MacOSContactsErrorCode`
|
||||
|
||||
Union type `(typeof MACOS_CONTACTS_CODES)[keyof typeof MACOS_CONTACTS_CODES]`. Use to type-check `error.code` in catch blocks.
|
||||
|
||||
### `MacOSContactsError(code, message, options?)`
|
||||
|
||||
Constructor parameters:
|
||||
|
||||
- `code: MacOSContactsErrorCode` — required, the discriminant.
|
||||
- `message: string` — required, English diagnostic.
|
||||
- `options.query?: string` — attaches `error.query`.
|
||||
- `options.groupName?: string` — attaches `error.groupName`.
|
||||
- `options.contactId?: string` — attaches `error.contactId`.
|
||||
- `options.cause?: unknown` — standard ES `Error.cause`; preserved through serialization and span events.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The discriminants live as direct instance properties so consumers can do `if (err.query)` without an extra unwrap. Codes are validated at registry time by `defineCodes`, which freezes the result and enforces unique namespace tokens.
|
||||
|
||||
Adding a new code requires three concurrent edits: append the literal to the `MACOS_CONTACTS_CODES` array, raise it from at least one production call site, and add a test that round-trips an instance with the new code. The package's own `macos-contacts.spec-conformance.test.ts` enforces the "every code referenced" rule — removing a code to placate a gate is banned per `AGENTS.md` §2c.
|
||||
|
||||
Error-code strings are part of the public API. Renaming one is a breaking change. Add a new code instead and let consumers migrate explicitly.
|
||||
@@ -0,0 +1,81 @@
|
||||
# executor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `AppleScriptRunner` injectable seam that `MacOSContactsBridge` depends on, plus a default implementation backed by `node:child_process` against the platform `osascript` binary. The seam exists so tests run on any platform without macOS — inject a fake runner that returns fixture strings and the entire bridge can be exercised on Linux CI, Bun on WSL, browser-style runtimes that polyfill child_process, etc.
|
||||
|
||||
## Features
|
||||
|
||||
- `AppleScriptRunner` interface — single method `run(script, signal?)` returning the script's stdout as a string.
|
||||
- `createDefaultAppleScriptRunner({ timeoutMs, maxBufferBytes?, binary? })` — produces a runner that calls `execFile` (argv-based, no shell interpolation) on the configured binary (default `"osascript"`), applies a per-invocation timeout, optionally caps stdout, and threads `AbortSignal` through so callers can cancel an in-flight invocation.
|
||||
- `DefaultAppleScriptRunnerOptions` — public options bag for the default factory.
|
||||
- No mocking framework required by consumers — the seam is the interface; tests provide fakes by `implements`-ing it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDefaultAppleScriptRunner } from "@suaveplan/macos-contacts";
|
||||
|
||||
const runner = createDefaultAppleScriptRunner({ timeoutMs: 30_000 });
|
||||
const stdout = await runner.run('return "hello"');
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { AppleScriptRunner } from "@suaveplan/macos-contacts";
|
||||
import { MacOSContactsBridge } from "@suaveplan/macos-contacts";
|
||||
|
||||
// Test-only fake — no macOS / no shell required.
|
||||
const fakeRunner: AppleScriptRunner = {
|
||||
async run(script) {
|
||||
if (script.includes("every person")) {
|
||||
return "id1|||A|||B||| ||| ||| |||false\nPHONES:\nEMAILS:\nADDRESSES:";
|
||||
}
|
||||
throw new Error("Unexpected script");
|
||||
},
|
||||
};
|
||||
|
||||
const bridge = new MacOSContactsBridge({ timeoutMs: 1000 }, { runner: fakeRunner });
|
||||
const contacts = await bridge.getAllContacts();
|
||||
|
||||
// In production: pass an AbortSignal to cancel a slow invocation.
|
||||
const controller = new AbortController();
|
||||
const real = createDefaultAppleScriptRunner({ timeoutMs: 60_000 });
|
||||
const promise = real.run(largeScript, controller.signal);
|
||||
setTimeout(() => controller.abort(), 5_000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AppleScriptRunner`
|
||||
|
||||
```ts
|
||||
interface AppleScriptRunner {
|
||||
run(script: string, signal?: AbortSignal): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
Implementations SHALL resolve with stdout on success and reject with an `Error` on failure (non-zero exit, timeout, abort, missing binary). The bridge maps rejections through its typed error-mapping rule; runner implementations SHOULD NOT wrap their rejection in `MacOSContactsError` themselves — leave that to the bridge.
|
||||
|
||||
### `DefaultAppleScriptRunnerOptions`
|
||||
|
||||
- `timeoutMs: number` — required. Applied to every invocation.
|
||||
- `maxBufferBytes?: number` — optional. Defaults to 10 MiB.
|
||||
- `binary?: string` — optional. Defaults to `"osascript"`. Override only for test harnesses (e.g. `"/bin/false"` to assert the failure path).
|
||||
|
||||
### `createDefaultAppleScriptRunner(options): AppleScriptRunner`
|
||||
|
||||
Returns a runner that wraps `node:child_process` via the argv-based file-execution path. The argument vector is fixed at `[binary, "-e", script]` so the AppleScript payload is never subject to shell interpolation. Honours the supplied `AbortSignal`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The runner uses the argv-based file-execution path rather than the shell-string path, so the AppleScript payload is never passed through `/bin/sh` and is therefore not subject to injection from quotes or backslashes inside the script body. This means template builders only need to escape `"` for AppleScript itself, not for any intermediate shell layer.
|
||||
|
||||
`AbortSignal` is forwarded directly to the child via the options bag; an already-aborted signal causes the child to be killed immediately and the returned promise to reject. The bridge does not currently surface this through its public API, but downstream callers that compose the runner directly can rely on cancellation working.
|
||||
|
||||
The `binary` override exists for tests: pointing at `"/bin/false"` exercises the rejection path without requiring `osascript`, and pointing at `"/bin/echo"` exercises the resolution path on any POSIX runner. Production callers should leave it at the default.
|
||||
@@ -0,0 +1,96 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`. The accessor `getMacOSContactsInstruments()` lazily binds against the current global `MeterProvider`, caches the bundle, and returns the same object on every subsequent call. Tests call `resetMacOSContactsInstruments()` after installing a fresh `MeterProvider` to force a rebind; production callers never need to touch the reset path.
|
||||
|
||||
## Features
|
||||
|
||||
- Six counters partitioned by `outcome` (`success` | `permission_denied` | `access_failed`):
|
||||
- `macos_contacts.list_total` — `MacOSContactsBridge.getAllContacts`
|
||||
- `macos_contacts.search_total` — `searchContacts`
|
||||
- `macos_contacts.get_total` — `getContactById`
|
||||
- `macos_contacts.groups_list_total` — `getGroups`
|
||||
- `macos_contacts.group_members_total` — `getGroupMembers`
|
||||
- `macos_contacts.sync_total` — `sync`
|
||||
- Two histograms (unit: ms):
|
||||
- `macos_contacts.applescript_duration_ms` — wraps every runner invocation
|
||||
- `macos_contacts.sync_duration_ms` — wraps `sync` end-to-end
|
||||
- Lazy + cached — the bundle is built on first access against the global `MeterProvider`. Subsequent calls return the same reference (`===`-equal).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
getMacOSContactsInstruments,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
const instruments = getMacOSContactsInstruments();
|
||||
instruments.listTotal.add(1, { outcome: "success" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getMacOSContactsInstruments,
|
||||
resetMacOSContactsInstruments,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
// Wire a real in-memory MeterProvider for tests.
|
||||
const exporter = new InMemoryMetricExporter(0);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.disable();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
// Force the bundle to rebind against the new provider.
|
||||
resetMacOSContactsInstruments();
|
||||
const instruments = getMacOSContactsInstruments();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MacOSContactsInstruments`
|
||||
|
||||
```ts
|
||||
interface MacOSContactsInstruments {
|
||||
readonly listTotal: Counter;
|
||||
readonly searchTotal: Counter;
|
||||
readonly getTotal: Counter;
|
||||
readonly groupsListTotal: Counter;
|
||||
readonly groupMembersTotal: Counter;
|
||||
readonly syncTotal: Counter;
|
||||
readonly applescriptDurationMs: Histogram;
|
||||
readonly syncDurationMs: Histogram;
|
||||
}
|
||||
```
|
||||
|
||||
### `getMacOSContactsInstruments(): MacOSContactsInstruments`
|
||||
|
||||
Lazily builds and caches the bundle. Returns the same object reference on every subsequent call until `resetMacOSContactsInstruments()` is called.
|
||||
|
||||
### `resetMacOSContactsInstruments(): void`
|
||||
|
||||
Drops the cached bundle. The next `getMacOSContactsInstruments()` call rebuilds it against whatever global `MeterProvider` is in effect.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bundle is built against the global `MeterProvider` rather than a parameter so consumers do not need to thread a meter through every layer. Tests reset the cache after installing a new provider so each test sees a fresh instrument set bound to its own in-memory exporter.
|
||||
|
||||
Counters partition by `outcome` so dashboards can distinguish revoked-permission incidents from transient `osascript` failures. Histograms record duration in milliseconds (`unit: "ms"`); the bridge wraps the runner call so the sample is recorded even when the runner rejects — observability survives platform-level failures.
|
||||
|
||||
Instrument names use `snake_case` per OpenTelemetry semantic conventions. The `macos_contacts.` prefix mirrors the package name so multi-package dashboards can filter by namespace.
|
||||
71
docs/commerce/contacts/macos-contacts/src/parser/parser.md
Normal file
71
docs/commerce/contacts/macos-contacts/src/parser/parser.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure free-function parser for the raw text stream emitted by the AppleScript templates that drive `MacOSContactsBridge`. Upstream `@suaveplan/macos-contacts` shipped this surface as a static-only class with a `// biome-ignore lint/complexity/noStaticOnlyClass` suppression — Ships it as three plain functions (`parseContacts`, `parseGroups`, `normalizePhone`) so no suppression is needed and tree-shaking is finer-grained.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseContacts(raw)` — split on `---CONTACT---`, parse each block into a `MacOSContact`, skip malformed blocks silently.
|
||||
- `parseGroups(raw)` — line-oriented, splits each line on `|||`, skips malformed lines silently.
|
||||
- `normalizePhone(phone)` — strips non-digit characters except a leading `+`; defaults bare 10-digit numbers to the NANP (`+1`) prefix; returns `""` for empty / non-digit input.
|
||||
- No side effects, no module-level state, no `Math.random()` / `Date.now()` — every function is referentially transparent.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseContacts } from "@suaveplan/macos-contacts";
|
||||
|
||||
const raw = "abc|||John|||Doe||| ||| ||| |||false\nPHONES:\nEMAILS:\nADDRESSES:";
|
||||
const contacts = parseContacts(raw);
|
||||
// contacts[0].firstName === "John"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { normalizePhone, parseContacts } from "@suaveplan/macos-contacts";
|
||||
|
||||
async function importContactsFile(text: string) {
|
||||
const contacts = parseContacts(text);
|
||||
return contacts.map((c) => ({
|
||||
...c,
|
||||
phoneNumbers: c.phoneNumbers.map((p) => ({
|
||||
...p,
|
||||
normalised: normalizePhone(p.number),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseContacts(rawOutput: string): MacOSContact[]`
|
||||
|
||||
Splits `rawOutput` on the `---CONTACT---` delimiter, parses each block. A block has one header line (`id|||first|||last|||org|||birthday|||notes|||imageAvailable`) followed by zero or more section lines (`PHONES:`, `EMAILS:`, `ADDRESSES:`). Section items are delimited by `;;;` and fields within an item by `:`. Empty input returns `[]`; malformed blocks (header missing 7 fields, body missing entirely) are silently dropped.
|
||||
|
||||
### `parseGroups(rawOutput: string): ContactGroup[]`
|
||||
|
||||
Splits `rawOutput` on newlines, then each line on `|||`. Lines with fewer than 3 fields are dropped. `memberCount` falls back to `0` when the third field is not numeric.
|
||||
|
||||
### `normalizePhone(phone: string): string`
|
||||
|
||||
Trims, strips non-digit characters (except a leading `+`), and:
|
||||
|
||||
- returns `""` for empty / whitespace-only input,
|
||||
- returns `""` for input with zero digits after stripping,
|
||||
- preserves the `+` prefix when present,
|
||||
- prepends `+1` when the digit count is exactly 10 (NANP default),
|
||||
- otherwise prepends `+`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The parser is intentionally tolerant: AppleScript output can vary slightly between macOS versions, and the bridge would rather drop one malformed contact than reject the whole address book. The malformed-block path is exercised by the test `parseContacts skips malformed contact blocks`.
|
||||
|
||||
Switching from the static class to free functions is a public-API change relative to upstream — callers that wrote `ContactParser.parse(raw)` migrate to `parseContacts(raw)`. The new surface is exported both from the package root and from the `./parser/parser` subpath so consumers can tree-shake at function granularity.
|
||||
|
||||
The internal `parseContactBlock` / `parsePhones` / `parseEmails` / `parseAddresses` helpers are not exported. They are pure, total, and structured so the test suite covers every branch (empty section, malformed item, all-fields-populated).
|
||||
84
docs/commerce/contacts/macos-contacts/src/schemas/schemas.md
Normal file
84
docs/commerce/contacts/macos-contacts/src/schemas/schemas.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas guarding every public input boundary of `@suaveplan/macos-contacts`. Validation runs before any side effect; failures raise a typed `MacOSContactsError(INVALID_INPUT)` with the underlying `ZodError` chained as `cause` and the failing field paths embedded in the message. This pattern keeps consumers from having to import `zod` to dispatch on validation errors, and keeps the bridge's `try { runner.run(...) }` blocks focused on platform failures, not shape failures.
|
||||
|
||||
## Features
|
||||
|
||||
- `macOSContactsConfigSchema` — validates the optional `{ timeoutMs, batchSize }` bundle accepted by `MacOSContactsBridge`.
|
||||
- `searchQuerySchema` — non-empty trimmed string; used by `searchContacts`.
|
||||
- `groupNameSchema` — non-empty trimmed string; used by `getGroupMembers`.
|
||||
- `contactIdSchema` — non-empty trimmed string; used by `getContactById`.
|
||||
- `parseOrThrow(schema, input, label)` — generic helper. Returns the parsed value on success, throws `MacOSContactsError(INVALID_INPUT)` on failure.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
macOSContactsConfigSchema,
|
||||
parseOrThrow,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
const config = parseOrThrow(
|
||||
macOSContactsConfigSchema,
|
||||
{ timeoutMs: 30_000 },
|
||||
"myCaller",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import {
|
||||
MacOSContactsError,
|
||||
parseOrThrow,
|
||||
searchQuerySchema,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
const richQuerySchema = z.object({
|
||||
query: searchQuerySchema,
|
||||
limit: z.number().int().min(1).max(500),
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = parseOrThrow(richQuerySchema, input, "rich.search");
|
||||
await bridge.searchContacts(parsed.query);
|
||||
} catch (err) {
|
||||
if (err instanceof MacOSContactsError && err.code === "MACOS_CONTACTS.INVALID_INPUT") {
|
||||
return form.showValidation(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `macOSContactsConfigSchema`
|
||||
|
||||
`z.object({ timeoutMs: z.number().int().positive().optional(), batchSize: z.number().int().positive().optional() })`. Both fields are optional; defaults are filled in by `MacOSContactsBridge`.
|
||||
|
||||
### `searchQuerySchema` / `groupNameSchema` / `contactIdSchema`
|
||||
|
||||
`z.string().trim().min(1)`. Each schema rejects empty / whitespace-only input.
|
||||
|
||||
### `parseOrThrow<T>(schema: z.ZodType<T>, input: unknown, label: string): T`
|
||||
|
||||
Runs `schema.safeParse(input)`. On success returns the parsed value (with whatever transformations the schema applied, e.g. `.trim()`). On failure constructs a `MacOSContactsError` with:
|
||||
|
||||
- `code === "MACOS_CONTACTS.INVALID_INPUT"`,
|
||||
- `message === "<label> input failed validation: <field>: <issue>; <field>: <issue>"`,
|
||||
- `cause === <ZodError>`.
|
||||
|
||||
The `label` argument is the human-readable name of the call site (e.g. `"MacOSContactsBridge.constructor"`); it shows up first in the message so log readers can tell which boundary failed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `parseOrThrow` helper is intentionally generic over the schema's output type so callers do not lose Zod's type inference. The error message lists every failing path so consumers can render granular validation feedback without re-running the parser.
|
||||
|
||||
Schemas live in their own module rather than being co-located with the bridge so unit tests can exercise schema-level invariants (empty query rejection, positive `timeoutMs` requirement) without instantiating the bridge. The bridge itself calls `parseOrThrow` exclusively — it never reaches for `schema.parse` or `schema.safeParse` directly, which keeps the error-mapping policy in one place.
|
||||
@@ -0,0 +1,91 @@
|
||||
# macos-contacts.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Entity types for `@suaveplan/macos-contacts`. Shapes are preserved verbatim from upstream `../suaveplan/packages/universal/macos-contacts/src/types/macos-contacts.types.ts` so consumers migrating from main see no semantic drift on the data surface; only the wiring layer (errors, OTel, runner seam) was rewritten for suaveplan. Every field is `readonly` to make the bridge's return values immutable by default — the bridge composes new objects on each invocation, never mutates a cached record.
|
||||
|
||||
## Features
|
||||
|
||||
- `ContactPhone` — label + number pair.
|
||||
- `ContactEmail` — label + address pair.
|
||||
- `ContactAddress` — label plus optional structured fields (street / city / state / postalCode / country).
|
||||
- `MacOSContact` — the full contact record, including arrays of phones / emails / postal addresses.
|
||||
- `ContactGroup` — group id + name + member count.
|
||||
- `MacOSContactsConfig` — `timeoutMs` / `batchSize` knobs for the bridge.
|
||||
- `SyncError` — single error entry on a `SyncResult`.
|
||||
- `SyncResult` — contacts + timestamp + total + errors bundle returned by `bridge.sync()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { MacOSContact } from "@suaveplan/macos-contacts";
|
||||
|
||||
function fullName(contact: MacOSContact): string {
|
||||
return `${contact.firstName} ${contact.lastName}`.trim();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ContactGroup,
|
||||
MacOSContact,
|
||||
SyncResult,
|
||||
} from "@suaveplan/macos-contacts";
|
||||
|
||||
function summarise(result: SyncResult, groups: readonly ContactGroup[]) {
|
||||
return {
|
||||
contactCount: result.total,
|
||||
firstNames: result.contacts.map((c: MacOSContact) => c.firstName),
|
||||
groupNames: groups.map((g) => g.name),
|
||||
syncedAt: result.syncedAt.toISOString(),
|
||||
errorCodes: result.errors.map((e) => e.code),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ContactPhone`
|
||||
|
||||
`{ readonly label: string; readonly number: string }`. `label` is the AppleScript-reported label string (`"mobile"`, `"home"`, `"work"`, …). `number` is verbatim — call `normalizePhone` from the `parser` module to convert to E.164 form.
|
||||
|
||||
### `ContactEmail`
|
||||
|
||||
`{ readonly label: string; readonly address: string }`.
|
||||
|
||||
### `ContactAddress`
|
||||
|
||||
`{ readonly label: string; readonly street?: string; readonly city?: string; readonly state?: string; readonly postalCode?: string; readonly country?: string }`. Every structured field is optional; the parser omits whichever fields were blank in the AppleScript output.
|
||||
|
||||
### `MacOSContact`
|
||||
|
||||
The full contact record:
|
||||
|
||||
- `id` — opaque AppleScript identifier (stable across queries on the same machine).
|
||||
- `firstName`, `lastName` — raw strings; may be empty.
|
||||
- `organization`, `birthday`, `notes` — optional; absent when blank.
|
||||
- `phoneNumbers`, `emailAddresses` — readonly arrays (may be empty).
|
||||
- `postalAddresses` — optional readonly array; absent when the contact has no addresses.
|
||||
- `imageAvailable` — boolean computed from `image of person is not missing value`.
|
||||
|
||||
### `ContactGroup`
|
||||
|
||||
`{ readonly id: string; readonly name: string; readonly memberCount: number }`.
|
||||
|
||||
### `MacOSContactsConfig`
|
||||
|
||||
`{ readonly timeoutMs?: number; readonly batchSize?: number }`. Both fields are optional; bridge defaults are 30 000 ms and 500 entries respectively.
|
||||
|
||||
### `SyncError` / `SyncResult`
|
||||
|
||||
`SyncError` carries `code` (one of the `MACOS_CONTACTS.*` strings), `message`, and an optional `contactId`. `SyncResult` carries `contacts` (readonly array), `syncedAt` (`Date` built from the injected `Clock`), `total` (mirrors `contacts.length`), and `errors` (readonly array). `bridge.sync()` always returns a `SyncResult` — failures land in `errors`, not as thrown exceptions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The types are intentionally `readonly` on every field. This signals "the bridge owns these values and may rebuild them across invocations". Consumers who need to mutate (e.g. to add a tag) clone via spread first — the spread is cheap because the underlying values are primitives or small readonly arrays.
|
||||
|
||||
`MacOSContact.id` is opaque — do not parse it for structural information. Apple changes the format between macOS versions; treating it as a string key (for `Map`, `Set`, URL paths) is safe.
|
||||
|
||||
The `Date` carried in `SyncResult.syncedAt` is constructed via `new Date(clock())` so it reflects the injected `Clock` for deterministic tests. Production callers pass `Date.now()` (the default) and receive a real wall-clock timestamp.
|
||||
Reference in New Issue
Block a user