Files
suaveplan/packages/commerce/contacts/macos-contacts/src/executor/executor.md
2026-05-27 23:29:53 +01:00

4.3 KiB

executor

(no submodules)

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

import { createDefaultAppleScriptRunner } from "@suaveplan/macos-contacts";

const runner = createDefaultAppleScriptRunner({ timeoutMs: 30_000 });
const stdout = await runner.run('return "hello"');

Usage: Advanced

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

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.