Initial Commit
This commit is contained in:
61
packages/commerce/contacts/macos-contacts/README.md
Normal file
61
packages/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.
|
||||
|
||||
75
packages/commerce/contacts/macos-contacts/package.json
Normal file
75
packages/commerce/contacts/macos-contacts/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@suaveplan/macos-contacts",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": { "types": "./dist/otel/instruments.d.ts", "default": "./dist/otel/instruments.js" }
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": { "types": "./dist/schemas/schemas.d.ts", "default": "./dist/schemas/schemas.js" }
|
||||
},
|
||||
"./parser/parser": {
|
||||
"import": { "types": "./dist/parser/parser.d.ts", "default": "./dist/parser/parser.js" }
|
||||
},
|
||||
"./applescript/applescript": {
|
||||
"import": { "types": "./dist/applescript/applescript.d.ts", "default": "./dist/applescript/applescript.js" }
|
||||
},
|
||||
"./executor/executor": {
|
||||
"import": { "types": "./dist/executor/executor.d.ts", "default": "./dist/executor/executor.js" }
|
||||
},
|
||||
"./bridge/bridge": {
|
||||
"import": { "types": "./dist/bridge/bridge.d.ts", "default": "./dist/bridge/bridge.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "catalog:observability",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getAllContactsScript,
|
||||
getGroupMembersScript,
|
||||
getGroupsScript,
|
||||
searchContactsScript,
|
||||
} from "./applescript.js";
|
||||
|
||||
describe("applescript", () => {
|
||||
it("getAllContactsScript emits the every-person loop", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = getAllContactsScript();
|
||||
expect(s).toContain('tell application "Contacts"');
|
||||
expect(s).toContain("every person");
|
||||
expect(s).toContain("---CONTACT---");
|
||||
});
|
||||
|
||||
it("searchContactsScript embeds the query in the matcher", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = searchContactsScript("Alice");
|
||||
expect(s).toContain('name contains "Alice"');
|
||||
});
|
||||
|
||||
it("searchContactsScript escapes double quotes in the query", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = searchContactsScript('John "Jack"');
|
||||
expect(s).toContain('\\"Jack\\"');
|
||||
expect(s).not.toContain('"Jack"');
|
||||
});
|
||||
|
||||
it("getGroupsScript emits the every-group loop", { timeout: 5000 }, () => {
|
||||
const s = getGroupsScript();
|
||||
expect(s).toContain("every group");
|
||||
expect(s).toContain("count of people of g");
|
||||
});
|
||||
|
||||
it("getGroupMembersScript references the named group", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = getGroupMembersScript("Family");
|
||||
expect(s).toContain('"Family"');
|
||||
expect(s).toContain("people of targetGroup");
|
||||
});
|
||||
|
||||
it("getGroupMembersScript escapes double quotes in the group name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = getGroupMembersScript('"Family"');
|
||||
expect(s).toContain('\\"Family\\"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* AppleScript template builders for querying macOS Contacts.app. Each
|
||||
* function returns a complete script suitable for passing to the platform
|
||||
* `osascript` binary via the {@link AppleScriptRunner} seam.
|
||||
*/
|
||||
|
||||
const CONTACT_PROPERTIES = [
|
||||
" set cId to id of p",
|
||||
" set cFirst to first name of p",
|
||||
" set cLast to last name of p",
|
||||
" set cOrg to organization of p",
|
||||
" set cBday to birth date of p",
|
||||
" set cNotes to note of p",
|
||||
" set cImg to image of p is not missing value",
|
||||
' set output to output & cId & "|||" & cFirst & "|||" & cLast & "|||" & cOrg & "|||" & cBday & "|||" & cNotes & "|||" & cImg',
|
||||
" set output to output & return",
|
||||
].join("\n");
|
||||
|
||||
const CONTACT_DETAILS = [
|
||||
' set phoneStr to "PHONES:"',
|
||||
" repeat with ph in phones of p",
|
||||
' set phoneStr to phoneStr & label of ph & ":" & value of ph & ";;;"',
|
||||
" end repeat",
|
||||
" set output to output & phoneStr & return",
|
||||
' set emailStr to "EMAILS:"',
|
||||
" repeat with em in emails of p",
|
||||
' set emailStr to emailStr & label of em & ":" & value of em & ";;;"',
|
||||
" end repeat",
|
||||
" set output to output & emailStr & return",
|
||||
' set addrStr to "ADDRESSES:"',
|
||||
" repeat with ad in addresses of p",
|
||||
' set addrStr to addrStr & label of ad & ":" & street of ad & ":" & city of ad & ":" & state of ad & ":" & zip of ad & ":" & country of ad & ";;;"',
|
||||
" end repeat",
|
||||
" set output to output & addrStr",
|
||||
' set output to output & "---CONTACT---"',
|
||||
].join("\n");
|
||||
|
||||
function contactLoop(source: string): string {
|
||||
return [
|
||||
' set output to ""',
|
||||
` repeat with p in ${source}`,
|
||||
CONTACT_PROPERTIES,
|
||||
CONTACT_DETAILS,
|
||||
" end repeat",
|
||||
" return output",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** AppleScript to fetch every contact with full property details. */
|
||||
export function getAllContactsScript(): string {
|
||||
return [
|
||||
'tell application "Contacts"',
|
||||
contactLoop("every person"),
|
||||
"end tell",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** AppleScript to search contacts whose name matches the supplied query. */
|
||||
export function searchContactsScript(query: string): string {
|
||||
const escaped = query.replace(/"/g, '\\"');
|
||||
return [
|
||||
'tell application "Contacts"',
|
||||
` set matches to every person whose name contains "${escaped}"`,
|
||||
contactLoop("matches"),
|
||||
"end tell",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** AppleScript to fetch every contact group with member counts. */
|
||||
export function getGroupsScript(): string {
|
||||
return [
|
||||
'tell application "Contacts"',
|
||||
' set output to ""',
|
||||
" repeat with g in every group",
|
||||
" set gId to id of g",
|
||||
" set gName to name of g",
|
||||
" set gCount to count of people of g",
|
||||
' set output to output & gId & "|||" & gName & "|||" & gCount & return',
|
||||
" end repeat",
|
||||
" return output",
|
||||
"end tell",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** AppleScript to fetch the members of a specific group by name. */
|
||||
export function getGroupMembersScript(groupName: string): string {
|
||||
const escaped = groupName.replace(/"/g, '\\"');
|
||||
return [
|
||||
'tell application "Contacts"',
|
||||
` set targetGroup to first group whose name is "${escaped}"`,
|
||||
contactLoop("people of targetGroup"),
|
||||
"end tell",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getAllContactsScript,
|
||||
getGroupMembersScript,
|
||||
getGroupsScript,
|
||||
searchContactsScript,
|
||||
} from "./applescript.js";
|
||||
115
packages/commerce/contacts/macos-contacts/src/bridge/bridge.md
Normal file
115
packages/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,476 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MacOSContactsError } from "../error-codes/error-codes.js";
|
||||
import type { AppleScriptRunner } from "../executor/executor.js";
|
||||
import { MacOSContactsBridge } from "./bridge.js";
|
||||
|
||||
function fixtureContact(id: string, first: string, last: string): string {
|
||||
return [
|
||||
`${id}|||${first}|||${last}|||Org||| ||| |||false`,
|
||||
`PHONES:mobile:+1555${id.padStart(7, "0")};;;`,
|
||||
`EMAILS:work:${first.toLowerCase()}@test.com;;;`,
|
||||
"ADDRESSES:",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const singleFixture = fixtureContact("c1", "Alice", "Smith");
|
||||
const multiFixture = [
|
||||
fixtureContact("c1", "Alice", "Smith"),
|
||||
"---CONTACT---",
|
||||
fixtureContact("c2", "Bob", "Jones"),
|
||||
].join("\n");
|
||||
const groupsFixture = ["g1|||Family|||3", "g2|||Coworkers|||8"].join("\n");
|
||||
|
||||
function recordingRunner(behaviour: Record<string, string | Error>): {
|
||||
runner: AppleScriptRunner;
|
||||
calls: string[];
|
||||
} {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
runner: {
|
||||
async run(script: string): Promise<string> {
|
||||
calls.push(script);
|
||||
for (const [key, value] of Object.entries(behaviour)) {
|
||||
if (script.includes(key)) {
|
||||
if (value instanceof Error) throw value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected script: ${script}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function alwaysFailingRunner(message: string): AppleScriptRunner {
|
||||
return {
|
||||
async run(): Promise<string> {
|
||||
throw new Error(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("MacOSContactsBridge", () => {
|
||||
it("constructor defaults to the platform runner", { timeout: 5000 }, () => {
|
||||
const bridge = new MacOSContactsBridge({ timeoutMs: 5000 });
|
||||
expect(bridge).toBeInstanceOf(MacOSContactsBridge);
|
||||
});
|
||||
|
||||
it("constructor accepts an injected runner", { timeout: 5000 }, () => {
|
||||
const { runner } = recordingRunner({});
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(bridge).toBeInstanceOf(MacOSContactsBridge);
|
||||
});
|
||||
|
||||
it("constructor accepts no arguments", { timeout: 5000 }, () => {
|
||||
const bridge = new MacOSContactsBridge();
|
||||
expect(bridge).toBeInstanceOf(MacOSContactsBridge);
|
||||
});
|
||||
|
||||
it("sync uses Date.now() as the default clock", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "every person": multiFixture });
|
||||
const before = Date.now();
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const result = await bridge.sync();
|
||||
const after = Date.now();
|
||||
expect(result.syncedAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(result.syncedAt.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("constructor rejects invalid timeoutMs", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
new MacOSContactsBridge({ timeoutMs: -1 });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(MacOSContactsError);
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.INVALID_INPUT"
|
||||
);
|
||||
});
|
||||
|
||||
it("getAllContacts parses contacts from the runner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "every person": multiFixture });
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const contacts = await bridge.getAllContacts();
|
||||
expect(contacts).toHaveLength(2);
|
||||
expect(contacts[0]?.firstName).toBe("Alice");
|
||||
});
|
||||
|
||||
it("getAllContacts returns empty array when runner returns empty stdout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "every person": "" });
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(await bridge.getAllContacts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("getAllContacts maps permission-denial errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("not authorized");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getAllContacts();
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(MacOSContactsError);
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED"
|
||||
);
|
||||
});
|
||||
|
||||
it("getAllContacts maps generic errors to access-failed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("boom");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getAllContacts();
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_ACCESS_FAILED"
|
||||
);
|
||||
});
|
||||
|
||||
it("getAllContacts handles non-Error rejections from the runner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner: AppleScriptRunner = {
|
||||
async run(): Promise<string> {
|
||||
const failure: unknown = "literal";
|
||||
throw failure;
|
||||
},
|
||||
};
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getAllContacts();
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_ACCESS_FAILED"
|
||||
);
|
||||
expect((caught as MacOSContactsError).cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("searchContacts parses contacts on a non-empty query", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({
|
||||
"name contains": singleFixture,
|
||||
});
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const contacts = await bridge.searchContacts("Alice");
|
||||
expect(contacts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("searchContacts short-circuits on empty query", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner, calls } = recordingRunner({});
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(await bridge.searchContacts("")).toEqual([]);
|
||||
expect(await bridge.searchContacts(" ")).toEqual([]);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("searchContacts attaches the query discriminant on failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("boom");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.searchContacts("Alice");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).query).toBe("Alice");
|
||||
});
|
||||
|
||||
it("getContactById returns the matching contact", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "every person": multiFixture });
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const c = await bridge.getContactById("c2");
|
||||
expect(c?.firstName).toBe("Bob");
|
||||
});
|
||||
|
||||
it("getContactById returns null when no contact matches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "every person": singleFixture });
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(await bridge.getContactById("not-real")).toBeNull();
|
||||
});
|
||||
|
||||
it("getContactById short-circuits on empty id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner, calls } = recordingRunner({});
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(await bridge.getContactById("")).toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("getContactById attaches the contactId discriminant on access failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("boom");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getContactById("c1");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).contactId).toBe("c1");
|
||||
});
|
||||
|
||||
it("getContactById records permission_denied outcome on permission failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("not authorized");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getContactById("c1");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED"
|
||||
);
|
||||
expect((caught as MacOSContactsError).contactId).toBe("c1");
|
||||
});
|
||||
|
||||
it("getContactById rewraps MacOSContactsError with the contactId discriminant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner: AppleScriptRunner = {
|
||||
async run(): Promise<string> {
|
||||
throw new MacOSContactsError(
|
||||
"MACOS_CONTACTS.CONTACTS_ACCESS_FAILED",
|
||||
"inner"
|
||||
);
|
||||
},
|
||||
};
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getContactById("c1");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).contactId).toBe("c1");
|
||||
});
|
||||
|
||||
it("getGroups parses groups from the runner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "every group": groupsFixture });
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const groups = await bridge.getGroups();
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[1]?.memberCount).toBe(8);
|
||||
});
|
||||
|
||||
it("getGroups maps runner failures to access-failed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("boom");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getGroups();
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_ACCESS_FAILED"
|
||||
);
|
||||
});
|
||||
|
||||
it("getGroupMembers parses the named group", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({
|
||||
"people of targetGroup": singleFixture,
|
||||
});
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const members = await bridge.getGroupMembers("Family");
|
||||
expect(members).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getGroupMembers short-circuits on empty group name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner, calls } = recordingRunner({});
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(await bridge.getGroupMembers("")).toEqual([]);
|
||||
expect(await bridge.getGroupMembers(" ")).toEqual([]);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("getGroupMembers attaches the groupName discriminant on failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("boom");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getGroupMembers("Family");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).groupName).toBe("Family");
|
||||
});
|
||||
|
||||
it("sync returns the contacts on success", { timeout: 5000 }, async () => {
|
||||
const { runner } = recordingRunner({ "every person": multiFixture });
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const result = await bridge.sync();
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("sync captures errors instead of throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("boom — generic failure");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const result = await bridge.sync();
|
||||
expect(result.contacts).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]?.code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_ACCESS_FAILED"
|
||||
);
|
||||
expect(result.errors[0]?.message).toContain("boom");
|
||||
});
|
||||
|
||||
it("sync records the permission-denied code on permission failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("not authorized");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const result = await bridge.sync();
|
||||
expect(result.errors[0]?.code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED"
|
||||
);
|
||||
});
|
||||
|
||||
it("sync surfaces a generic message for non-Error rejections", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner: AppleScriptRunner = {
|
||||
async run(): Promise<string> {
|
||||
const failure: unknown = "literal";
|
||||
throw failure;
|
||||
},
|
||||
};
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
const result = await bridge.sync();
|
||||
expect(result.errors[0]?.message).toContain(
|
||||
"Failed to invoke Contacts.app"
|
||||
);
|
||||
});
|
||||
|
||||
it("sync uses the injected clock for syncedAt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "every person": multiFixture });
|
||||
const bridge = new MacOSContactsBridge(
|
||||
{},
|
||||
{ runner, clock: () => 1_700_000_000_000 }
|
||||
);
|
||||
const result = await bridge.sync();
|
||||
expect(result.syncedAt.getTime()).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("getAllContacts maps not-found patterns to CONTACTS_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("can't get person id 123");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getAllContacts();
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_NOT_FOUND"
|
||||
);
|
||||
});
|
||||
|
||||
it("getAllContacts maps syntax-error output to CONTACTS_PARSE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("syntax error in script");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getAllContacts();
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_PARSE_FAILED"
|
||||
);
|
||||
});
|
||||
|
||||
it("getAllContacts maps missing-binary failures to CONTACTS_UNAVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("ENOENT no such file or directory");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bridge.getAllContacts();
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).code).toBe(
|
||||
"MACOS_CONTACTS.CONTACTS_UNAVAILABLE"
|
||||
);
|
||||
});
|
||||
|
||||
it("isAvailable returns true when the runner resolves", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "System Events": "Finder" });
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(await bridge.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("isAvailable returns false when the runner resolves with empty stdout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = recordingRunner({ "System Events": "" });
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(await bridge.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("isAvailable returns false when the runner rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = alwaysFailingRunner("command not found");
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
expect(await bridge.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
289
packages/commerce/contacts/macos-contacts/src/bridge/bridge.ts
Normal file
289
packages/commerce/contacts/macos-contacts/src/bridge/bridge.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* `MacOSContactsBridge` — high-level bridge to macOS Contacts.app.
|
||||
*
|
||||
* Uses the injectable {@link AppleScriptRunner} seam so tests run on any
|
||||
* platform without macOS. 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.
|
||||
*/
|
||||
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
getAllContactsScript,
|
||||
getGroupMembersScript,
|
||||
getGroupsScript,
|
||||
searchContactsScript,
|
||||
} from "../applescript/applescript.js";
|
||||
import {
|
||||
MACOS_CONTACTS_CODES,
|
||||
MacOSContactsError,
|
||||
type MacOSContactsErrorCode,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type AppleScriptRunner,
|
||||
createDefaultAppleScriptRunner,
|
||||
} from "../executor/executor.js";
|
||||
import {
|
||||
getMacOSContactsInstruments,
|
||||
type MacOSContactsInstruments,
|
||||
} from "../otel/instruments.js";
|
||||
import { parseContacts, parseGroups } from "../parser/parser.js";
|
||||
import {
|
||||
contactIdSchema,
|
||||
groupNameSchema,
|
||||
macOSContactsConfigSchema,
|
||||
parseOrThrow,
|
||||
searchQuerySchema,
|
||||
} from "../schemas/schemas.js";
|
||||
import type {
|
||||
ContactGroup,
|
||||
MacOSContact,
|
||||
MacOSContactsConfig,
|
||||
SyncError,
|
||||
SyncResult,
|
||||
} from "../types/macos-contacts.types.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_BATCH_SIZE = 500;
|
||||
const PERMISSION_PATTERN =
|
||||
/not authorized|permission denied|access denied|user denied/i;
|
||||
|
||||
/** Optional second-arg dependency bundle for {@link MacOSContactsBridge}. */
|
||||
export interface MacOSContactsBridgeDeps {
|
||||
readonly runner?: AppleScriptRunner;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
type Outcome = "success" | "permission_denied" | "access_failed";
|
||||
|
||||
function classify(cause: unknown): Outcome {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return PERMISSION_PATTERN.test(message)
|
||||
? "permission_denied"
|
||||
: "access_failed";
|
||||
}
|
||||
|
||||
const NOT_FOUND_PATTERN =
|
||||
/can.?t get|doesn.?t exist|no such|not found|missing value/i;
|
||||
const PARSE_PATTERN = /syntax error|expected end|unexpected token/i;
|
||||
const UNAVAILABLE_PATTERN =
|
||||
/command not found|enoent|no such file or directory|not executable/i;
|
||||
|
||||
/** Classify a runner failure into the appropriate {@link MacOSContactsErrorCode}. */
|
||||
function classifyCode(message: string): MacOSContactsErrorCode {
|
||||
if (PERMISSION_PATTERN.test(message))
|
||||
return MACOS_CONTACTS_CODES.CONTACTS_PERMISSION_DENIED;
|
||||
if (UNAVAILABLE_PATTERN.test(message))
|
||||
return MACOS_CONTACTS_CODES.CONTACTS_UNAVAILABLE;
|
||||
if (PARSE_PATTERN.test(message))
|
||||
return MACOS_CONTACTS_CODES.CONTACTS_PARSE_FAILED;
|
||||
if (NOT_FOUND_PATTERN.test(message))
|
||||
return MACOS_CONTACTS_CODES.CONTACTS_NOT_FOUND;
|
||||
return MACOS_CONTACTS_CODES.CONTACTS_ACCESS_FAILED;
|
||||
}
|
||||
|
||||
function toBridgeError(
|
||||
cause: unknown,
|
||||
discriminants: {
|
||||
query?: string;
|
||||
groupName?: string;
|
||||
contactId?: string;
|
||||
} = {}
|
||||
): MacOSContactsError {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
const code = classifyCode(message);
|
||||
const prefix =
|
||||
code === MACOS_CONTACTS_CODES.CONTACTS_PERMISSION_DENIED
|
||||
? "macOS denied access to Contacts.app: "
|
||||
: code === MACOS_CONTACTS_CODES.CONTACTS_NOT_FOUND
|
||||
? "Contacts.app could not find the requested resource: "
|
||||
: code === MACOS_CONTACTS_CODES.CONTACTS_PARSE_FAILED
|
||||
? "Failed to parse Contacts.app output: "
|
||||
: code === MACOS_CONTACTS_CODES.CONTACTS_UNAVAILABLE
|
||||
? "macOS Contacts bridge is unavailable on this platform: "
|
||||
: "Failed to invoke Contacts.app via osascript: ";
|
||||
return new MacOSContactsError(code, prefix + message, {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
...discriminants,
|
||||
});
|
||||
}
|
||||
|
||||
export class MacOSContactsBridge {
|
||||
private readonly runner: AppleScriptRunner;
|
||||
private readonly clock: Clock;
|
||||
private readonly config: Required<MacOSContactsConfig>;
|
||||
private readonly instruments: MacOSContactsInstruments;
|
||||
|
||||
public constructor(
|
||||
config?: MacOSContactsConfig,
|
||||
deps?: MacOSContactsBridgeDeps
|
||||
) {
|
||||
const parsed = parseOrThrow(
|
||||
macOSContactsConfigSchema,
|
||||
config ?? {},
|
||||
"MacOSContactsBridge.constructor"
|
||||
);
|
||||
this.config = {
|
||||
timeoutMs: parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
batchSize: parsed.batchSize ?? DEFAULT_BATCH_SIZE,
|
||||
};
|
||||
this.runner =
|
||||
deps?.runner ??
|
||||
createDefaultAppleScriptRunner({
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
});
|
||||
this.clock = deps?.clock ?? (() => Date.now());
|
||||
this.instruments = getMacOSContactsInstruments();
|
||||
}
|
||||
|
||||
/** Check if the current platform supports macOS Contacts via osascript. */
|
||||
public async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.runWithHistogram(
|
||||
'tell application "System Events" to return name of current application'
|
||||
);
|
||||
return result.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Retrieve every contact from macOS Contacts.app. */
|
||||
public async getAllContacts(): Promise<MacOSContact[]> {
|
||||
try {
|
||||
const raw = await this.runWithHistogram(getAllContactsScript());
|
||||
this.instruments.listTotal.add(1, { outcome: "success" });
|
||||
return parseContacts(raw);
|
||||
} catch (err) {
|
||||
this.instruments.listTotal.add(1, { outcome: classify(err) });
|
||||
throw toBridgeError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Search contacts whose name matches the supplied query. */
|
||||
public async searchContacts(query: string): Promise<MacOSContact[]> {
|
||||
if (!query || query.trim().length === 0) {
|
||||
this.instruments.searchTotal.add(1, { outcome: "success" });
|
||||
return [];
|
||||
}
|
||||
parseOrThrow(searchQuerySchema, query, "searchContacts");
|
||||
try {
|
||||
const raw = await this.runWithHistogram(
|
||||
searchContactsScript(query)
|
||||
);
|
||||
this.instruments.searchTotal.add(1, { outcome: "success" });
|
||||
return parseContacts(raw);
|
||||
} catch (err) {
|
||||
this.instruments.searchTotal.add(1, { outcome: classify(err) });
|
||||
throw toBridgeError(err, { query });
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a single contact by its unique identifier. */
|
||||
public async getContactById(id: string): Promise<MacOSContact | null> {
|
||||
if (!id || id.trim().length === 0) {
|
||||
this.instruments.getTotal.add(1, { outcome: "success" });
|
||||
return null;
|
||||
}
|
||||
parseOrThrow(contactIdSchema, id, "getContactById");
|
||||
try {
|
||||
const all = await this.getAllContacts();
|
||||
this.instruments.getTotal.add(1, { outcome: "success" });
|
||||
return all.find((c) => c.id === id) ?? null;
|
||||
} catch (err) {
|
||||
// `getAllContacts` always raises a `MacOSContactsError` (Error
|
||||
// subclass). Re-wrap to attach the `contactId` discriminant.
|
||||
const wrapped = err as MacOSContactsError;
|
||||
this.instruments.getTotal.add(1, {
|
||||
outcome:
|
||||
wrapped.code ===
|
||||
MACOS_CONTACTS_CODES.CONTACTS_PERMISSION_DENIED
|
||||
? "permission_denied"
|
||||
: "access_failed",
|
||||
});
|
||||
throw new MacOSContactsError(wrapped.code, wrapped.message, {
|
||||
cause: wrapped.cause,
|
||||
contactId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Retrieve every contact group with member counts. */
|
||||
public async getGroups(): Promise<ContactGroup[]> {
|
||||
try {
|
||||
const raw = await this.runWithHistogram(getGroupsScript());
|
||||
this.instruments.groupsListTotal.add(1, { outcome: "success" });
|
||||
return parseGroups(raw);
|
||||
} catch (err) {
|
||||
this.instruments.groupsListTotal.add(1, { outcome: classify(err) });
|
||||
throw toBridgeError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Retrieve members of a specific group by name. */
|
||||
public async getGroupMembers(groupName: string): Promise<MacOSContact[]> {
|
||||
if (!groupName || groupName.trim().length === 0) {
|
||||
this.instruments.groupMembersTotal.add(1, { outcome: "success" });
|
||||
return [];
|
||||
}
|
||||
parseOrThrow(groupNameSchema, groupName, "getGroupMembers");
|
||||
try {
|
||||
const raw = await this.runWithHistogram(
|
||||
getGroupMembersScript(groupName)
|
||||
);
|
||||
this.instruments.groupMembersTotal.add(1, { outcome: "success" });
|
||||
return parseContacts(raw);
|
||||
} catch (err) {
|
||||
this.instruments.groupMembersTotal.add(1, {
|
||||
outcome: classify(err),
|
||||
});
|
||||
throw toBridgeError(err, { groupName });
|
||||
}
|
||||
}
|
||||
|
||||
/** Full sync of every contact; never throws. */
|
||||
public async sync(): Promise<SyncResult> {
|
||||
const start = this.clock();
|
||||
const errors: SyncError[] = [];
|
||||
let contacts: MacOSContact[] = [];
|
||||
try {
|
||||
contacts = await this.getAllContacts();
|
||||
this.instruments.syncTotal.add(1, { outcome: "success" });
|
||||
} catch (err) {
|
||||
// `getAllContacts` always raises a `MacOSContactsError` (Error
|
||||
// subclass), so `err.code` is guaranteed.
|
||||
const wrapped = err as MacOSContactsError;
|
||||
this.instruments.syncTotal.add(1, {
|
||||
outcome:
|
||||
wrapped.code ===
|
||||
MACOS_CONTACTS_CODES.CONTACTS_PERMISSION_DENIED
|
||||
? "permission_denied"
|
||||
: "access_failed",
|
||||
});
|
||||
errors.push({
|
||||
message: wrapped.message,
|
||||
code: wrapped.code,
|
||||
});
|
||||
}
|
||||
const end = this.clock();
|
||||
this.instruments.syncDurationMs.record(Math.max(0, end - start));
|
||||
return {
|
||||
contacts,
|
||||
syncedAt: new Date(start),
|
||||
total: contacts.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
private async runWithHistogram(script: string): Promise<string> {
|
||||
const start = this.clock();
|
||||
try {
|
||||
return await this.runner.run(script);
|
||||
} finally {
|
||||
this.instruments.applescriptDurationMs.record(
|
||||
Math.max(0, this.clock() - start)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { MacOSContactsBridgeDeps } from "./bridge.js";
|
||||
export { MacOSContactsBridge } from "./bridge.js";
|
||||
@@ -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,47 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
MACOS_CONTACTS_CODES,
|
||||
MacOSContactsError,
|
||||
type MacOSContactsErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("every registered code constructs a MacOSContactsError with namespaced code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codes = Object.values(
|
||||
MACOS_CONTACTS_CODES
|
||||
) as MacOSContactsErrorCode[];
|
||||
expect(codes.length).toBe(6);
|
||||
for (const code of codes) {
|
||||
const err = new MacOSContactsError(code, "msg");
|
||||
expect(err).toBeInstanceOf(MacOSContactsError);
|
||||
expect(err.code).toBe(code);
|
||||
expect(code.startsWith("MACOS_CONTACTS.")).toBe(true);
|
||||
expect(err.message).toBe("msg");
|
||||
}
|
||||
});
|
||||
|
||||
it("every code exposes its discriminants on the instance", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new MacOSContactsError(
|
||||
MACOS_CONTACTS_CODES.CONTACTS_NOT_FOUND,
|
||||
"x",
|
||||
{ query: "q", groupName: "g", contactId: "c" }
|
||||
);
|
||||
expect(err.query).toBe("q");
|
||||
expect(err.groupName).toBe("g");
|
||||
expect(err.contactId).toBe("c");
|
||||
});
|
||||
|
||||
it("omitted discriminants stay undefined", { timeout: 5000 }, () => {
|
||||
const err = new MacOSContactsError(
|
||||
MACOS_CONTACTS_CODES.INVALID_INPUT,
|
||||
"x"
|
||||
);
|
||||
expect(err.query).toBeUndefined();
|
||||
expect(err.groupName).toBeUndefined();
|
||||
expect(err.contactId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/macos-contacts`. Every public
|
||||
* failure path raises a {@link MacOSContactsError} carrying one of these
|
||||
* codes; consumers dispatch on the namespaced string
|
||||
* (`"MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED"`).
|
||||
*/
|
||||
export const MACOS_CONTACTS_CODES = defineCodes("MACOS_CONTACTS", [
|
||||
"INVALID_INPUT",
|
||||
"CONTACTS_NOT_FOUND",
|
||||
"CONTACTS_PERMISSION_DENIED",
|
||||
"CONTACTS_ACCESS_FAILED",
|
||||
"CONTACTS_PARSE_FAILED",
|
||||
"CONTACTS_UNAVAILABLE",
|
||||
] as const);
|
||||
|
||||
/** Union of every registered code. */
|
||||
export type MacOSContactsErrorCode =
|
||||
(typeof MACOS_CONTACTS_CODES)[keyof typeof MACOS_CONTACTS_CODES];
|
||||
|
||||
/** Options bundle accepted by the {@link MacOSContactsError} constructor. */
|
||||
export interface MacOSContactsErrorOptions extends SuaveplanErrorOptions {
|
||||
/** Search query that triggered the error, when applicable. */
|
||||
readonly query?: string;
|
||||
/** Group name that triggered the error, when applicable. */
|
||||
readonly groupName?: string;
|
||||
/** Contact identifier that triggered the error, when applicable. */
|
||||
readonly contactId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated error class for `@suaveplan/macos-contacts`. Extends
|
||||
* `SuaveplanError` so `instanceof` narrows `code` to
|
||||
* {@link MacOSContactsErrorCode}.
|
||||
*/
|
||||
export class MacOSContactsError extends SuaveplanError<MacOSContactsErrorCode> {
|
||||
public readonly query?: string;
|
||||
public readonly groupName?: string;
|
||||
public readonly contactId?: string;
|
||||
|
||||
public constructor(
|
||||
code: MacOSContactsErrorCode,
|
||||
message: string,
|
||||
options: MacOSContactsErrorOptions = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
if (options.query !== undefined) this.query = options.query;
|
||||
if (options.groupName !== undefined) this.groupName = options.groupName;
|
||||
if (options.contactId !== undefined) this.contactId = options.contactId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
MacOSContactsErrorCode,
|
||||
MacOSContactsErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
MACOS_CONTACTS_CODES,
|
||||
MacOSContactsError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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,61 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createDefaultAppleScriptRunner } from "./executor.js";
|
||||
|
||||
describe("executor", () => {
|
||||
it("default runner rejects when osascript fails", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const runner = createDefaultAppleScriptRunner({
|
||||
timeoutMs: 5000,
|
||||
binary: "/bin/false",
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runner.run("anything");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("default runner resolves with stdout when the binary succeeds", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const runner = createDefaultAppleScriptRunner({
|
||||
timeoutMs: 5000,
|
||||
binary: "/bin/echo",
|
||||
});
|
||||
const out = await runner.run("ignored");
|
||||
expect(out).toContain("ignored");
|
||||
});
|
||||
|
||||
it("default runner honours AbortSignal", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const runner = createDefaultAppleScriptRunner({
|
||||
timeoutMs: 5000,
|
||||
binary: "/bin/echo",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runner.run("ignored", controller.signal);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("default runner honours the maxBufferBytes override", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const runner = createDefaultAppleScriptRunner({
|
||||
timeoutMs: 5000,
|
||||
maxBufferBytes: 1024,
|
||||
binary: "/bin/echo",
|
||||
});
|
||||
const out = await runner.run("ignored");
|
||||
expect(typeof out).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* `AppleScriptRunner` is the injectable seam for invoking the platform
|
||||
* `osascript` binary. The bridge depends on this interface (not on
|
||||
* `node:child_process` directly) so tests run on any platform by injecting a
|
||||
* fake runner that returns fixture strings — no mocking framework, no macOS,
|
||||
* no shell access required.
|
||||
*/
|
||||
|
||||
import * as cp from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
/** Callback contract for running an AppleScript payload. */
|
||||
export interface AppleScriptRunner {
|
||||
/**
|
||||
* Run the supplied AppleScript and resolve with stdout. Rejections SHALL
|
||||
* surface to the caller so the bridge can map them through the typed
|
||||
* error-mapping rule. Implementations SHOULD honour `signal` to abort an
|
||||
* in-flight invocation when the caller cancels.
|
||||
*/
|
||||
run(script: string, signal?: AbortSignal): Promise<string>;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createDefaultAppleScriptRunner}. */
|
||||
export interface DefaultAppleScriptRunnerOptions {
|
||||
/** Timeout in milliseconds applied to every invocation. */
|
||||
readonly timeoutMs: number;
|
||||
/** Maximum stdout buffer in bytes. Defaults to 10 MiB. */
|
||||
readonly maxBufferBytes?: number;
|
||||
/**
|
||||
* Binary to invoke. Defaults to `"osascript"`. Override only for
|
||||
* test harnesses that need to point at a deterministic stand-in
|
||||
* (e.g. `"/bin/false"` to assert the failure path).
|
||||
*/
|
||||
readonly binary?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
||||
const runProcess = promisify(cp.execFile);
|
||||
|
||||
/**
|
||||
* Build an {@link AppleScriptRunner} backed by `node:child_process` against
|
||||
* the platform `osascript` binary. The returned runner uses `execFile`
|
||||
* (argv-based, no shell) so the AppleScript payload is never subject to
|
||||
* shell interpolation, threads the caller's `AbortSignal` through to the
|
||||
* spawned process, and resolves with stdout.
|
||||
*/
|
||||
export function createDefaultAppleScriptRunner(
|
||||
options: DefaultAppleScriptRunnerOptions
|
||||
): AppleScriptRunner {
|
||||
const timeoutMs = options.timeoutMs;
|
||||
const maxBuffer = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
||||
const binary = options.binary ?? "osascript";
|
||||
return {
|
||||
async run(script: string, signal?: AbortSignal): Promise<string> {
|
||||
const { stdout } = await runProcess(binary, ["-e", script], {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer,
|
||||
encoding: "utf-8",
|
||||
signal,
|
||||
});
|
||||
return stdout;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
AppleScriptRunner,
|
||||
DefaultAppleScriptRunnerOptions,
|
||||
} from "./executor.js";
|
||||
export { createDefaultAppleScriptRunner } from "./executor.js";
|
||||
55
packages/commerce/contacts/macos-contacts/src/index.ts
Normal file
55
packages/commerce/contacts/macos-contacts/src/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `@suaveplan/macos-contacts` — universal bridge to macOS Contacts.app
|
||||
* via the platform `osascript` binary.
|
||||
*/
|
||||
|
||||
export {
|
||||
getAllContactsScript,
|
||||
getGroupMembersScript,
|
||||
getGroupsScript,
|
||||
searchContactsScript,
|
||||
} from "./applescript/applescript.js";
|
||||
export {
|
||||
MacOSContactsBridge,
|
||||
type MacOSContactsBridgeDeps,
|
||||
} from "./bridge/bridge.js";
|
||||
// Error codes — eager side-effect registration ensures consumers see the
|
||||
// codes the moment the package is imported.
|
||||
export {
|
||||
MACOS_CONTACTS_CODES,
|
||||
MacOSContactsError,
|
||||
type MacOSContactsErrorCode,
|
||||
type MacOSContactsErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
type AppleScriptRunner,
|
||||
createDefaultAppleScriptRunner,
|
||||
type DefaultAppleScriptRunnerOptions,
|
||||
} from "./executor/executor.js";
|
||||
export {
|
||||
getMacOSContactsInstruments,
|
||||
type MacOSContactsInstruments,
|
||||
resetMacOSContactsInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
export {
|
||||
normalizePhone,
|
||||
parseContacts,
|
||||
parseGroups,
|
||||
} from "./parser/parser.js";
|
||||
export {
|
||||
contactIdSchema,
|
||||
groupNameSchema,
|
||||
macOSContactsConfigSchema,
|
||||
parseOrThrow,
|
||||
searchQuerySchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export type {
|
||||
ContactAddress,
|
||||
ContactEmail,
|
||||
ContactGroup,
|
||||
ContactPhone,
|
||||
MacOSContact,
|
||||
MacOSContactsConfig,
|
||||
SyncError,
|
||||
SyncResult,
|
||||
} from "./types/macos-contacts.types.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Spec-conformance tests for `@suaveplan/macos-contacts`. Pins barrel
|
||||
* shape, subpath exports, error-code wiring, and the §7 ban on direct
|
||||
* `vitest` / `bun:test` imports. Failure of any test here means an OpenSpec
|
||||
* requirement has drifted from the implementation.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MACOS_CONTACTS_CODES } from "./error-codes/error-codes.js";
|
||||
|
||||
const PKG_ROOT = resolve(__dirname, "..");
|
||||
const SRC_ROOT = join(PKG_ROOT, "src");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
const stat = statSync(p);
|
||||
if (stat.isDirectory()) walk(p, out);
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("macos-contacts conformance", () => {
|
||||
it("every feature index.ts uses explicit named exports", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const offenders: string[] = [];
|
||||
const files = walk(SRC_ROOT).filter(
|
||||
(f) =>
|
||||
f.endsWith("/index.ts") && relative(SRC_ROOT, f) !== "index.ts"
|
||||
);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf-8");
|
||||
if (/^\s*export\s+(type\s+)?\*\s+from/m.test(text)) {
|
||||
offenders.push(relative(SRC_ROOT, file));
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("every declared subpath export resolves to a real source file", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k.startsWith("./") && k !== "./package.json"
|
||||
);
|
||||
expect(subpaths.length).toBeGreaterThanOrEqual(7);
|
||||
for (const subpath of subpaths) {
|
||||
if (subpath === ".") continue;
|
||||
const featurePath = subpath.replace(/^\.\//, "");
|
||||
const srcFile = join(SRC_ROOT, `${featurePath}.ts`);
|
||||
expect(() => statSync(srcFile), srcFile).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("every MACOS_CONTACTS_CODES code is referenced in non-test src", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codeNames = Object.keys(MACOS_CONTACTS_CODES);
|
||||
const files = walk(SRC_ROOT).filter(
|
||||
(f) =>
|
||||
f.endsWith(".ts") &&
|
||||
!f.endsWith(".test.ts") &&
|
||||
!f.endsWith(".spec-conformance.test.ts") &&
|
||||
!f.endsWith("error-codes.ts")
|
||||
);
|
||||
const blob = files.map((f) => readFileSync(f, "utf-8")).join("\n");
|
||||
const missing: string[] = [];
|
||||
for (const name of codeNames) {
|
||||
if (!new RegExp(`\\b${name}\\b`).test(blob)) missing.push(name);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("OTel instrument bundle exposes the eight declared instruments", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mod = await import("./otel/instruments.js");
|
||||
mod.resetMacOSContactsInstruments();
|
||||
const bundle = mod.getMacOSContactsInstruments();
|
||||
const keys = Object.keys(bundle);
|
||||
expect(keys.length).toBe(8);
|
||||
});
|
||||
|
||||
it("no test file imports from vitest or bun:test directly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const offenders: string[] = [];
|
||||
const files = walk(SRC_ROOT).filter((f) => f.endsWith(".test.ts"));
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf-8");
|
||||
if (/from\s+['"](vitest|bun:test)['"]/.test(text)) {
|
||||
offenders.push(relative(SRC_ROOT, file));
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("root barrel exports MACOS_CONTACTS_CODES", { timeout: 5000 }, () => {
|
||||
const index = readFileSync(join(SRC_ROOT, "index.ts"), "utf-8");
|
||||
expect(index).toMatch(/MACOS_CONTACTS_CODES/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getMacOSContactsInstruments,
|
||||
type MacOSContactsInstruments,
|
||||
resetMacOSContactsInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,164 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
type DataPoint,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MacOSContactsBridge } from "../bridge/bridge.js";
|
||||
import type { AppleScriptRunner } from "../executor/executor.js";
|
||||
import {
|
||||
getMacOSContactsInstruments,
|
||||
resetMacOSContactsInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
interface MetricsSnapshot {
|
||||
readonly counters: Record<string, number>;
|
||||
readonly histograms: Record<string, number>;
|
||||
readonly counterAttrs: Record<string, Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
async function collect(
|
||||
exporter: InMemoryMetricExporter,
|
||||
provider: MeterProvider
|
||||
): Promise<MetricsSnapshot> {
|
||||
await provider.forceFlush();
|
||||
const collected = exporter.getMetrics();
|
||||
const counters: Record<string, number> = {};
|
||||
const histograms: Record<string, number> = {};
|
||||
const counterAttrs: Record<string, Array<Record<string, unknown>>> = {};
|
||||
for (const rm of collected) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
for (const dp of m.dataPoints as DataPoint<unknown>[]) {
|
||||
const v = dp.value as { count?: number } | number;
|
||||
if (typeof v === "number") {
|
||||
counters[m.descriptor.name] =
|
||||
(counters[m.descriptor.name] ?? 0) + v;
|
||||
const list =
|
||||
counterAttrs[m.descriptor.name] ??
|
||||
(counterAttrs[m.descriptor.name] = []);
|
||||
list.push(dp.attributes as Record<string, unknown>);
|
||||
} else if (v && typeof v.count === "number") {
|
||||
histograms[m.descriptor.name] =
|
||||
(histograms[m.descriptor.name] ?? 0) + v.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { counters, histograms, counterAttrs };
|
||||
}
|
||||
|
||||
function fixtureContact(id: string, first: string, last: string): string {
|
||||
return [
|
||||
`${id}|||${first}|||${last}|||Org||| ||| |||false`,
|
||||
`PHONES:mobile:+1555${id.padStart(7, "0")};;;`,
|
||||
`EMAILS:work:${first.toLowerCase()}@test.com;;;`,
|
||||
"ADDRESSES:",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const multiFixture = [
|
||||
fixtureContact("c1", "Alice", "Smith"),
|
||||
"---CONTACT---",
|
||||
fixtureContact("c2", "Bob", "Jones"),
|
||||
].join("\n");
|
||||
const groupsFixture = ["g1|||Family|||3"].join("\n");
|
||||
|
||||
function multiplexedRunner(
|
||||
behaviour: Record<string, string | Error>
|
||||
): AppleScriptRunner {
|
||||
return {
|
||||
async run(script: string): Promise<string> {
|
||||
for (const [key, value] of Object.entries(behaviour)) {
|
||||
if (script.includes(key)) {
|
||||
if (value instanceof Error) throw value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected script: ${script}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("otel/instruments", () => {
|
||||
let provider: MeterProvider;
|
||||
let exporter: InMemoryMetricExporter;
|
||||
|
||||
beforeEach(() => {
|
||||
exporter = new InMemoryMetricExporter(0);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.disable();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetMacOSContactsInstruments();
|
||||
}, 5000);
|
||||
|
||||
it("getMacOSContactsInstruments returns the same bundle on subsequent calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getMacOSContactsInstruments();
|
||||
const b = getMacOSContactsInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("every counter and histogram fires from the bridge", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = multiplexedRunner({
|
||||
"every person": multiFixture,
|
||||
"name contains": multiFixture,
|
||||
"every group": groupsFixture,
|
||||
"people of targetGroup": multiFixture,
|
||||
});
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
await bridge.getAllContacts();
|
||||
await bridge.searchContacts("Alice");
|
||||
await bridge.getContactById("c1");
|
||||
await bridge.getGroups();
|
||||
await bridge.getGroupMembers("Family");
|
||||
await bridge.sync();
|
||||
|
||||
const snap = await collect(exporter, provider);
|
||||
expect(snap.counters["macos_contacts.list_total"]).toBeGreaterThan(0);
|
||||
expect(snap.counters["macos_contacts.search_total"]).toBeGreaterThan(0);
|
||||
expect(snap.counters["macos_contacts.get_total"]).toBeGreaterThan(0);
|
||||
expect(
|
||||
snap.counters["macos_contacts.groups_list_total"]
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
snap.counters["macos_contacts.group_members_total"]
|
||||
).toBeGreaterThan(0);
|
||||
expect(snap.counters["macos_contacts.sync_total"]).toBeGreaterThan(0);
|
||||
expect(
|
||||
snap.histograms["macos_contacts.applescript_duration_ms"]
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
snap.histograms["macos_contacts.sync_duration_ms"]
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("failure path emits outcome=permission_denied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner: AppleScriptRunner = {
|
||||
async run(): Promise<string> {
|
||||
throw new Error("not authorized");
|
||||
},
|
||||
};
|
||||
const bridge = new MacOSContactsBridge({}, { runner });
|
||||
try {
|
||||
await bridge.getAllContacts();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
const snap = await collect(exporter, provider);
|
||||
const attrs = snap.counterAttrs["macos_contacts.list_total"] ?? [];
|
||||
expect(attrs.some((a) => a.outcome === "permission_denied")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`.
|
||||
*
|
||||
* Counters partition by `outcome` (`"success" | "permission_denied" |
|
||||
* "access_failed"`) so dashboards can distinguish revoked-permission
|
||||
* incidents from transient `osascript` failures without scraping logs.
|
||||
* Histograms record in milliseconds (`unit: "ms"`).
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
let cached: MacOSContactsInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the macOS-contacts instruments bundle against the
|
||||
* current global `MeterProvider`. Call {@link resetMacOSContactsInstruments}
|
||||
* in test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getMacOSContactsInstruments(): MacOSContactsInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/macos-contacts");
|
||||
cached = {
|
||||
listTotal: meter.createCounter("macos_contacts.list_total", {
|
||||
description:
|
||||
"Total getAllContacts invocations, partitioned by outcome",
|
||||
}),
|
||||
searchTotal: meter.createCounter("macos_contacts.search_total", {
|
||||
description:
|
||||
"Total searchContacts invocations, partitioned by outcome",
|
||||
}),
|
||||
getTotal: meter.createCounter("macos_contacts.get_total", {
|
||||
description:
|
||||
"Total getContactById invocations, partitioned by outcome",
|
||||
}),
|
||||
groupsListTotal: meter.createCounter(
|
||||
"macos_contacts.groups_list_total",
|
||||
{
|
||||
description:
|
||||
"Total getGroups invocations, partitioned by outcome",
|
||||
}
|
||||
),
|
||||
groupMembersTotal: meter.createCounter(
|
||||
"macos_contacts.group_members_total",
|
||||
{
|
||||
description:
|
||||
"Total getGroupMembers invocations, partitioned by outcome",
|
||||
}
|
||||
),
|
||||
syncTotal: meter.createCounter("macos_contacts.sync_total", {
|
||||
description: "Total sync invocations, partitioned by outcome",
|
||||
}),
|
||||
applescriptDurationMs: meter.createHistogram(
|
||||
"macos_contacts.applescript_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration of each AppleScriptRunner.run invocation",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
syncDurationMs: meter.createHistogram(
|
||||
"macos_contacts.sync_duration_ms",
|
||||
{
|
||||
description: "Duration of MacOSContactsBridge.sync",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getMacOSContactsInstruments} binds against the global
|
||||
* `MeterProvider` that is in effect at that point.
|
||||
*/
|
||||
export function resetMacOSContactsInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
normalizePhone,
|
||||
parseContacts,
|
||||
parseGroups,
|
||||
} from "./parser.js";
|
||||
@@ -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).
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { normalizePhone, parseContacts, parseGroups } from "./parser.js";
|
||||
|
||||
describe("parser", () => {
|
||||
describe("parseContacts", () => {
|
||||
it("parseContacts parses a single contact from raw output", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = [
|
||||
"abc-123|||John|||Doe|||Acme Corp|||1990-01-15|||Some notes|||true",
|
||||
"PHONES:mobile:+1234567890;;;work:+0987654321;;;",
|
||||
"EMAILS:home:john@example.com;;;work:john@acme.com;;;",
|
||||
"ADDRESSES:home:123 Main St:Springfield:IL:62701:US;;;",
|
||||
].join("\n");
|
||||
const contacts = parseContacts(raw);
|
||||
expect(contacts).toHaveLength(1);
|
||||
const c = contacts[0];
|
||||
if (!c) throw new Error("expected contact");
|
||||
expect(c.id).toBe("abc-123");
|
||||
expect(c.firstName).toBe("John");
|
||||
expect(c.lastName).toBe("Doe");
|
||||
expect(c.organization).toBe("Acme Corp");
|
||||
expect(c.birthday).toBe("1990-01-15");
|
||||
expect(c.notes).toBe("Some notes");
|
||||
expect(c.imageAvailable).toBe(true);
|
||||
expect(c.phoneNumbers).toHaveLength(2);
|
||||
expect(c.phoneNumbers[0]?.label).toBe("mobile");
|
||||
expect(c.phoneNumbers[0]?.number).toBe("+1234567890");
|
||||
expect(c.emailAddresses).toHaveLength(2);
|
||||
expect(c.emailAddresses[0]?.address).toBe("john@example.com");
|
||||
expect(c.postalAddresses).toHaveLength(1);
|
||||
expect(c.postalAddresses?.[0]?.city).toBe("Springfield");
|
||||
});
|
||||
|
||||
it("parseContacts parses multiple contacts separated by delimiter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = [
|
||||
"id-1|||Alice|||Smith||| ||| |||notes1|||false",
|
||||
"PHONES:",
|
||||
"EMAILS:home:alice@example.com;;;",
|
||||
"ADDRESSES:",
|
||||
"---CONTACT---",
|
||||
"id-2|||Bob|||Jones|||Corp||| ||| |||false",
|
||||
"PHONES:mobile:5551234;;;",
|
||||
"EMAILS:",
|
||||
"ADDRESSES:",
|
||||
].join("\n");
|
||||
const contacts = parseContacts(raw);
|
||||
expect(contacts).toHaveLength(2);
|
||||
expect(contacts[0]?.firstName).toBe("Alice");
|
||||
expect(contacts[1]?.firstName).toBe("Bob");
|
||||
});
|
||||
|
||||
it("parseContacts returns empty array for empty input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseContacts("")).toEqual([]);
|
||||
expect(parseContacts(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parseContacts skips malformed contact blocks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = [
|
||||
"id-1|||Alice|||Smith||| ||| |||notes|||false",
|
||||
"PHONES:",
|
||||
"EMAILS:",
|
||||
"ADDRESSES:",
|
||||
"---CONTACT---",
|
||||
"bad-data",
|
||||
].join("\n");
|
||||
const contacts = parseContacts(raw);
|
||||
expect(contacts).toHaveLength(1);
|
||||
expect(contacts[0]?.firstName).toBe("Alice");
|
||||
});
|
||||
|
||||
it("parseContacts handles contacts with no optional fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = [
|
||||
"id-x|||Jane|||Doe||| ||| ||| |||false",
|
||||
"PHONES:",
|
||||
"EMAILS:",
|
||||
"ADDRESSES:",
|
||||
].join("\n");
|
||||
const contacts = parseContacts(raw);
|
||||
expect(contacts).toHaveLength(1);
|
||||
expect(contacts[0]?.organization).toBeUndefined();
|
||||
expect(contacts[0]?.birthday).toBeUndefined();
|
||||
expect(contacts[0]?.phoneNumbers).toEqual([]);
|
||||
expect(contacts[0]?.emailAddresses).toEqual([]);
|
||||
});
|
||||
|
||||
it("parseContacts skips malformed phone/email/address items inside a block", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = [
|
||||
"id|||A|||B||| ||| ||| |||false",
|
||||
"PHONES:badonly;;;mobile:+1;;;",
|
||||
"EMAILS:badonly;;;home:a@b.com;;;",
|
||||
"ADDRESSES:badonly;;;home:1:2:3:4:5;;;",
|
||||
].join("\n");
|
||||
const c = parseContacts(raw)[0];
|
||||
if (!c) throw new Error("expected contact");
|
||||
expect(c.phoneNumbers).toHaveLength(1);
|
||||
expect(c.emailAddresses).toHaveLength(1);
|
||||
expect(c.postalAddresses).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parseContacts tolerates empty PHONES/EMAILS/ADDRESSES sections", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = [
|
||||
"id|||A|||B||| ||| ||| |||false",
|
||||
"PHONES:",
|
||||
"EMAILS:",
|
||||
"ADDRESSES:",
|
||||
"",
|
||||
].join("\n");
|
||||
const c = parseContacts(raw)[0];
|
||||
if (!c) throw new Error("expected contact");
|
||||
expect(c.phoneNumbers).toEqual([]);
|
||||
expect(c.emailAddresses).toEqual([]);
|
||||
expect(c.postalAddresses).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseContacts ignores empty interior lines and unknown prefixes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = [
|
||||
"id|||A|||B||| ||| ||| |||false",
|
||||
"",
|
||||
"UNKNOWN:something",
|
||||
"PHONES:mobile:+1;;;",
|
||||
"",
|
||||
"EMAILS:home:a@b.com;;;",
|
||||
"ADDRESSES:home:::::US;;;",
|
||||
].join("\n");
|
||||
const c = parseContacts(raw)[0];
|
||||
if (!c) throw new Error("expected contact");
|
||||
expect(c.phoneNumbers).toHaveLength(1);
|
||||
expect(c.postalAddresses).toHaveLength(1);
|
||||
expect(c.postalAddresses?.[0]?.label).toBe("home");
|
||||
expect(c.postalAddresses?.[0]?.street).toBeUndefined();
|
||||
expect(c.postalAddresses?.[0]?.city).toBeUndefined();
|
||||
expect(c.postalAddresses?.[0]?.country).toBe("US");
|
||||
});
|
||||
|
||||
it("parseContacts handles addresses with only a label (no other fields)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = [
|
||||
"id|||A|||B||| ||| ||| |||false",
|
||||
"PHONES:",
|
||||
"EMAILS:",
|
||||
"ADDRESSES:home:;;;",
|
||||
].join("\n");
|
||||
const c = parseContacts(raw)[0];
|
||||
if (!c) throw new Error("expected contact");
|
||||
expect(c.postalAddresses).toHaveLength(1);
|
||||
expect(c.postalAddresses?.[0]?.label).toBe("home");
|
||||
expect(c.postalAddresses?.[0]?.country).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseContacts skips blocks whose header has too few fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = "only|||two|||fields";
|
||||
expect(parseContacts(raw)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGroups", () => {
|
||||
it("parseGroups parses group output", { timeout: 5000 }, () => {
|
||||
const raw = ["g1|||Family|||5", "g2|||Work|||12"].join("\n");
|
||||
const groups = parseGroups(raw);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]?.id).toBe("g1");
|
||||
expect(groups[0]?.name).toBe("Family");
|
||||
expect(groups[0]?.memberCount).toBe(5);
|
||||
expect(groups[1]?.memberCount).toBe(12);
|
||||
});
|
||||
|
||||
it("parseGroups returns empty array for empty input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseGroups("")).toEqual([]);
|
||||
expect(parseGroups(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parseGroups skips malformed group lines", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = "g1|||Family|||5\nbadline\ng2|||Work|||3";
|
||||
expect(parseGroups(raw)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parseGroups falls back to 0 when memberCount is not numeric", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = "g1|||X|||nope";
|
||||
const groups = parseGroups(raw);
|
||||
expect(groups[0]?.memberCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePhone", () => {
|
||||
it("normalizePhone strips formatting and adds +", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(normalizePhone("(555) 123-4567")).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("normalizePhone preserves existing + prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(normalizePhone("+44 20 7946 0958")).toBe("+442079460958");
|
||||
});
|
||||
|
||||
it("normalizePhone handles already clean numbers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(normalizePhone("+12025551234")).toBe("+12025551234");
|
||||
});
|
||||
|
||||
it("normalizePhone returns empty string for empty input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(normalizePhone("")).toBe("");
|
||||
expect(normalizePhone(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("normalizePhone returns empty string for non-digit input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(normalizePhone("abc")).toBe("");
|
||||
});
|
||||
|
||||
it("normalizePhone handles 11+ digit numbers without +", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(normalizePhone("12025551234")).toBe("+12025551234");
|
||||
});
|
||||
});
|
||||
});
|
||||
199
packages/commerce/contacts/macos-contacts/src/parser/parser.ts
Normal file
199
packages/commerce/contacts/macos-contacts/src/parser/parser.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Free-function parser for raw `osascript` output. The upstream surface was a
|
||||
* static-only class with a `// biome-ignore lint/complexity/noStaticOnlyClass`
|
||||
* suppression; genesis ships these as plain functions so no suppression is
|
||||
* needed and tree-shaking is finer-grained.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ContactAddress,
|
||||
ContactEmail,
|
||||
ContactGroup,
|
||||
ContactPhone,
|
||||
MacOSContact,
|
||||
} from "../types/macos-contacts.types.js";
|
||||
|
||||
const CONTACT_DELIMITER = "---CONTACT---";
|
||||
const FIELD_DELIMITER = "|||";
|
||||
const ITEM_DELIMITER = ";;;";
|
||||
|
||||
/**
|
||||
* Parse raw `osascript` output into an array of {@link MacOSContact} objects.
|
||||
* Expected format per contact (blocks delimited by `---CONTACT---`):
|
||||
*
|
||||
* ```text
|
||||
* id|||firstName|||lastName|||org|||birthday|||notes|||imageAvailable
|
||||
* PHONES:label:number;;;label:number
|
||||
* EMAILS:label:address;;;label:address
|
||||
* ADDRESSES:label:street:city:state:zip:country;;;...
|
||||
* ```
|
||||
*/
|
||||
export function parseContacts(rawOutput: string): MacOSContact[] {
|
||||
if (!rawOutput || rawOutput.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
const blocks = rawOutput
|
||||
.split(CONTACT_DELIMITER)
|
||||
.map((b) => b.trim())
|
||||
.filter((b) => b.length > 0);
|
||||
return blocks
|
||||
.map((block) => parseContactBlock(block))
|
||||
.filter((c): c is MacOSContact => c !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw `osascript` output into an array of {@link ContactGroup} objects.
|
||||
* Expected format per line: `id|||name|||memberCount`.
|
||||
*/
|
||||
export function parseGroups(rawOutput: string): ContactGroup[] {
|
||||
if (!rawOutput || rawOutput.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
return rawOutput
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const parts = line.split(FIELD_DELIMITER);
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
// `parts.length >= 3` is guaranteed by the guard above.
|
||||
return {
|
||||
id: (parts[0] as string).trim(),
|
||||
name: (parts[1] as string).trim(),
|
||||
memberCount:
|
||||
Number.parseInt((parts[2] as string).trim(), 10) || 0,
|
||||
};
|
||||
})
|
||||
.filter((g): g is ContactGroup => g !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a phone number towards E.164 format. Strips non-digit characters
|
||||
* (except leading `+`), defaults to NANP (`+1`) for bare 10-digit numbers.
|
||||
*/
|
||||
export function normalizePhone(phone: string): string {
|
||||
if (!phone || phone.trim().length === 0) {
|
||||
return "";
|
||||
}
|
||||
const trimmed = phone.trim();
|
||||
const hasPlus = trimmed.startsWith("+");
|
||||
const digits = trimmed.replace(/[^\d]/g, "");
|
||||
if (digits.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (hasPlus) {
|
||||
return `+${digits}`;
|
||||
}
|
||||
if (digits.length === 10) {
|
||||
return `+1${digits}`;
|
||||
}
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
function parseContactBlock(block: string): MacOSContact | null {
|
||||
// `parseContacts` guarantees the block is non-empty when this runs;
|
||||
// `split("\n")` therefore always yields at least one entry whose first
|
||||
// element is the header. We still defensively bail when the header has
|
||||
// too few fields (malformed-block path).
|
||||
// `split("\n")` always yields ≥1 entry; `lines[0]` is therefore always
|
||||
// defined (it is the header line).
|
||||
const lines = block.split("\n").map((l) => l.trim());
|
||||
const firstLine = lines[0] as string;
|
||||
const mainParts = firstLine.split(FIELD_DELIMITER);
|
||||
if (mainParts.length < 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let phoneNumbers: ContactPhone[] = [];
|
||||
let emailAddresses: ContactEmail[] = [];
|
||||
let postalAddresses: ContactAddress[] | undefined;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
if (line.startsWith("PHONES:")) {
|
||||
phoneNumbers = parsePhones(line.substring(7));
|
||||
} else if (line.startsWith("EMAILS:")) {
|
||||
emailAddresses = parseEmails(line.substring(7));
|
||||
} else if (line.startsWith("ADDRESSES:")) {
|
||||
postalAddresses = parseAddresses(line.substring(10));
|
||||
}
|
||||
}
|
||||
|
||||
// `mainParts.length >= 7` is guaranteed by the guard above.
|
||||
const orgRaw = (mainParts[3] as string).trim();
|
||||
const bdayRaw = (mainParts[4] as string).trim();
|
||||
const notesRaw = (mainParts[5] as string).trim();
|
||||
const base: MacOSContact = {
|
||||
id: (mainParts[0] as string).trim(),
|
||||
firstName: (mainParts[1] as string).trim(),
|
||||
lastName: (mainParts[2] as string).trim(),
|
||||
imageAvailable: (mainParts[6] as string).trim() === "true",
|
||||
phoneNumbers,
|
||||
emailAddresses,
|
||||
...(orgRaw ? { organization: orgRaw } : {}),
|
||||
...(bdayRaw ? { birthday: bdayRaw } : {}),
|
||||
...(notesRaw ? { notes: notesRaw } : {}),
|
||||
...(postalAddresses && postalAddresses.length > 0
|
||||
? { postalAddresses }
|
||||
: {}),
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
function parsePhones(raw: string): ContactPhone[] {
|
||||
if (raw.trim().length === 0) return [];
|
||||
return raw
|
||||
.split(ITEM_DELIMITER)
|
||||
.map((item) => {
|
||||
const parts = item.split(":");
|
||||
if (parts.length < 2) return null;
|
||||
// `parts[0]` is always defined: `split(":")` returns ≥1 entry.
|
||||
return {
|
||||
label: (parts[0] as string).trim(),
|
||||
number: parts.slice(1).join(":").trim(),
|
||||
};
|
||||
})
|
||||
.filter((p): p is ContactPhone => p !== null);
|
||||
}
|
||||
|
||||
function parseEmails(raw: string): ContactEmail[] {
|
||||
if (raw.trim().length === 0) return [];
|
||||
return raw
|
||||
.split(ITEM_DELIMITER)
|
||||
.map((item) => {
|
||||
const parts = item.split(":");
|
||||
if (parts.length < 2) return null;
|
||||
return {
|
||||
label: (parts[0] as string).trim(),
|
||||
address: parts.slice(1).join(":").trim(),
|
||||
};
|
||||
})
|
||||
.filter((e): e is ContactEmail => e !== null);
|
||||
}
|
||||
|
||||
function parseAddresses(raw: string): ContactAddress[] {
|
||||
if (raw.trim().length === 0) return [];
|
||||
return raw
|
||||
.split(ITEM_DELIMITER)
|
||||
.map((item) => {
|
||||
const parts = item.split(":");
|
||||
if (parts.length < 2) return null;
|
||||
const street = parts[1]?.trim();
|
||||
const city = parts[2]?.trim();
|
||||
const state = parts[3]?.trim();
|
||||
const postalCode = parts[4]?.trim();
|
||||
const country = parts[5]?.trim();
|
||||
const out: ContactAddress = {
|
||||
label: (parts[0] as string).trim(),
|
||||
...(street ? { street } : {}),
|
||||
...(city ? { city } : {}),
|
||||
...(state ? { state } : {}),
|
||||
...(postalCode ? { postalCode } : {}),
|
||||
...(country ? { country } : {}),
|
||||
};
|
||||
return out;
|
||||
})
|
||||
.filter((a): a is ContactAddress => a !== null);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
contactIdSchema,
|
||||
groupNameSchema,
|
||||
macOSContactsConfigSchema,
|
||||
parseOrThrow,
|
||||
searchQuerySchema,
|
||||
} from "./schemas.js";
|
||||
@@ -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,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { MacOSContactsError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
contactIdSchema,
|
||||
groupNameSchema,
|
||||
macOSContactsConfigSchema,
|
||||
parseOrThrow,
|
||||
searchQuerySchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("schemas", () => {
|
||||
it("macOSContactsConfigSchema accepts a valid bundle", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = macOSContactsConfigSchema.parse({
|
||||
timeoutMs: 30_000,
|
||||
batchSize: 500,
|
||||
});
|
||||
expect(parsed.timeoutMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it("macOSContactsConfigSchema rejects non-positive timeoutMs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = macOSContactsConfigSchema.safeParse({ timeoutMs: -1 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("searchQuerySchema rejects empty / whitespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(searchQuerySchema.safeParse("").success).toBe(false);
|
||||
expect(searchQuerySchema.safeParse(" ").success).toBe(false);
|
||||
expect(searchQuerySchema.safeParse("a").success).toBe(true);
|
||||
});
|
||||
|
||||
it("groupNameSchema rejects empty / whitespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(groupNameSchema.safeParse("").success).toBe(false);
|
||||
expect(groupNameSchema.safeParse(" ").success).toBe(false);
|
||||
expect(groupNameSchema.safeParse("Family").success).toBe(true);
|
||||
});
|
||||
|
||||
it("contactIdSchema rejects empty / whitespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(contactIdSchema.safeParse("").success).toBe(false);
|
||||
expect(contactIdSchema.safeParse("c1").success).toBe(true);
|
||||
});
|
||||
|
||||
it("parseOrThrow returns the parsed value on success", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseOrThrow(z.string(), "x", "label");
|
||||
expect(out).toBe("x");
|
||||
});
|
||||
|
||||
it("parseOrThrow throws MacOSContactsError(INVALID_INPUT) with cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
parseOrThrow(searchQuerySchema, "", "search");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(MacOSContactsError);
|
||||
const err = caught as MacOSContactsError;
|
||||
expect(err.code).toBe("MACOS_CONTACTS.INVALID_INPUT");
|
||||
expect(err.cause).toBeInstanceOf(z.ZodError);
|
||||
expect(err.message).toContain("search input failed validation");
|
||||
});
|
||||
|
||||
it("parseOrThrow lists (root) when the failure has no path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
parseOrThrow(z.number(), "not-a-number", "label");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as MacOSContactsError).message).toContain("(root)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
MACOS_CONTACTS_CODES,
|
||||
MacOSContactsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Zod schema for {@link MacOSContactsConfig}. */
|
||||
export const macOSContactsConfigSchema = z.object({
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
batchSize: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for a non-empty search query string. */
|
||||
export const searchQuerySchema = z.string().trim().min(1);
|
||||
|
||||
/** Zod schema for a non-empty group name string. */
|
||||
export const groupNameSchema = z.string().trim().min(1);
|
||||
|
||||
/** Zod schema for a non-empty contact-id string. */
|
||||
export const contactIdSchema = z.string().trim().min(1);
|
||||
|
||||
/**
|
||||
* Parse `input` with `schema`; on failure raise a typed
|
||||
* `MacOSContactsError(INVALID_INPUT)` whose `cause` is the underlying
|
||||
* `ZodError` and whose message lists the failing paths.
|
||||
*/
|
||||
export function parseOrThrow<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
label: string
|
||||
): T {
|
||||
const result = schema.safeParse(input);
|
||||
if (result.success) return result.data;
|
||||
const issues = result.error.issues
|
||||
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
||||
.join("; ");
|
||||
throw new MacOSContactsError(
|
||||
MACOS_CONTACTS_CODES.INVALID_INPUT,
|
||||
`${label} input failed validation: ${issues}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Entity types for `@suaveplan/macos-contacts`. Shapes are preserved verbatim
|
||||
* from upstream so consumers migrating from `../suaveplan/packages/universal/macos-contacts/`
|
||||
* see no semantic drift on the data surface.
|
||||
*/
|
||||
|
||||
/** Phone number entry with label (e.g., "home", "work", "mobile"). */
|
||||
export interface ContactPhone {
|
||||
readonly label: string;
|
||||
readonly number: string;
|
||||
}
|
||||
|
||||
/** Email address entry with label. */
|
||||
export interface ContactEmail {
|
||||
readonly label: string;
|
||||
readonly address: string;
|
||||
}
|
||||
|
||||
/** Postal address entry with label and structured fields. */
|
||||
export interface ContactAddress {
|
||||
readonly label: string;
|
||||
readonly street?: string;
|
||||
readonly city?: string;
|
||||
readonly state?: string;
|
||||
readonly postalCode?: string;
|
||||
readonly country?: string;
|
||||
}
|
||||
|
||||
/** A contact record sourced from macOS Contacts.app. */
|
||||
export interface MacOSContact {
|
||||
readonly id: string;
|
||||
readonly firstName: string;
|
||||
readonly lastName: string;
|
||||
readonly organization?: string;
|
||||
readonly phoneNumbers: readonly ContactPhone[];
|
||||
readonly emailAddresses: readonly ContactEmail[];
|
||||
readonly postalAddresses?: readonly ContactAddress[];
|
||||
readonly birthday?: string;
|
||||
readonly notes?: string;
|
||||
readonly imageAvailable: boolean;
|
||||
}
|
||||
|
||||
/** A contact group from macOS Contacts.app. */
|
||||
export interface ContactGroup {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly memberCount: number;
|
||||
}
|
||||
|
||||
/** Configuration for the macOS contacts bridge. */
|
||||
export interface MacOSContactsConfig {
|
||||
readonly timeoutMs?: number;
|
||||
readonly batchSize?: number;
|
||||
}
|
||||
|
||||
/** An error that occurred during sync. */
|
||||
export interface SyncError {
|
||||
readonly contactId?: string;
|
||||
readonly message: string;
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
/** Result of a contact sync operation. */
|
||||
export interface SyncResult {
|
||||
readonly contacts: readonly MacOSContact[];
|
||||
readonly syncedAt: Date;
|
||||
readonly total: number;
|
||||
readonly errors: readonly SyncError[];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Polyfills for test environment — none required.
|
||||
export {};
|
||||
12
packages/commerce/contacts/macos-contacts/tsconfig.json
Normal file
12
packages/commerce/contacts/macos-contacts/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/commerce/contacts/macos-contacts/tsconfig.lib.json
Normal file
12
packages/commerce/contacts/macos-contacts/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/commerce/contacts/macos-contacts/vite.config.ts
Normal file
38
packages/commerce/contacts/macos-contacts/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/macos-contacts",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/commerce/contacts/macos-contacts/vitest.config.ts
Normal file
27
packages/commerce/contacts/macos-contacts/vitest.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user